Skip to content

security: v0.3.6 fixes for secrets, emit subjects, governed mode and http-in - #11

Merged
cpoder merged 5 commits into
masterfrom
fix/shadow-secrets-dlq-http-in
Nov 6, 2026
Merged

cpoder merged 5 commits into
masterfrom
fix/shadow-secrets-dlq-http-in

Conversation

@cpoder

@cpoder cpoder commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Fixes from a review of v0.3.6. Each problem was reproduced on that binary before the change, and each commit stands alone. This is the content of v0.4.0, a minor release because of the governed-mode change to the HTTP API; the version bump and the release are not in this PR.

  • Protocol injection through an emit subject (e7b0316). A subject built from an event field reached the NATS wire as it was, so through http-in an anonymous POST could publish anywhere or purge a stream. It needs a flow that builds a subject from an event field; no shipped flow or template does. emit now refuses whitespace and control characters: the run fails and the message reaches the DLQ. http-in answers 400 on such a suffix.
  • Secret values left through every path that shows a run (fb3ab18). GET /preview, open with no token, returned variables holding a secret, and in governed mode a vejas_time_travel candidate handed the value to an agent holding only VEJAS_TOKEN. Such runs now use Engine::shadow, where secret() stays fail-closed and yields <secret:path/key>. Engine::new is gone, so a new proof path has to choose.
  • Governed mode (59e0d9f). Replay and purge of dead letters are human acts on VEJAS_APPROVAL_TOKEN, refused over MCP, and they are now audited. The panel could not approve at all when VEJAS_TOKEN was set, the documented production posture: a 401 from the write gate. A valid approval token now answers it on the human routes.
  • http-in and supervision (542c876). With a thread limit, a burst of connections panicked the accept loop: the connector stopped for good while /healthz said 200 and the topology said running, restarts: 0. http-in now holds MAX_CONNECTIONS (default 512) and answers 503 past it, the panel port has VEJAS_HTTP_MAX_INFLIGHT, and a panic in a unit takes the path of any other failure.
  • Advisories (9b12db7). rustls-webpki 0.103.16 and webpki-roots 1.0.11 for RUSTSEC-2026-0381 and -0382. The daily audit job has been red on master since 2026-10-21.

Behaviour changes

  • Governed mode: the last step of the self-healing loop moves to the panel, since vejas_dlq_replay is refused. docs/security-audit-scope.md called replay and purge operational "by design"; ADR-0024 Amendment A reverses that, because an agent reading dead letters reads untrusted content.
  • Shadow runs: a branch that compares with a secret takes the not-equal path there.
  • Flows: an emit on a subject with whitespace now fails the run.
  • Operators: rotate any secret a shared or agent-facing deployment could have exposed.

Verification

  • New regressions, all in CI: e2e/emit-subject.sh, e2e/governed-agent.sh, e2e/http-in-bound.sh (with a real pids limit when docker is there). The first two fail on v0.3.6; http-in-bound was reproduced there separately, on the released image.
  • Approve and Replay checked in a real browser, with both tokens set.
  • Run locally on this code plus the dead-letter events that follow it: unit tests, 22 golden cases, and the transport, governed, write-gate and doc-example suites. This branch alone was not built locally, so this CI run is its first full one.

Known limits: http-in's bound is not rate limiting, a client holding every slot still blocks ingestion until it stops, and tiny_http's own pool is unbounded. A child of an exec-* connector inherits the runtime's environment, approval token included; that is unchanged here.

🤖 Generated with Claude Code

https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V

cpoder and others added 5 commits November 6, 2026 17:06
The daily audit job has failed on master since 2026-10-21: RUSTSEC-2026-0381
and -0382 (certificate paths through distrusted emSign intermediates) landed
with no commit here.

