{"slug": "show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh", "title": "Show HN: Bulwark – a kernel read gate so coding agents can't read .env or .ssh", "summary": "Bulwark, an OS-level read gate for AI coding agents, blocks file reads at the kernel level by inode before bytes reach the agent, preventing access to protected files like .env or .ssh. It uses fanotify on Linux and Endpoint Security on macOS to enforce deny, allow, or consent policies, ensuring security boundaries are structural rather than prompt-dependent.", "body_md": "Gate an agent's file reads at the OS, by inode, before the bytes reach it.\n\nBulwark is an OS-level read gate for running AI coding agents on a developer\nmachine. Launch an agent under it; when any process in its tree tries to `open()`\n\na protected file, Bulwark applies a policy before the bytes reach the agent: deny,\nallow, or ask for consent. On Linux it uses fanotify permission events; on macOS,\nEndpoint Security. Hardened mode adds a Landlock floor so protected paths stay\ndenied even if the userspace gate dies.\n\n```\n# Run an agent, but deny it any read of your SSH keys.\nbrew install obstalabs/tap/bulwark\nsudo bulwark run --protect ~/.ssh -- claude\n#   the agent works normally; a read of a protected file -> Operation not permitted\n\n# macOS note: sudo resets PATH, so on Homebrew/Apple Silicon use the full form,\n# which also keeps the agent's own runtime (e.g. node) on PATH:\nsudo env \"PATH=$PATH\" \"$(which bulwark)\" run --protect ~/.ssh -- claude\n```\n\nIt supervises a process tree, installs a fanotify `FAN_OPEN_PERM`\n\nmark, and\ndecides each open by the file's **inode**, not its path string. A protected inode\nopened by the supervised tree is denied; the reader gets `EPERM`\n\n. Every decision\nis logged with the process ancestry that caused it.\n\nThe point is that the boundary is an OS-mediated checkpoint, not a rule in a prompt the agent can be talked past.\n\n**Not redaction.** It does not scrub or obfuscate secrets out of content. (That is[NeuroRouter](https://neurorouter.dev)'s job.) It stops the open from happening at all.**Not an authority/approval system.** It does not decide*who*may act. (That is Verdict's job.)**Not a network or routing gate.** It does not see or stop exfiltration over the wire. (That is[NeuroRouter](https://neurorouter.dev)'s job.)**Not protection for secrets already inside the allowed workspace.** Bulwark bounds what the tree can*reach*, not what it already holds.**Not protection against an unwrapped process.** Only the tree launched under`bulwark run`\n\nis gated — including work the agent*delegates*to a separate daemon. An agent that calls`docker run`\n\nhands the read to`dockerd`\n\n(a different tree), so it is not gated; the standard rule applies — don't give a confined agent a root-equivalent socket. The full tested boundary is in[docs/containment-boundaries.md](/obstalabs/bulwark/blob/main/docs/containment-boundaries.md).\n\nOne mechanism — gate the `open()`\n\n— with a few policies layered over it\n(deny-list, allow-list, consent, a crash-safe hardened floor).\n\n*Principiis obsta* — resist the beginning. A security boundary must not depend\non a prompt or a guess. Bulwark prevents the dangerous condition structurally\nrather than detecting it after the fact: the kernel is the evidence source, not\nthe agent's self-report; the decision is deterministic, not probabilistic; the\nidentity is the inode, not a string a symlink can forge.\n\nLinux (fanotify) and macOS (Endpoint Security). The gate needs root —\n\n`CAP_SYS_ADMIN`\n\nfor fanotify on Linux, root + Full Disk Access on macOS ([why, and how to set it up]). Prebuilt binaries:`brew install obstalabs/tap/bulwark`\n\nor the.[Releases page]\n\n```\ncargo build --release\n\n# Deny the supervised command any read of files under ~/.ssh, by inode.\nsudo ./target/release/bulwark run \\\n  --protect ~/.ssh \\\n  --receipts /tmp/bulwark-receipts.jsonl \\\n  -- bash -c 'cat ~/.ssh/id_ed25519'   # -> cat: Permission denied\n```\n\nA benign-named symlink to a protected file is still denied — the decision is by inode, so the name cannot lie.\n\n```\nbulwark run --protect <PATH> [--protect <PATH>...] [--receipts <FILE>] -- <CMD>...\n```\n\n`--protect <PATH>`\n\n— protect a file or directory by inode (directories expand to their immediate entries' inodes at launch). Repeatable, at least one required.`--receipts <FILE>`\n\n— append one JSON-line receipt per decision.- everything after\n`--`\n\nis the supervised command.\n\nReceipts are JSON lines:\n\n```\n{\"ts_ms\":1717377600000,\"pid\":959596,\"dev\":43,\"ino\":192214,\"decision\":\"deny\",\"path\":\"/tmp/guard/secret.env\",\"ancestry\":\"cat(959596) <- bash(959591)\",\"reason\":\"protected inode opened by supervised tree\"}\nbulwark run --protect <path> -- <cmd>\n      │\n      ├── resolve protected paths -> (dev, ino) set          [protect.rs]\n      ├── fanotify_init + FAN_OPEN_PERM mark (before fork)    [gate.rs]\n      ├── fork/exec <cmd>                                     [gate.rs]\n      └── event loop:\n            open() by tree  ──►  kernel pauses the open\n                                  fstat(event fd) -> (dev, ino)   [gate.rs]\n                                  in supervised tree?             [proctree.rs]\n                                  inode protected?                [protect.rs]\n                                    yes -> FAN_DENY (EPERM)\n                                    no  -> FAN_ALLOW\n                                  log decision + ancestry         [receipt.rs]\n```\n\n**Deny-list (default):** protect specific paths; everything else is allowed. With`--consent socket`\n\n, a protected open is held and an operator answers off-band (allow-once / allow-session / deny / deny-forever). Without it, protected opens are denied.**Allow-list (** default-deny for CI/CD — the agent may read only`--deny-all`\n\n):`--allow`\n\npaths plus the runtime base set, every other read denied, no prompt. See`docs/ci-allowlist.md`\n\n.**Hardened (** the allow-list enforced as a kernel-level Landlock floor instead of via the fanotify supervisor. Crash-safe — the restriction is in the kernel on the agent itself, so`--hardened`\n\n):`SIGKILL`\n\n/crash cannot widen access. See`docs/hardened-mode.md`\n\n.\n\nNot sure which mode fits, or wrapping an agent launcher so every run is confined?\nSee [docs/modes-and-wrapping.md](/obstalabs/bulwark/blob/main/docs/modes-and-wrapping.md).\n\nTree membership is decided by control-group membership (Linux) or a process set\ntracked from kernel fork/exec/exit events (macOS), recorded when each process is\ncreated — not by walking the parent chain at read time. So a descendant that\ndeliberately orphans itself with a double-`fork()`\n\nis still attributed to the\ntree and gated, rather than escaping by shedding its parent link. The tested\nmatrix is in [docs/containment-boundaries.md](/obstalabs/bulwark/blob/main/docs/containment-boundaries.md).\n\nBulwark is built to be operated by an agent, not only a human: in a fleet, an\norchestrator dispatching a sub-agent onto a sensitive host clamps that sub-agent's\nreach at dispatch. The restrainer is itself an agent — which is why Bulwark ships\nan ANCC contract ([ docs/SKILL.md](/obstalabs/bulwark/blob/main/docs/SKILL.md)), so an orchestrator can read\nits interface and apply the clamp programmatically.\n\nHanding an agent the lever is only safe because the lever turns one way.\n**Tightening** a clamp (adding `--protect`\n\n, narrowing an allowlist) is\nagent-initiated and free. **Widening or removing** one routes through the off-band\nconsent path (`--consent socket`\n\n), where a human answers over a channel the\nsupervised tree cannot see or forge. *An agent can clamp; it cannot un-clamp.*\nANCC-compliance here is about who pulls the lever, not who gets restricted — and\na lever an agent may pull must be a ratchet.\n\nEach run records its integrity context — a generation counter, a clean-shutdown\nmarker, the policy epoch, and the inode identity of every protected object — to a\nstate file. The next run enters **tainted mode** when the prior run ended\nuncleanly (no clean-shutdown marker: a `SIGKILL`\n\n, crash, OOM, or power loss) or\nwhen a protected path now resolves to a different inode (object-identity drift).\n\nA tainted run denies protected reads by default and, in socket mode, bypasses the\nallow-session cache so every protected open is decided fresh — no pre-taint grant\nsurvives. The taint is sticky and persists across restarts until an operator\nreviews the audit receipt (`source: \"integrity\"`\n\n) and acknowledges it:\n\n```\nbulwark reset   # clears the taint marker after review\n```\n\nThis bounds the blast radius *after* an unclean recovery. It does not change the\nheld-read fail-open behaviour at the instant of a hard kill — that is what\n`--hardened`\n\nis for.\n\nBefore the enforcement was opened publicly it went through repeated adversarial\nreview — attacking the gate, fixing what was found, then attacking the fixes —\nuntil two consecutive rounds found nothing a supervised agent could reach. That\npass surfaced around a dozen-and-a-half issues, including a few that the first\nround of fixes introduced; each is closed with a regression test that fails on\nthe pre-fix code and passes on the fixed code, verified on real hardware (Linux\nkernel 6.12, macOS 26). The boundaries Bulwark does **not** cross are documented\nas plainly as the ones it does — see **Known limitations** below and\n[docs/containment-boundaries.md](/obstalabs/bulwark/blob/main/docs/containment-boundaries.md).\n\n**Reads, not consequences.** Bulwark gates file opens; it does not stop use of data already read, environment-variable credentials, or network exfiltration. Pair it with an egress control.**fanotify modes fail open on hard supervisor death.** A`SIGKILL`\n\n/crash while a read is held releases that read as allowed (documented kernel behaviour); a graceful`SIGTERM`\n\nfails closed. The`--hardened`\n\n(Landlock) mode is crash-safe and does not have this limitation. The integrity circuit-breaker (below) bounds the blast radius*after*such an event but does not change the held read at the moment of the kill.- Allow-list/hardened modes allow a runtime base set (\n`bulwark base-set`\n\n) so the agent can execute — a deliberate, inspectable trade-off. **Daemon-delegated reads are outside the tree.** Bulwark governs the process tree it launched. Work the agent hands to a separate daemon — e.g.`docker run`\n\n, which delegates to`dockerd`\n\n— happens in a different tree and is not gated; this is the standard root-equivalent-socket boundary, not a Bulwark-specific gap. The agent's own attempt to escape into a new mount namespace*is*blocked under`--hardened`\n\n. Full tested matrix:[docs/containment-boundaries.md](/obstalabs/bulwark/blob/main/docs/containment-boundaries.md).- macOS support requires a signed Endpoint Security edge and\n**Full Disk Access** for the launching terminal (without it the gate cannot start). Why that's needed, how the gate is wired, and the common setup errors are in[docs/macos-permissions.md](/obstalabs/bulwark/blob/main/docs/macos-permissions.md); see also[docs/macos.md](/obstalabs/bulwark/blob/main/macos.md). - Requires root (\n`CAP_SYS_ADMIN`\n\nfor fanotify; Endpoint Security privilege on macOS; Landlock for`--hardened`\n\n).\n\n- The managed remote trust channel (mutual-TLS, signed grants) — the Pro\nhardening of\n`bulwark ssh`\n\n. - Fleet control plane, centralized audit, signed managed daemons (Pro).\n- Windows support (minifilter gate) — under research.\n\n**Local enforcement is open. Managed trust is paid.**\n\nThe line is architectural, not a feature flag: if it runs entirely on your own machines, it is open source; if it depends on Obsta-operated trust, identity, availability, fleet policy, audit, or signed grants, it is the commercial tier.\n\n**Bulwark Core** is licensed **AGPL-3.0-or-later** (see [LICENSE](/obstalabs/bulwark/blob/main/LICENSE)) and\ncovers self-managed local enforcement:\n\n- the read gate (\n`bulwark run --protect`\n\n) - local off-band consent (\n`--consent socket`\n\n) - the CI allowlist (\n`--deny-all`\n\n) - the crash-safe Landlock floor (\n`--hardened`\n\n) - the peer\n`bulwark ssh`\n\nmechanism, when you own both ends\n\nYou can read exactly what the gate does and how it decides; the security boundary is inspectable by design. Local Linux functionality is never gated by a license check.\n\n**Bulwark Pro / Fleet** is the commercial tier — managed trust for teams, which\nis the part you do not want to run yourself:\n\n- the remote trust channel: mutual-TLS / certificate issuance and a signed-grant\nauthority (the production hardening of\n`bulwark ssh`\n\n) - managed daemon identity and fleet policy distribution\n- a centralized, tamper-evident audit pipeline\n- team approval flows and an operator cockpit\n- an SLA on the consent and trust channel\n\n`bulwark ssh`\n\nis the *mechanism* and is open; the *managed trust* around it —\nreliable authority across machines, users, agents, and audit boundaries — is the\nproduct. The local enforcement is open; the managed infrastructure on top is the\npaid line.\n\nCopyright © 2026 Obsta Labs.", "url": "https://wpnews.pro/news/show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh", "canonical_source": "https://github.com/obstalabs/bulwark", "published_at": "2026-06-30 15:06:21+00:00", "updated_at": "2026-06-30 15:20:18.285288+00:00", "lang": "en", "topics": ["ai-safety", "ai-agents", "developer-tools"], "entities": ["Bulwark", "Obstalabs", "Linux", "macOS", "fanotify", "Endpoint Security", "Landlock", "NeuroRouter"], "alternates": {"html": "https://wpnews.pro/news/show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh", "markdown": "https://wpnews.pro/news/show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh.md", "text": "https://wpnews.pro/news/show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh.txt", "jsonld": "https://wpnews.pro/news/show-hn-bulwark-a-kernel-read-gate-so-coding-agents-can-t-read-env-or-ssh.jsonld"}}