Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ on:
permissions:
contents: read

# A newer push to the same pull request makes the older run's result moot, so the
# older run is cancelled rather than left holding runners. A push to main is never
# cancelled: its group is its own commit. A red-first commit's run is cancelled by
# the next push too, so read its test job before pushing the fix.
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
CARGO_TERM_COLOR: always
# One archive snapshot for every apt step in this file. Bump it deliberately,
Expand Down Expand Up @@ -126,9 +134,10 @@ jobs:
- name: one integration-test binary per crate
run: ./scripts/one-test-binary-per-crate.sh

# The teeth harness runs as four shards (the teeth jobs below). This lists
# what each shard would run and requires the four to run every case exactly
# once. It runs no case and needs no cargo (invariant 113).
# The teeth harness runs as shards (the teeth jobs below). This reads their
# count from this file, lists what each shard would run and requires the
# shards to run every case exactly once. It runs no case and needs no cargo
# (invariant 113).
- name: the teeth shards run every case exactly once
run: ./scripts/teeth-shards-cover-every-case.sh

Expand Down Expand Up @@ -254,19 +263,21 @@ jobs:
run: cargo test

# The teeth harness is the slowest step in this repository, 30 to 60 minutes as
# one job, so it runs as four shards of the same case list. Shard i runs the
# cases at positions i, i+4, ..., and scripts/teeth-shards-cover-every-case.sh
# requires the four together to run each case exactly once (invariant 113).
# Standard runners: this repository is public, so they cost nothing.
# one job, so it runs as eight shards of the same case list. Shard i runs the
# cases at positions i, i+8, ..., and scripts/teeth-shards-cover-every-case.sh
# requires them together to run each case exactly once (invariant 113). The
# count is written here only, in the matrix, TEETH_SHARD and the name, and that
# gate reads it from here and requires the three to agree. Standard runners: this
# repository is public, so they cost nothing.
teeth:
name: teeth (shard ${{ matrix.shard }} of 4)
name: teeth (shard ${{ matrix.shard }} of 8)
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
env:
TEETH_SHARD: ${{ matrix.shard }}/4
TEETH_SHARD: ${{ matrix.shard }}/8
steps:
# fetch-depth: 0 here too: this job runs gates-have-teeth.sh, whose
# stated-numbers cases call the version-badge check, and a checkout
Expand Down Expand Up @@ -325,21 +336,21 @@ jobs:
run: ./scripts/audit.sh

# The required check. Its name stays, because branch protection names it. It
# passes only when the audit job and all four teeth shards passed: a skipped
# passes only when the audit job and every teeth shard passed: a skipped
# or cancelled need reads as not success, so it fails here too.
security:
name: security (cargo audit)
needs: [audit, teeth]
if: always()
runs-on: ubuntu-24.04
steps:
- name: audit and all four teeth shards passed
- name: audit and every teeth shard passed
env:
AUDIT: ${{ needs.audit.result }}
TEETH: ${{ needs.teeth.result }}
run: |
echo "cargo audit: $AUDIT" >>"$GITHUB_STEP_SUMMARY"
echo "teeth, four shards: $TEETH" >>"$GITHUB_STEP_SUMMARY"
echo "teeth, every shard: $TEETH" >>"$GITHUB_STEP_SUMMARY"
test "$AUDIT" = success
test "$TEETH" = success

Expand Down
1 change: 1 addition & 0 deletions .secretscan-allow
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Exceptions for the estate secret scan (estate-gates invariant 23). Each
# names a fingerprint and a path, never a value, and says why it is not a secret.
33ea101ddbe7c33b crates/cloud/src/audit_sign.rs # a PEM header around deliberate garbage, in the test that the parser rejects it
3021d90eb9437b2d crates/cloud/src/audit_sign.rs # RFC 6979 appendix A.2.5's published P-256 test key in PKCS#8 form, in the test that every accepted key encoding loads; a public test vector that signs nothing
3021d90eb9437b2d crates/cloud/tests/it/oidc.rs # an RSA key generated for the OIDC integration tests; it signs nothing outside them
743554670c6065b3 crates/core/src/dlp.rs # the DLP detector's own fixture: an AWS key id shape it must catch and redact
743554670c6065b3 crates/core/src/injection.rs # the injection redactor tests feed the DLP fixture shapes
Expand Down
35 changes: 22 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1921,6 +1921,8 @@ A PDP holding the token and the proof together can check the proof's freshness,