core and connectors/amqp move to the patched versions, so the HTTP client and
MQTT over TLS reject those paths again. The sync nats client pins rustls-webpki
0.102, which has no patched release: -0382 joins the documented ignores with its
reason, until the move to async-nats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V
secret() resolved the real value in every path that shows a run instead of
performing it: preview, vejas_run_flow, flow-as-tool, shadow-replay, time-travel,
canary, golden capture. GET /preview, open with no token, returned any variable
holding a secret. In governed mode a vejas_time_travel candidate handed the value
to an agent holding only VEJAS_TOKEN, against the S4 invariant in
docs/security-audit-scope.md.

A candidate is arbitrary code and can transform the value, so this cannot be a
filter on the output: the value must not enter the run. Engine::shadow resolves
through RedactedStore, which stays fail-closed and yields <secret:path/key>.
Engine::live is the only way to a value, and Engine::new is gone so that a new
proof path has to choose. vjs-test runs in shadow too, so that a curated golden
round-trips.

The cost is written down: a branch comparing with a secret takes the not-equal
path in shadow.

e2e/governed-agent.sh covers each path, a candidate that transforms the value,
fail-closed behaviour, and that a live run still gets the value. It fails on
v0.3.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V
… both tokens

Replay and purge of dead letters stayed on VEJAS_TOKEN as operational actions
(docs/security-audit-scope.md said "by design"). That left them in the agent's
hands while ADR-0024 promises that every mutating path is gated: a replay re-runs
side effects, a purge destroys what the agent's work is judged on, and a dead
letter is untrusted content the agent reads. No proposal kind carries them, so in
governed mode they now sit on VEJAS_APPROVAL_TOKEN: refused over MCP, 409 over
HTTP without X-Approval-Token. This reverses that written decision, in ADR-0024
Amendment A, and moves the last step of the self-healing loop to the panel.

The human door did not work to begin with: the panel holds the approval token and
never VEJAS_TOKEN, so with both set, the documented production posture, Approve
got a 401 from the write gate. A valid approval token now answers the write gate
on the routes that belong to the human.

Replays and purges are recorded in VEJAS_AUDIT. The guide said purge was audited;
it was not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V
http-in spawned one thread per connection with no bound, and thread::spawn panics
when the OS refuses a thread. On the v0.3.6 image with --pids-limit=64, 300 idle
connections panicked the accept loop: the connector stopped for good while
/healthz said 200 and the topology said running, restarts 0, last_error null.
This port has no authentication and the compose file publishes it.

http-in now holds at most MAX_CONNECTIONS (default 512) and answers 503 past it,
gives a request 30 s to arrive, and drops one connection when no thread is
available. The panel port has the same bound (VEJAS_HTTP_MAX_INFLIGHT, default
256). Refusals count in vejas_http_rejected_total.

Each unit's supervisor is the thread that runs it, so any panic ended the
supervision as well. Attempts now run inside contained(): a panic takes the path
of any other failure, restarting with the message in last_error. Supervisor
threads carry the unit's name, and start_proc reports a thread it cannot start.
The detect reader stops on every way out of an attempt.

This is not rate limiting: a client holding every slot still blocks ingestion
until it stops, so a hostile network needs the ingress in front. tiny_http's own
pool is unbounded and untouched.

e2e/http-in-bound.sh holds the bound, and repeats the pids-limit scenario in a
container when docker is available.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V
A subject built from an event field reached the NATS wire as it was, so a line
break in the field became protocol commands, with the permissions of the runtime's
NATS user. Through http-in that needed no credential: a flow building a subject
from an event field let an anonymous POST publish anywhere or purge a stream. No
shipped flow or template does this, but a flow written by an agent can.

emit now refuses a subject with whitespace or a control character. It is checked
in the interpreter, so the run fails like any other: the message reaches the DLQ
after its deliveries, and a shadow run shows the error a live run would get.
http-in answers 400 on such a suffix. Its path had no injection of its own, since
it cannot carry the CRLF a payload must end with, but it could drop the connection.

e2e/emit-subject.sh checks a hostile field, the path to the DLQ, the shadow run,
the suffix, and that a clean field still routes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V
@cpoder
cpoder merged commit 44db9be into master Nov 6, 2026
4 checks passed
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.

1 participant