Skip to content

Policy gate + Bulkhead: layered middleware for tool-using agents - #24

Open
josh123zz wants to merge 9 commits into
RrankPyramid:mainfrom
josh123zz:bulkhead-middleware
Open

josh123zz wants to merge 9 commits into
RrankPyramid:mainfrom
josh123zz:bulkhead-middleware

Conversation

@josh123zz

@josh123zz josh123zz commented Sep 3, 2026

Copy link
Copy Markdown

Track: Agent Launchpad — Design and Build Lightweight Agent Middleware

Two middlewares, built by the two of us and combined here into one platform.
They are not alternatives: each answers a question the other cannot.

Asks Acts at
Three-layer policy gate Is this request something the Agent should attempt, and is this command safe to run? The prompt, then every command in the Codex event stream
Bulkhead Does this Agent hold the authority, and may this content leave? Runtime dispatch capability, mediated reads and sinks

Bulkhead by @MeditatorLee; the policy gate by @josh123zz.

Bulkhead — capability and taint

A Python kernel (standard library only) runs as a local sidecar; the Node
control plane talks to it through a single file,
apps/server/src/middleware-client.ts. apps/server/src/index.ts spawns and
health-checks it, so npm run poc stays a one-command startup.

Three mediation points, all in the backend path rather than the UI:
invoke_runtime is a capability scoped to the Agent's own workspace, issued at
creation and revocable independently of deletion; read_shared_doc is a taint
source; notify_webhook is a sink that refuses a tainted body by default.

The client fails closed. If the sidecar is unreachable, times out, or
errors, the call is denied — including Agent creation, which returns 503 rather
than producing an Agent that would be denied on every later call.

Denial case: seed workspace/shared/roadmap.md with an injected instruction
and ask the Agent to notify the team with that content. The outbound notify is
blocked, the Run still completes, and the attempt appears in the audit panel as
a DENY with a non-empty taint field. A clean notification from the same Agent
then succeeds — mediation, not a kill switch.

Policy gate — three layers over intent and action

Layer 1 is deterministic pattern rules over the prompt, evaluated before the
Runtime starts: an obvious attack costs 0 tokens and ~2.4 µs against roughly
88,000 tokens for a real coding Run. Layer 2 is a small-model intent classifier
that runs only when layer 1 allows, so obvious attacks never pay for it — against
nine paraphrases of the same three attacks, patterns alone caught 1 of 9 and
both layers caught 9 of 9, with 0 of 3 ordinary coding prompts falsely denied.
Layer 3 inspects the Codex event stream command by command and terminates a Run
mid-flight, and also scans what the Agent reads, because a clean prompt plus a
clean command can still surface a poisoned file.

Denial case: a benign prompt — "clean up leftover build output" — where the
Agent reaches for rm -rf. The gate stops it at that command; the seeded files
survive. No prompt filter could have prevented it, because the prompt was never
the problem.

How they compose

The gate runs first, because its pattern layer is deterministic and free: an
obvious attack is refused before Bulkhead's sidecar is called or a container is
started. Bulkhead's capability check follows, then its mediated reads, which
produce the prompt actually sent to the Runtime.

The gate deliberately judges run.prompt — the operator's own words — and not
the shared-document-augmented prompt. Judging the augmented text would deny the
Run outright whenever a shared document carried an injected instruction, which
is exactly the case Bulkhead exists to survive.

Verification

npm run check                                             # TypeScript, tests, build
python -m unittest discover -s middleware/bulkhead/tests   # kernel suite

Run against this branch before opening the PR:

  • typecheck clean
  • 142 tests passed, 1 skipped, across 16 files
  • build succeeds
  • 38 Python kernel tests pass

apps/server/src/bulkhead-integration.test.ts spawns the real sidecar
process, not a mock, and drives AgentService through the real HTTP client.
Ground truth for each assertion is an HTTP status the sidecar actually returned
or an entry in the real hash-chained log. Combining the two middlewares broke
two of those tests — the completion path assigned output twice and the second
assignment discarded Bulkhead's outbox notes — and they are what caught it.