141. **A delegation proof names one of the public origins the operator configured, and a `Host` header never stands in for one.** A gateway reached at more than one address (a public name and an internal one) must accept a proof addressed to any of them, and must refuse one addressed to anything else. `TOKENFUSE_DELEGATION_ORIGINS`, a comma-separated list, is read beside `TOKENFUSE_DELEGATION_URL` (one origin, unchanged); `TOKENFUSE_MCP_PROOF_ORIGINS` is read beside `TOKENFUSE_MCP_PROOF_URL` at the broker's proof door. The single origin and the list are one set. Every entry is normalised as wardryx's `normalizeOrigin` does, and an entry that is not `scheme://host[:port]` with nothing after the port refuses to start, naming its variable; so does a list naming no origin at all. A proof is accepted for a route when its `htu`, without its query, is one configured origin plus that route's path. A fragment, user information, another path or an unlisted origin is refused. The check picks the origin from the `htu` read before the signature is checked (`dpop::origin::expected_url`), and the verifier then checks the signature over that same claim, so nothing can change it after the choice. `@claude 2026-10-19`, choices under delegated authority, open to reversal: the list and the single origin are a union; an empty entry refuses the list rather than being skipped; and the verifier refuses a fragment in an `htu` (RFC 9449 section 4.3), which changes every door, not only the new list. This amends the single origin of invariant 30 and the `htu` rule of invariant 31 without changing either for a deployment that names one origin. *(test: `dpop::origin::tests`, `chainproof::origin_list_tests::the_single_origin_and_the_list_are_one_set_and_a_bad_entry_names_its_variable`, `tests/it/mcp_door.rs` (the list and fragment tests), `tests/it/delegation_origins.rs` (eight over the real router, both doors); scenarios: `features/a-proof-names-one-of-the-public-origins.feature`, eleven, each bound. Not a script gate: held by `cargo test`.)*

142. **A message the MCP broker cannot judge is refused whole and never forwarded: a JSON-RPC batch, anything that is not one JSON object, and an object whose `method` is not a string.** Every check the broker makes keys on `method` read from one JSON object: the Wardryx decision, the identity requirement, the drift refusal, the taint gate, the raw-secret and PII scans of the arguments, the `tool_call` record, and the poisoning and rug-pull scans of a `tools/list` answer. A JSON-RPC batch is an array, so its `method` read as empty, every one of those was skipped, and the array was forwarded as it came. Measured 2026-10-26 against the v1.19.0 release: a batch of two `tools/call`, admitted by an agent token, reached the upstream with no policy decision and no `tool_call` record; v1.20.0 has the same code path. `@decided 2026-10-26`, paraphrased: a batch is refused whole rather than judged element by element. MCP removed batching in its 2025-06-18 revision and its HTTP transport carries one message per POST, so a client on the current protocol loses nothing; one that still sends batches, as 2025-03-26 allowed, must send each message on its own. `process_call`, which both transports reach, refuses first, before an upstream is resolved or a secret read (`unjudgeable`): JSON-RPC `-32600` with a null id, HTTP 400 on the HTTP transport, and one warning in the log. The refusal is not on the agent-event bus: no event type fits a message that names no tool, and inventing one needs the agent-passport registry first, the reasoning invariant 135 gives for its own refusal. `@claude 2026-10-26`, under delegated authority, open to reversal: a `method` that is present and not a string is refused the same way, because it reads as empty here and may read as something else upstream; a message with no `method` at all is a JSON-RPC response, a client answering the server's own request, and is still forwarded. *(test: `tests/it/mcp_broker.rs`: `a_batch_of_tool_calls_is_refused_and_nothing_reaches_the_upstream`, `the_stdio_transport_refuses_a_batch_too`, `a_message_the_broker_cannot_read_as_one_request_is_refused`, and the must-not-fire case `a_single_request_and_a_clients_response_still_reach_the_upstream`; scenarios: `features/a-batch-cannot-carry-a-call-past-the-gate.feature`, four, each bound. Not a script gate: held by `cargo test`.)*

## Decisions that have no gate yet

This list is debt, and it is here to stay visible rather than to be tidy.
Expand Down Expand Up @@ -7383,22 +7385,29 @@ is public, so a literal publishes somebody's username to everyone who reads it.

