# Security Properties

Designed against the [OWASP Top 10 for Agentic AI](https://genai.owasp.org/resource/agentic-ai-threats-and-mitigations/).

| OWASP | Threat | Mitigation |
|-------|--------|------------|
| AG01 | Excessive agency | Three-tier permission model. Tier 1 (always allowed): workspace file access, web search. Tier 2 (first-use approval, remembered 30 days): external file access, cross-conversation message, and a curated allowlist of shell-inspection verbs (ls / cat / grep / stat / pwd / whoami / ... — no documented exec escape hatch). Tier 3 (always prompt): destructive ops, credential access, network requests, skill install, and every shell verb outside the Tier 2 allowlist — shell wrappers, language interpreters with -c/-e eval, and build/deploy tools all default to Tier 3. **Tier-2 approvals are per-permissions-store, not per-operator:** every operator that drives the same data dir reads the same `permissions.db` and inherits each other's Tier-2 approvals. Multi-operator deployments where the operators do not share a trust boundary (e.g., a shared workstation, a service account driven by several humans) should run separate `wirken` instances against separate `--data-dir` paths so each operator's approvals are scoped to their own session log. See [permissions-and-identity.md](permissions-and-identity.md). |
| AG02 | Code execution | Docker sandbox: ephemeral containers, no-network, 512MB memory, 256 PID limit, non-root user. gVisor sandbox: same constraints with kernel attack surface reduction via `runsc` runtime. Wasm sandbox: compiled skill modules run in Wasmtime with fuel-based CPU limits, no filesystem, no network. Shell exec timeout at 300s. **Operator-controlled escape:** `sandbox.json` set to `mode: off` runs `exec` directly on the host at the wirken UID. The host shell can then read and rewrite every trust file under the data dir (`tool_policy.json`, `sandbox.json`, `org.url`, `org-config-pubkey.pub`, `audit.db`, `vault.db`, `agents/<id>/identity.key`, `audit-alarms.log`); changes take effect at the next gateway start. The gateway emits a `tracing::warn!` at startup when `sandbox.json` is `mode: off`. Treat this as a deliberate operator opt-out, not a default posture. **MCP child trust:** MCP servers configured in `mcp.json` spawn at the wirken UID with no process sandbox. The agent's permission gate checks the MCP tool *name* but not the child's own behavior once spawned; a child can read `audit.db` / `vault.db` / identity keys, open outbound connections, or spawn further processes, all at the wirken UID. Install MCP servers only from trusted sources. The gateway lists configured MCP server names at startup so the inventory is operator-visible. **Egress scope:** the `exec` shell sink, MCP children, and the LLM HTTP client are outside the skill-set `egress.domains` allowlist. The host shell's `curl`/`wget`/etc., MCP server outbound connections, and the LLM client's provider endpoint are all reached without `EgressClient` mediation. Operators bound these at the OS layer (network namespace, container, firewall). See [egress.md](egress.md) for the configuration-side view of the same gap. |
| AG04 | Tool misuse | Tool inputs validated against JSON schema. Workspace path confinement: file operations are canonicalized and rejected if outside workspace boundary. **Workspace TOCTOU caveat:** files inside the agent workspace are read by the agent runtime and may be modified by any process running at the same UID between the agent's `read` and its subsequent use of the contents. Operators handling sensitive workspace contents should not run other processes at the wirken UID concurrently; the cap-std `Dir` confinement defends against the agent escaping the workspace, not against same-UID writers racing the agent inside it. |
| AG05 | Identity spoofing | Per-adapter Ed25519 challenge-response handshake over the local IPC transport (Unix domain sockets on Linux/macOS, Windows named pipes on Windows); signature covers `(domain || adapter_id || nonce)` so the signed payload binds the identity cryptographically, not only through the pubkey-registration lookup. After handshake the gateway resolves the adapter's channel from the registry and wraps it in `AuthenticatedChannel`; every inbound frame's self-declared channel is matched against that value, and cross-channel frames are rejected with an `adapter.channel_mismatch` audit event. Compile-time channel isolation: `SessionHandle<Telegram>` and `SessionHandle<Discord>` are different types; the compiler rejects cross-channel access. |
| AG07 | Multi-agent manipulation | Each channel adapter runs as a separate OS process. If an adapter is compromised, the blast radius is one channel. IPC boundary prevents lateral movement. Child agents spawned via `spawn_subagent` run under capability-attenuated ceilings: tool allowlist intersection, clamped permission tier, max rounds, max runtime, headless (no interactive approvals). Hard depth cap of 4 prevents nesting cycles. |
| AG08 | Runaway loops | Agent tool call loop capped at 20 rounds per turn. Child agents have a separate per-ceiling `max_rounds` budget. Shell exec timeout at 300s. Rate limiting on all sources including loopback, with no localhost exemption. |
| AG09 | Insufficient logging | Every agent action logged as a typed session event to an append-only, per-session hash-chained SQLite table before execution. Per-agent Ed25519 attestation signs the chain head after every turn. `wirken session verify` replays the session log offline and re-checks hashes. 90-day retention with configurable pruning. Real-time SIEM forwarding to Datadog, Splunk HEC, Microsoft Sentinel (Logs Ingestion API), or webhook. Two parallel pipes reach SIEM: the legacy `AuditEvent` flat-tuple stream on the `AuditWriter` mpsc flush path, and the typed-event stream that polls `session_events` via `SessionLog::get_since` when the operator sets `SiemConfig.typed_forwarding_enabled` (or any of `typed_include_variants` / `typed_exclude_variants` / `sentinel_typed`). The typed pipe forwards `AssistantToolCalls`, `ToolResult`, `HttpFetch`, `PermissionDenied`, `SkillPermissionDenied`, `SubagentSpawned`, `SubagentResult`, and `ChainHead` by default; PII-carrying and token-accounting variants are excluded unless the operator opts in. Sentinel takes a second DCR stream because its first stream is column-pinned; the other targets accept mixed-shape batches on one endpoint. See [siem-forwarder.md](siem-forwarder.md) for the wire envelopes. Prompt injection detection flags inbound messages with threat indicators. Permission denials logged with full context: tool, tier, agent, trigger message. **Egress dispatcher** wires operator-controlled post-execution hooks at the same trust tier as veto hooks: each tool's output bytes flow through the registered `HookType::Egress` chain before entering the LLM conversation, and a hook can `Allow`, `Replace`, or `Refuse` with the same Ed25519-authenticated handshake the veto path uses. Redacted bytes land on the `ToolResult` row verbatim; the paired `ToolOutputRedacted` row carries `original_sha256` only, never the original plaintext. See [enforcement-model.md](enforcement-model.md) for the audit-row shape. **Out-of-chain alarm sink:** continuous chain verification writes integrity alarms to `<data_dir>/audit-alarms.log` (mode 0o600, append-only, one JSON record per line) **before** attempting an in-chain `audit.chain_broken` row. The alarm log is the load-bearing record because an attacker who tampered the SQLite chain can also tamper any follow-up row in the same chain; the alarm-log file is independent of `audit.db` at the inode level, and surviving alarms are surfaced by `wirken doctor`. The independence claim is *inode-level only*: a same-UID attacker can rewrite both files. The in-chain `audit.chain_broken` row is now dispatched through the `AuditWriter`'s own mpsc channel, so the SIEM forwarder receives chain-tamper events alongside the rest of the legacy stream (it previously bypassed the channel and reached only SQLite). Tamper-resistance against a same-UID attacker therefore depends on SIEM corroboration of the alarm-log records (the writer's `tracing::error!` halt event, the per-alarm structured logs, and the now-forwarded in-chain `audit.chain_broken` rows are the channels for this). One known gap at the halt boundary: when the writer halts at `MAX_INTEGRITY_FAILURES`, the chain_broken event from the halt-triggering pass can be dropped before flush; the alarm log remains the canonical record. See [issue #107](https://github.com/gebruder/wirken/issues/107). |
| | Credential security | XChaCha20-Poly1305 encryption at rest, keyed from OS keychain on Linux/macOS (macOS Keychain / libsecret) with an age-encrypted file fallback. Windows uses the age-file backend; native Credential Manager / DPAPI integration is on the roadmap. Per-credential expiry and rotation. `secrecy` + `zeroize` ensure that logging or serializing a secret is a compile error. Key material zeroed after use. Credential ciphertexts bind the credential name into the AEAD additional-authenticated-data field so a row's ciphertext cannot be spliced into a different row and decrypt successfully. |
| | Keychain access serialization | On macOS, the wirken process serializes keychain compound operations (read-modify-write) with a process-local mutex around every `SecItemAdd` / `SecItemUpdate` / `SecItemCopyMatching` call. This closes the intra-process race where two threads observe the same pre-update value and both write. Cross-process keychain races are out of scope: a same-UID attacker is excluded by the local-attacker threat model floor in this doc, and an attacker without same-UID access cannot drive the macOS keychain permission gate at all. |
| | Signature verification | Every Ed25519 signature verification in the workspace uses `verify_strict` (not the legacy `verify`). This rejects non-canonical scalar encodings and small-order / mixed-order R points so a malleable signature variant cannot be substituted for a valid one. Covers session attestation, gateway and MCP-proxy handshakes, skill-registry self-signed bundles, registry-anchored skill keys, the org-config trust anchor, and the agent identity verifier. Where bundles carry `signed_at` + `max_age_seconds` fields (currently org-config), the gateway also enforces a freshness window so a captured-then-replayed signed bundle is rejected after expiry; see `WIRKEN_ALLOW_STALE_ORG_CONFIG` in the escape-hatch table for the documented opt-out. |
| | Transport security | HTTPS enforced at transport level for all LLM and Matrix connections (non-localhost). Cap'n Proto IPC with 16MB frame limit, 512M word traversal limit, 64-level nesting limit. |
| | Supply chain | Skill signatures verified against registry-provided Ed25519 key, not a bundled key. When the binary embeds a non-empty `wirken-registry-pubkey.pub` (compile-time root anchor), the registry-provided `signer_key` must additionally carry a `signer_key_delegation` Ed25519 signature by the bundled root before the per-entry signature is trusted. Default builds ship the pubkey file empty, so the registry-provided key is the sole anchor in that case; rotation of the bundled root requires rebuilding the binary. Release binaries include SHA-256 checksums; installer verifies before installing. CI runs clippy with `-D warnings`, fmt check, and full test suite on every push. |
| | Confidential inference | Tinfoil and Privatemode providers run open-source LLMs inside hardware TEEs (AMD SEV-SNP, Intel TDX, NVIDIA H100 CC). Prompts encrypted end-to-end, protected against software attacks on infrastructure. |

## Documented escape hatches

The defaults in the table above are deliberately strict. A handful of
environment variables and config knobs let an operator opt out of a
specific check. Each is opt-in (the default posture is the secure one),
emits a `tracing::warn!` line every time it engages, and leaves the
operator on the hook for whatever the relaxed posture loses.

The "operational footprint" column lists what the operator has to
arrange to flip the bit persistently. A one-shot env var on the
command line is not durable across reboots, so the practical answers
are a systemd `EnvironmentFile`, a shell `rc` export, or a wrapper
script that invokes `wirken run`.

| Knob | What it disables | Operational footprint |
|------|------------------|------------------------|
| `WIRKEN_ALLOW_UNSIGNED_ORG_CONFIG=1` | Skips Ed25519 verification of the org-config bundle pulled from the policy URL. The bundle is then trusted purely on the basis of HTTPS + URL pinning. | systemd `EnvironmentFile`, `~/.bashrc` export, or wrapper script. The gateway logs a warn line on every fetch. |
| `WIRKEN_ALLOW_UNSIGNED_SKILLS=1` | Allows `wirken skills install` to land a registry skill that has no `signer_key` in the index entry, removing the registry-anchored trust pin. | Same options. The install path emits a warn and the resulting bundle stays unsigned on disk. |
| `WIRKEN_ALLOW_UNSIGNED_MCP=1` | Allows the MCP proxy to spawn an `mcp.json` entry that carries no `signature` field even when the build was compiled with a populated `wirken-mcp-pubkey.pub` anchor. Invalid signatures are never bypassed. | Same options. Each spawn emits a `tracing::warn!` and lands an `mcp_entry_verified` audit row with signer `<unsigned-bypass>` on the `gateway-mcp` sentinel session. |
| `sandbox.json` set to `mode: off` | Runs the `exec` tool directly on the host at the wirken UID instead of inside a Docker / gVisor / Wasm sandbox. Without sandbox-level confinement the only remaining gate is the agent's permission tier. | Edit `<data_dir>/sandbox.json` once. Persists across restarts. Gateway warns at startup whenever it engages. |
| `WIRKEN_WEBCHAT_ALLOW_NO_ORIGIN=1` | Removes the `Origin` header check on the webchat `/api/chat` endpoint. Same-host non-browser callers (curl, scripts) can drive the agent; same-UID becomes the sole trust boundary. | Same options as the unsigned-* variables. Gateway emits a warn at startup. |
| `WIRKEN_AUDIT_VERIFY_EVERY_FLUSHES=N` | Sets the number of flush cycles between continuous chain-verification passes (default 100). Values above 10,000 effectively disable the in-process verifier so a chain break is only caught at the next operator-run `wirken audit verify`. | Same options. The value-above-10,000 case emits a warn at startup; smaller departures from default do not. |
| `WIRKEN_ALLOW_STALE_ORG_CONFIG=1` | Accepts a signed org-config bundle even when `signed_at + max_age_seconds < now` (or when `signed_at` is in the future, which usually means clock skew or replay). The signature itself is still verified; only the freshness window is bypassed. | Same options as the unsigned-* variables. Each bypass emits a warn carrying the bundle's age and the configured `max_age_seconds`. |
| `WIRKEN_ALLOW_UNVERIFIED=1` *(install-time only)* | Bypasses the release-binary checksum and signature verification step in `install.sh`. The downloaded binary is installed without any integrity check — the operator is trusting the network and the URL alone. | Set inline before invoking `install.sh`; not honored at runtime. Each bypass logs a `WARNING` line to install stderr; the gateway itself has no equivalent runtime override (re-running `install.sh` is the only way to re-engage the bypass). |

### Audit-writer halt counters are per-process

The audit writer halts after `MAX_INTEGRITY_FAILURES` (3) consecutive
failed chain-verification passes or `MAX_ALARM_WRITE_FAILURES` (3)
consecutive failed alarm-log writes. Both counters live in the
in-process flush loop and reset to zero on every gateway restart.

A noisy local attacker who can trigger a gateway restart could in
principle reset the counter between every two failures and avoid the
halt. We treat this as out of scope for the local-attacker threat
model: an attacker with restart authority over the gateway already
has UID-equivalent control of the host (systemd unit, service file,
or a trojaned wrapper script), at which point the audit chain itself
is no longer a defended boundary. Operators should monitor process
restart frequency through their normal SIEM channel; the halt event
emits a structured `tracing::error!` line that downstream pipelines
can alert on.

Persistent-state alternatives (e.g., a `.audit-halt-state.json` file
that survives restarts) were considered and rejected: the file would
be writable at the same UID and a restart-capable attacker could
delete it as easily as draining the in-process counter.

## NIST AI Risk Management Framework mapping

The [NIST AI RMF (AI 100-1)](https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf) takes the complementary view: how an organization governs, maps, measures, and manages AI risk across its lifecycle. The mapping below lists only RMF subcategories where Wirken ships a code-verifiable capability today. Subcategory text is defined in the companion [NIST AI RMF Playbook](https://airc.nist.gov/AI_RMF_Knowledge_Base/Playbook).

| Subcategory | Wirken Capability | Implementation |
|-------------|-------------------|----------------|
| GOVERN 1.1, policies and procedures | Three-tier permission model with first-use approval, expiry, and revocation | [`crates/gateway/src/permissions.rs:9-49`](../crates/gateway/src/permissions.rs) (tiers + `Action` enum), [`:105-138`](../crates/gateway/src/permissions.rs) (`tier()` resolver), [`:323-356`](../crates/gateway/src/permissions.rs) (`approve_by_key`, Tier 3 refusal at `:329-337`), [`:226-266`](../crates/gateway/src/permissions.rs) (`prune_non_tier2_shell_approvals` migration) |
| GOVERN 1.6, AI system inventory and lifecycle | Per-credential lifecycle metadata: `created_at`, `expires_at`, `last_used_at`, `rotation_due_at`; `rotate()` API | [`crates/vault/src/store.rs:16-19`](../crates/vault/src/store.rs) (struct fields), [`:25-30`](../crates/vault/src/store.rs) (`is_expired` / `rotation_due`), [`:270`](../crates/vault/src/store.rs) (`rotate`) |
| GOVERN 2.1, roles and responsibilities | Centralized org policy endpoint: provider, SIEM, MCP servers, sandbox mode pulled from a company URL and applied locally | [`crates/gateway/src/org.rs:139`](../crates/gateway/src/org.rs) (`apply_org_config`) |
| MAP 1.1, context and use cases | Model-agnostic provider routing across OpenAI, Anthropic, Gemini, Bedrock, Ollama, Tinfoil, Privatemode, and OpenAI-compatible endpoints | [`crates/agent/src/llm.rs:46-141`](../crates/agent/src/llm.rs) (per-provider factories: `openai`, `anthropic`, `gemini`, `bedrock`, `ollama`, `tinfoil`, `privatemode`) |
| MAP 5.1, impact and blast radius | Compile-time channel isolation: `SessionHandle<C: Channel>` is parameterized by a sealed marker type, so cross-channel access is a type error | [`crates/ipc/src/channel.rs:68`](../crates/ipc/src/channel.rs) (sealed `Channel` trait), [`:85-90`](../crates/ipc/src/channel.rs) (`SessionHandle<C>` typing rules); negative-test scaffolding at [`crates/ipc/src/tests.rs:20-30`](../crates/ipc/src/tests.rs) |
| MEASURE 2.5, output monitoring | Real-time SIEM forwarding to Datadog Log Intake, Splunk HEC, Microsoft Sentinel (Logs Ingestion API), or generic webhook, in addition to the local session log | [`crates/audit/src/siem.rs:26-43`](../crates/audit/src/siem.rs) (`SiemTarget` enum), [`:81-86`](../crates/audit/src/siem.rs) (forward dispatch) |
| MEASURE 2.6, security and resilience | Prompt injection detector flags inbound messages with threat metadata (role-switching, instruction overrides, base64 commands, tool-call injection); events are tagged in audit, not blocked | [`crates/gateway/src/injection_detect.rs:39-54`](../crates/gateway/src/injection_detect.rs) (`ThreatSeverity`), [`:94-148`](../crates/gateway/src/injection_detect.rs) (`InjectionDetector::scan`) |
| MEASURE 2.7, system logging | Append-only per-session hash-chained session log. Each event carries a SHA-256 leaf hash and chain hash. Per-agent Ed25519 attestation signs the chain head. `wirken session verify` replays events offline and re-checks hashes and tool results | [`crates/audit/src/session_log.rs:640-650`](../crates/audit/src/session_log.rs) (table schema), [`:659-770`](../crates/audit/src/session_log.rs) (`append`), [`:773-833`](../crates/audit/src/session_log.rs) (`verify`); attestation [`crates/agent/src/attestation.rs:86`](../crates/agent/src/attestation.rs) (`attest_session`), [`:134`](../crates/agent/src/attestation.rs) (`verify_session_attestations`) |
| MANAGE 1.3, risk mitigation | Three sandbox runtimes: Docker and gVisor confine the `exec` tool (no-network, 512 MB, 256 PID, non-root, 300 s); Wasmtime runs Wasm skills with fuel-based CPU limits, no filesystem, no network. See [sandbox-properties.md](sandbox-properties.md). | [`crates/agent/src/sandbox.rs:22-41`](../crates/agent/src/sandbox.rs) (`SandboxMode`), [`:287-319`](../crates/agent/src/sandbox.rs) (`build_host_config`); [`crates/agent/src/wasm_sandbox.rs:37-96`](../crates/agent/src/wasm_sandbox.rs) (`WasmSkill`), [`:62-67`](../crates/agent/src/wasm_sandbox.rs) (`consume_fuel`); plumbed through [`crates/agent/src/tool.rs:328-346`](../crates/agent/src/tool.rs) (no silent fallback) |
| MANAGE 2.2, input validation | Tool inputs declared as JSON Schema; filesystem tools constrained by `cap_std::Dir` rooted at the workspace, so any path traversal or absolute-path escape is rejected at the capability boundary | [`crates/agent/src/tool.rs:52`](../crates/agent/src/tool.rs) (`workspace_dir: Arc<cap_std::fs::Dir>`), [`:76`](../crates/agent/src/tool.rs) (`Dir::open_ambient_dir`); call sites [`:397`](../crates/agent/src/tool.rs) (`read_file`), [`:428`](../crates/agent/src/tool.rs) (`write_file`), [`:464`](../crates/agent/src/tool.rs) (`list_files`) |
| MANAGE 2.4, abuse and overuse limits | Auth rate limiter with no loopback exemption (5 failures / 60 s / 10-minute lockout) and a control-plane GCRA limiter via `governor` | [`crates/gateway/src/rate_limit.rs:10-23`](../crates/gateway/src/rate_limit.rs) (`AuthRateLimiter`, no-loopback-exemption note), [`:131-136`](../crates/gateway/src/rate_limit.rs) (`ControlPlaneRateLimiter` GCRA via `governor::RateLimiter`) |