Notes for reviewers

  • Adds a Python 3.10+ requirement alongside Node 22+ and a container engine.
    The spawner looks up python3 then python; on Windows the former resolves
    to a Store stub, which made the sidecar look broken for a reason unrelated to
    any change. macOS and Linux are unaffected and remain the judging path.
  • New environment variables: BULKHEAD_SIDECAR_URL (default
    http://127.0.0.1:8787) and BULKHEAD_SIDECAR_TIMEOUT_MS (default 5000).
  • The web client distinguishes the two audit trails: agentAudit for
    Bulkhead's per-Agent record, audit for the gate's per-Run record.
  • The baseline lifecycle is unchanged: Agent CRUD, Playground chat,
    persistence and model execution all behave as before.
  • No credentials are logged, committed or displayed.

Design notes, architecture diagrams, demo scripts and honest limitations are in
docs/BULKHEAD.md and
docs/MIDDLEWARE_DESIGN.md.

joshuazou-web and others added 9 commits August 30, 2026 19:41
Two layers gate the prompt, a third gates what the agent does and reads.

Pattern rules run first at ~2.1us and short circuit, so an obvious attack
never costs a model call. A semantic classifier judges what survives them:
on nine paraphrases of the same three attacks, patterns alone blocked 1 of 9
and both layers blocked 9 of 9. Neither can see what the agent will choose to
do, so a runtime layer evaluates every command Codex reports and terminates
the run at the offending one, and inspects command output for instructions
injected into files the agent reads (1 of 7 paraphrases by pattern, 7 of 7
with the classifier).

Extract the gate into PolicyGate, which depends on nothing in this app: two
calls and a callback. A test drives all four outcomes through a toy agent
unrelated to Codex.

Harden against evasion after testing found four working bypasses. Normalise
NFKC plus a homoglyph table so Cyrillic cannot spell rm past a deny rule,
deny mixed-script tokens outright, and deny reads whose target is built by
shell substitution. Rewrite egress as URL extraction with label-boundary host
matching, after the previous regex let `curl -s`, `curl | bash`, and
`github.com.evil.example.com` through while denying ordinary downloads.

Replace the audit digest with a forward hash chain. Hashing a record and
returning the digest beside it proves nothing, since whoever edits the record
recomputes it; chaining each event to the previous forces an edit to rewrite
the tail. Report unverifiable separately from broken so legacy traces are not
called tampered.

Map every deny rule to OWASP LLM Top 10 categories, and assert that the five
outside a command-and-content gate stay unclaimed.

Measure the other half too: 25 benign prompts, 15 file contents, and 15
routine commands, none blocked. Fix a classifier false positive on "the agent
should ignore node_modules" by keying on grammatical person — documentation
describes an agent in the third person, an injection addresses "you".

Resolve the npm-global codex shim to its vendored executable on Windows,
where Node cannot spawn .cmd without a shell, and retry the store's atomic
rename through transient locks.

115 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An external review probed the gate with commands none of the rules were
written for. Five of nine got through: `node -e "console.log(process.env)"`,
`python3 -c "import os; print(os.environ)"`, `ruby -e "puts ENV.inspect"`,
reading .env from inside a script, and `rmSync(..., {recursive: true})`.

Every runtime rule spoke shell — printenv, cat .env, rm -rf. An agent that
reaches for a language runtime speaks a different idiom, and the rules were
blind to all of it. More shell patterns would not have helped, so recognise
that an inline script is being run at all (-e, -c, --eval, and subcommands
like `deno eval`, which a flag list alone missed) and judge it in each
runtime's own spelling. Reading one named variable stays allowed; dumping the
environment does not. Twelve probes denied, ten legitimate inline scripts
unaffected.

Stop seeding injection payloads into every workspace. The demo scenarios need
them, but writing hostile content into a user's workspace is not something a
deployment should do, so put them behind SEED_DEMO_FIXTURES, off by default,
and set it in the demo launcher.

The semantic prompt's JSON example listed five risk categories while the enum
carried eight, so egress, cryptomining, and ransomware could not be returned.
List them all.

Correct the published numbers against a fresh measurement: 2.4us per prompt,
3.0us per command, 6.1us for a 2KB output scan, 137 tests. The old figures
predate the egress, normalisation, and interpreter rules.

138 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Actions were written to the store only when a Run settled, so the console
polled every 900ms and saw nothing until the end. The most persuasive thing
this project does — the gate stopping an agent at the command it should not
run — happened invisibly, and the judge watched a spinner for up to forty
seconds before the whole record appeared at once.

Flush the action log after each inspection instead. The existing poll turns
that into a live view: commands appear one at a time as the agent runs them,
the newest slides in, and a denial appears in red as the last row. Failures to
flush are swallowed, since a dropped intermediate write costs one frame and
the authoritative write still happens when the Run settles.

Verified against a real Run: the list goes 0 -> 1 -> 2 rows while the status
is still running, where it previously stayed empty until completion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three deliverable problems found while auditing against the track's acceptance
checklist.

The architecture diagram and demo script still described the gate as it stood
before the evasion, interpreter and egress work landed: 3 pattern rules where
there are now 4 plus a default-allow, 4 semantic categories where there are 7,
5 runtime rules where there are 8, and a test count of 79 against an actual 138.
A reviewer who compares the diagram to the sidebar sees a contradiction and has
to decide which one to believe. Track the code instead.

The workspace-escape scenario hardcoded a real developer's home directory. That
would have shipped a username into a public repository and a public demo video,
and it shows a Windows path to judges running macOS or Linux. A cron drop is
portable and reads as a clearer escape attempt, since it is persistence rather
than a stray file. The rule already covers /etc, so the verdict is unchanged.

The written description Devpost requires did not exist. Add it as a document in
the repository so the claims stay next to the code that proves them, with the
command that reproduces each number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The banner printed all three layers as ACTIVE before the server had started,
and claimed the semantic layer whenever POLICY_SEMANTIC was not "off". In the
default "auto" that layer also requires a usable Ark configuration, so the
claim could be false at the moment it was printed.

The track brief singles out hard-coded success messages as not qualifying as
middleware evidence, and a banner that says ACTIVE regardless of state is
exactly that. Print the requested mode and point at the two places that answer
authoritatively once the server is running: GET /api/policy and the sidebar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README carried placeholder links because neither the video nor a public
repository existed when it was written. Both do now, so the links resolve.

Also correct two limitations. The outbound-network entry described the state
before the egress work landed: layer 3 does extract URLs and check them against
a host allowlist today, and the honest remaining gap is that this is
command-layer enforcement a compiled binary can route around.

The second is one we found while recording. Layer 3 inspects what the Agent
runs, so an Agent that already holds a file's contents from earlier in the same
Codex session can answer from memory without issuing a command, and the gate has
nothing to inspect. Running a scenario twice in one session looks like a miss
and is not: the second Run never re-reads the file. Better a reviewer reads this
here than discovers it themselves and wonders what else went unsaid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RunStatus answers whether a Run finished, not whether the middleware worked, so
a Run the gate stopped is failed rather than completed. That is correct, and
every denial in the demo video is one. It is also the same value a Run gets when
the model provider errors out, which means the field a reviewer is most likely
to read first does not separate "we stopped it" from "it broke".

Splitting the enum would fix that, and is the first change to make with more
time; changing a core type this late is not worth the regression risk. Document
it instead, in the two places someone hits it: the audit endpoint section for
anyone reading the JSON, and the judges Q&A for anyone who notices three failed
Runs in the video and wonders whether the demo is broken.

The Q&A entry says plainly that this cost us during recording -- we read failed
and assumed a block, when the provider had rate limited us -- since a limitation
we walked into ourselves is more useful to a reviewer than one stated abstractly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two middlewares were built on separate forks of the same starter kit and
answer questions the other cannot: the gate asks whether a request should be
attempted and whether a command is safe to run; Bulkhead asks whether an Agent
holds the authority and whether content may leave. Running both is the point.

Ordering is deliberate. The gate goes first because its pattern layer is
deterministic and free -- an obvious attack is refused before the sidecar is
called or a container is started. Bulkhead's capability check follows, then its
mediated reads, which produce the prompt actually sent to the Runtime.

The gate deliberately judges run.prompt, the operator's own words, rather than
the shared-document-augmented prompt. Judging the augmented text would deny the
Run outright whenever a shared document carried an injected instruction, which
is exactly the case Bulkhead exists to survive: the Run completes and the
tainted outbound call is what gets blocked.

Two collisions needed a decision rather than a union. The web client had one
name for two different audits, so Bulkhead's Agent-level trail is now
agentAudit and the gate keeps audit for its Run-level record. And the completion
path assigned output twice; Bulkhead's value already wraps the runner output
together with its outbox notes, so the raw assignment was dropped. Its absence
was caught by the two integration tests that cover the notify sink, not by
reading.

The gate suites predate the required Bulkhead client, so they construct the
service with an allow-all double and keep asserting on what they are about.

The sidecar spawner now looks up python3 then python. On Windows the former
resolves to a Store stub that prints an advert and exits, so the sidecar never
became healthy and the failure said nothing about what went wrong. macOS and
Linux are unaffected and remain the documented judging path.

Verified: typecheck clean, 142 tests passed and 1 skipped across 16 files,
build succeeds, and the 38 Python kernel tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josh123zz josh123zz changed the title Bulkhead middleware: capability + taint mediation for tool-using agents Policy gate + Bulkhead: layered middleware for tool-using agents Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants