Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.tsspawns andhealth-checks it, so
npm run pocstays a one-command startup.Three mediation points, all in the backend path rather than the UI:
invoke_runtimeis a capability scoped to the Agent's own workspace, issued atcreation and revocable independently of deletion;
read_shared_docis a taintsource;
notify_webhookis 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.mdwith an injected instructionand 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
taintfield. A clean notification from the same Agentthen 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 filessurvive. 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 notthe 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
Run against this branch before opening the PR:
apps/server/src/bulkhead-integration.test.tsspawns the real sidecarprocess, not a mock, and drives
AgentServicethrough 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
outputtwice and the secondassignment discarded Bulkhead's outbox notes — and they are what caught it.
Notes for reviewers
The spawner looks up
python3thenpython; on Windows the former resolvesto 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.
BULKHEAD_SIDECAR_URL(defaulthttp://127.0.0.1:8787) andBULKHEAD_SIDECAR_TIMEOUT_MS(default5000).agentAuditforBulkhead's per-Agent record,
auditfor the gate's per-Run record.persistence and model execution all behave as before.
Design notes, architecture diagrams, demo scripts and honest limitations are in
docs/BULKHEAD.mdanddocs/MIDDLEWARE_DESIGN.md.