112. **A call the gateway answered just before it stops reaches the Cloud: the stop delivers what it holds, bounded, and names what it could not deliver.** On SIGTERM or SIGINT the server stops taking calls first; `flush_before_exit` then delivers the queue (in order) and the buffer within `EXIT_FLUSH_WITHIN`, waits for a push already on the wire, and starts nothing new while stopping. What is still undelivered at the bound is logged with its count. A SIGKILL, an out-of-memory kill or a lost node still loses what was not yet pushed, at most the last flush interval of calls plus the queue; a local spool is a follow-up, because replaying it needs the Cloud to recognise a record it already holds. `@decided 2026-10-14`: spend reports are made durable across a stop, and this change takes the delivery-before-exit half. *(test: `gateway::cloudsink::the_exit_flush_delivers_the_queue_then_the_buffer_in_order`, `gateway::cloudsink::the_exit_flush_is_bounded_and_says_what_it_could_not_deliver`, `gateway::cloudsink::a_push_on_the_wire_at_exit_is_counted_when_it_never_lands`, `tests/it/spend_reports_survive_a_stop.rs`: `a_call_served_just_before_sigterm_reaches_the_cloud`, `a_stop_while_the_cloud_does_not_answer_says_what_was_not_delivered`. Scenarios: `features/a-served-call-reaches-the-cloud-when-the-gateway-stops.feature`.)*

113. **The teeth harness runs as four CI shards, and the shards together run every case exactly once.**
113. **The teeth harness runs as CI shards, the shards together run every case exactly once, and their count is written once, in the workflow.**
One job took 30 to 60 minutes, the slowest step in this repository. `TEETH_SHARD=i/N` runs the cases at
positions `i`, `i+N`, ... of the list; `TEETH_LIST=1` prints the names through the same filter and runs
none; a shard that runs no case says it measured nothing. `scripts/teeth-shards-cover-every-case.sh` lists
the full harness and each of the four shards, and requires that no name is listed twice, that no shard is
empty, and that the shards together are the full list: nothing dropped, nothing run in two shards. The
required check `security (cargo audit)` is an aggregate: it passes only when the `cargo audit` job and all
four shards pass. `@claude` 2026-10-14, the split and the aggregate, under delegated authority; open to
reversal.

Not covered: the shard count of four is written twice (the workflow matrix and the gate's `shards=4`) and
nothing checks that they agree; the wall time is measured in CI, not here; the four shards compile the same
dependencies, each in its own runner.
positions `i`, `i+N`, ... of the list; `TEETH_LIST=1` prints the names through the same filter and runs none;
a shard that runs no case says it measured nothing. `scripts/teeth-shards-cover-every-case.sh` reads N from
`.github/workflows/ci.yml`, where the teeth job's matrix, its `TEETH_SHARD` divisor and its name each state
it, and refuses when the three disagree or the matrix is not `1..N`. It then lists the full harness and each
of the N shards, and requires that no name is listed twice, that no shard is empty, and that the shards
together are the full list: nothing dropped, nothing run in two shards. The required check `security (cargo
audit)` is an aggregate: it passes only when the `cargo audit` job and every shard pass. `@claude` 2026-10-14,
the split and the aggregate, under delegated authority; open to reversal. `@decided 2026-10-26`, paraphrased:
eight shards instead of four, for wall time. The four took 11.6 to 19.3 minutes each on main (`@measured` CI
run 39356312313, 2026-10-20), and the cases alone added up to 54.7 minutes across the four (`@measured` from
the per-case timestamps in the teeth logs of CI run 40259164936, 2026-10-26), 9.0 of them in five
`stated-numbers` cases, because each runs the full suite. The same change makes CI cancel a pull request's
older run when a newer push arrives (`concurrency` in `ci.yml`); a push to main is never cancelled.

Not covered: the wall time with eight shards is measured in CI, not here; the shards compile the same
dependencies, each in its own runner; and how many jobs run at once is bounded by the account's concurrency
limit, which this repository cannot see, so two pull requests in flight may queue.
*(gate: `scripts/teeth-shards-cover-every-case.sh`; teeth cases in `scripts/gates-have-teeth.sh`: the real
harness partitions, a filter that drops every fifth case, a filter that runs a case in every shard up to its
own, and a harness that is gone)*
own, a harness that is gone, a matrix that skips a shard, a divisor left at four under a matrix of eight, and
the matrix gone from the workflow)*

114. **A DLP finding the scanner lets through is one `dlp_shadow` on the bus per scanner, kinds and counts,
never the text, and only when neither scanner refused the prompt.** agent-passport SPEC 6.2 registered
Expand Down
Loading
Loading