feat: MU1 multi-user control plane (opt-in, SH_REQUIRE_AUTH=false by default) - #249
Conversation
The route table is declared as data so the router, the authz enumeration test (spec §9.3 test 2) and the OpenAPI drift test (test 3) all read one source of truth. Errors map to a status in exactly one place (spec §9.1); an unrecognised throwable becomes a bare internal_error so its text cannot leak a connection string to an arbitrary caller. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Ed25519 rather than a shared HMAC secret because the harness is the brain tier: under a shared secret a compromised harness could mint a token for any subject (spec §5.2). makeSigner() refuses a public key, so 'the verifier cannot sign' is a property of the API rather than of its callers. Verification is local arithmetic with no JWKS fetch, keeping spec §9.2's property that a control-plane outage does not break running work. The kid is derived from the key (sha256 of its SPKI DER) so it cannot drift, and the keyset is a LIST so rotation needs no flag day. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…x alg-check test Important fixes from review: 1. verifyToken now guards against JSON.parse returning null for header/payload (e.g., when header decodes to the literal string 'null'). Previously this caused a raw TypeError when dereferencing header.alg; now it correctly throws CpError(token_invalid). 2. The alg-none test now uses a properly signed token with alg='none' instead of an invalid signature. This ensures the test actually exercises the alg check at line 188; without it the test would pass when the check is removed (previously it silently passed because the structural check rejected the empty segment before the alg check could run). Mutation verification: - Temporarily disabled alg check: test fails (expected function to throw) - Restored alg check: test passes - All 21 token tests pass, full suite 38/38 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…at rest A Secret is base64, not encryption, and namespace get-secrets reads it. The KEK lives in a separate Secret mounted only into the control plane, so 'read the credential store' and 'read the key that opens it' are two RBAC subjects (spec §6.5). AAD = subject|name, which buys a property rather than tidiness: an attacker who can write Secrets still cannot relabel Alice's ciphertext into Bob's row and spend her key. One opaque decrypt error for every failure, so a caller cannot learn which half of the AAD it guessed right. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…malformed IV/tag
The createDecipheriv and setAuthTag calls can throw if the IV or tag segment
decodes to the wrong byte length (e.g. a truncated or corrupted iv/tag field
from a bit-corruption or truncation that preserves segment count). These were
outside the try block, leaking raw Node crypto TypeErrors instead of the
module's single opaque 'failed to decrypt credential' message.
This violated both the code comment ("One opaque message for every failure")
and the module's security contract (not distinguishing which part of the
serialization is malformed).
Add regression tests for 4-segment inputs where the IV or tag content length
is wrong: too-short IV, too-long IV, too-short tag, too-long tag. All must
throw the opaque error, not a raw TypeError.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…resolution Keyed on the CONSUMER TIER rather than the service (spec §6.1): which trust tier consumes a credential decides whether it can be delivered safely at all, so keying on the vendor buries the governing property and makes every new service a schema change. kind is a registry, so SigV4 is an addition rather than a migration. Resolution refuses to guess: several inference credentials and none named is credential_ambiguous, none at all is credential_required AT SESSION CREATION rather than three turns in (spec §6.4). The name charset is the intersection of the Secret data-key charset, envelope.ts's AAD delimiter, and URL path safety. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Same shape as packages/k8s-sandbox/src/resolve-pod.ts -- pure builders plus one injectable runner, so every Kubernetes interaction is unit-testable with no cluster. kubectl is already in the runtime image, so this needs no client library and no new dependency. Patch bodies ride on stdin rather than argv: /proc/<pid>/cmdline is readable by anything sharing the pod. No Secret operation is ever a list, which is what keeps spec §6.5's list-free Role usable rather than a 403 waiting to happen. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…encryption Redis is entirely ephemeral (spec §2.5), so it cannot hold credentials -- a pod bounce would lose every user's key. The Secret name is derived from the subject, which is what makes every access a get by exact name, so the runtime Role can omit the list verb and a bug cannot enumerate users (spec §6.5). A hashed name also discloses no logins. Writes are merge patches, not apply: apply would drop the subject's other credentials, and read-modify-write would lose updates. list() rebuilds descriptors from annotations, so it never touches the KEK. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The pre-commit prettier hook runs with --write (.pre-commit-config.yaml), so `make lint` mutates the tree rather than only reporting. Task 5 committed before that run, leaving the committed blob unformatted while the working tree carried the formatted version -- which would have misattributed a whitespace diff to whichever later task next staged these paths. Machine-generated by the repo's pinned Prettier 3.9.6 and verified by `prettier --check`; no logic change. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The index lives in Redis BECAUSE Redis is ephemeral (spec §7.1): if Redis is
wiped the sessions are gone, so their ownership records are meaningless, and
co-location means index and data can never disagree.
The owner zset is the only user-facing list path -- LogStore.list() is a
keys('session:*') scan with no owner concept (spec §2.3). Cascade delete is
tombstone → data → index, asserted through the op log, because dropping the index
first leaves data present but invisible. The runtime hash is written through a
field allow-list, so an 'owner' field from a compromised harness is dropped
rather than stored and then trusted.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…dently The "carries no owner field" test only observed the allow-list through getRuntime(), which re-filters on read through the same RUNTIME_FIELDS list. That gave zero regression protection to the write-side property stated in ownership.ts's class doc: deleting putRuntime's filter and storing the raw fields spread would still pass, since getRuntime would strip owner on the way out regardless. Add an assertion against the fake's raw hash store so the write-side filter is pinned on its own; keep the existing read-side assertion too. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…der seam GitHub is not an OIDC provider -- no id_token, no discovery, no JWKS -- so identity comes from exchanging a code for an opaque token and reading GET /user (spec §5.1). The DEVICE flow, because demo-multiuser.sh is a shell script and cannot complete a browser redirect, and because it needs no client secret. The subject is the numeric id, never the login: a login is mutable and reusable after account deletion, so a login-keyed subject would let a new account inherit a departed user's sessions and credentials. The opaque token is used once for GET /user and dropped -- MU1 stores no GitHub token, since OAuth authenticates API calls while the stored credential authorizes egress (spec §5.5). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…rmatting
Two important fixes:
1. Prettier formatting: reflowed 10 lines in identity.ts and identity.test.ts
to respect the 100-char printWidth (lines 82-85, 88, 115, 119-121, 142-144,
in the original files).
2. Test coverage for null-body guard: the typeof null === 'object' trap in
parseBody (line 172) was not covered. Added test case where /user returns
literal JSON null, verifying that the || parsed === null clause prevents a
TypeError when accessing properties on a null object. Verified mutation:
removing the clause causes the test to fail with a TypeError instead of
throwing CpError('unauthorized', ...).
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
One ownership choke point, because the failure mode is authz scattered per-handler where the fifth endpoint someone adds forgets the check (spec §5.4). A non-owner and an unknown session both return 404 with the same code, so a 403 cannot act as an existence oracle; admin does not bypass it, since the role gates ?owner= on the list route only. POST /v1/sessions resolves the inference credential at CREATION and records the choice, so a missing key fails there rather than three turns in, and a later second credential cannot turn a running session ambiguous. It never consults the deployment's ANTHROPIC_AUTH_TOKEN -- the first of the two policy points that make MU1 fail closed before P5's sentinel lands (spec §3.5). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
PUT is write-only and no read-back path exists anywhere in /v1 (spec §4.2), so a compromised api token cannot exfiltrate a stored provider key. The subject comes from the token, never the path or the body, so there is nothing to spoof -- and a smuggled subject field is ignored rather than honoured. GET returns metadata only and decrypts nothing, which is what the annotation-based descriptor buys (spec §6.2). DELETE is 204 whether or not the credential existed: a 404 there would be an existence oracle over credential names. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…onse
The binding field contains the credential binding template (e.g. 'Bearer {token}')
and is specified in spec §6.2 as explicitly non-secret metadata. It is part of the
OpenAPI contract and required in the CredentialDescriptor. The {token} placeholder
in the format string is a template variable, not a credential value.
Update the test assertion to verify field shape exactly (no added secret fields,
exactly six descriptor fields) rather than attempting a blunt substring ban that
fails on correct output containing template variables. The stricter shape assertion
catches accidental leaks while allowing legitimate metadata.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… outage No session -> pod index exists (spec §2.4: a lease member is the run id, not the session id), so the harness self-reports into sh:cp:session:<sid>:runtime. That hash is written by the brain tier, so the projection emits only fields it knows by name -- an 'owner' written there cannot surface as though the control plane had blessed it (spec §7.4). A Kubernetes outage yields sandbox.phase 'unknown' rather than a 500: for an introspection endpoint, partial data with explicit unknowns beats an error (spec §9.2). lease and queue are null rather than zeroed, so MU2 filling them in is visibly a change. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…uard The test 'reports unknown when the harness reported neither' did not verify that the kubectl runner was never invoked. Without asserting on the `called` flag, the test passes whether the early-return guard on line 29 is present or deleted -- if deleted, run() would throw on an undefined selector, and the catch block would return the identical result. Added a `called` flag and assertion to verify the early-return path executes and the kubectl invocation is avoided. This prevents a malformed kubectl call on every request for a session whose harness reported no sandbox. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…hout The single most important behaviour in the design (spec §9.2): the control plane never falls back to the environment. A deleted credential, a tombstoned session, or a token whose subject is not the owner all REFUSE -- and the refusals hold with the deployment's own ANTHROPIC_AUTH_TOKEN present in the environment, which is what makes MU1 fail closed before P5's sentinel lands (spec §3.5). anthropicBaseUrl is never returned undefined: run-turn.ts:313's || would fall through to the environment and send one user's gateway token to the default Anthropic endpoint. A credential whose destination cannot be resolved is a misdirected secret, not a degraded request, so it is refused. Exchange auth is a shared bearer, fail-closed with none configured, compared in constant time and never logged with the presented value (spec §5.3.1). Placeholder mode wins whenever an injector exists, so adding one strictly narrows what the harness may hold (spec §3.6). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ound 1) checkExchangeAuth had no test with equal-length, different-content tokens, so timingSafeEqual's own comparison was never exercised (only the length-mismatch shortcut before it). Add a same-length case. The api-scoped-token rejection test minted a token with no sid, so the downstream !claims.sid guard independently threw token_invalid, masking whether requiredScope: 'turn:write' was actually enforced. Mint the api token with a valid sid against a real session instead. Both gaps verified by mutation: temporarily removing the guard under test makes the corresponding test fail; both guards restored exactly afterward. No change to src/exchange.ts or src/handlers.ts. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ion test The router is driven entirely by ROUTES, so 'which routes need a token' and 'which go through assertOwner' are enumerable data rather than lines scattered through handler bodies. The enumeration test (spec §9.3 test 2) then proves every session-scoped route 404s a non-owner, so a sixth endpoint added without the check fails CI instead of shipping -- and it guards both directions, a route with no handler and a handler with no route. Config fails fast at startup: a control plane booted without a KEK would accept credential writes it cannot encrypt, and one without an exchange token would 401 every turn from a healthy-looking pod. ALLOW_OPERATOR_FALLBACK is read as exactly 'true' so a typo cannot switch it on. Two deviations from the literal reference implementation, both needed to make the reference's own tests pass cleanly rather than to change behavior: - readBody() in server.ts drains the socket to 'end' instead of calling req.destroy() the instant the 64 KiB cap is hit. Destroying mid-stream while unread bytes still sit in the kernel's receive buffer races an abortive RST against the 400 response, so the client sees ECONNRESET instead of the error body. Confirmed with two standalone repro scripts before touching the real file; memory stays bounded because chunks stop being pushed at the same point. - startControlPlane() now removes its SIGTERM listener on the server's 'close' event. Called once per test across many server-starting tests, the literal version leaks one process-level listener per call and trips Node's MaxListenersExceededWarning well before a real suite's tenth test. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…g auth-separation comment Fix round 1 on the router/entrypoint task, per code review. Two one-line fixes, both confined to test/server.test.ts -- src/server.ts and src/main.ts are unchanged. - The fabricated Redis URL in the error-hygiene test's thrown Error was missing its `# notsecret` marker; every other fabricated secret-shaped literal in the file carries one on its own line. - "does not accept the exchange token as an /v1 credential" fails verifyToken's structural JWT check before requiredScope is ever evaluated, so it does not exercise scope separation as its old comment claimed (that property is covered by the session-token-on-/v1 test above). No fixture can make it depend on scope -- the exchange token is an opaque secret that never parses as a JWT -- so the test stays as a guard against a future exchange-token shortcut inside authorize(), and only its comment now says so. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Spec §3.4 says MU1 need not touch run-turn.ts and §3.6 requires a tagged credential that lives there; both hold, because §3.4 is about behaviour (:306 already prefers config over the environment) and §3.6's tag is a type. So this is one type, one optional field, and one new leading || term -- nothing deleted. The tag is not ceremony: a bare string makes placeholder and direct mode indistinguishable, and both failure directions are silent -- a misconfigured injector sends a placeholder upstream, and a deployment that later grows an injector has its real key rewritten. P5 still owns :310-312 and the two env fallbacks; deleting the seed without P5's sentinel would make ANTHROPIC_API_KEY absent and break gateway mode outright (spec §3.5). A test pins that they are still there. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ession token POST /turn enforces exactly one rule -- token.sid === body.sessionId -- and does no ownership lookup, because it holds no ownership data (spec §4.3). The subject is token.sub, never an inbound header: a request carrying both a token and a conflicting X-SH-Subject is rejected rather than resolved by precedence, since a silent winner there is a cross-tenant bug waiting to be written (spec §3.5). SH_REQUIRE_AUTH defaults to false because 14 deploy scripts call /turn with no auth today, but a present-but-bad token is 401 in EITHER mode: the flag governs whether auth is required, never whether it is enforced (spec §4.3.1). An unreachable control plane fails the turn -- it never falls back to the environment (spec §9.2) -- and an authenticated turn does not carry the ambient token in TurnConfig at all. A token-borne sid binds createIfAbsent:true, since it was minted by the trusted tier and the 404 contract exists to stop a client resuming an id it invented. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…orter Fix round 1 for Task 15, addressing two Important review findings: - Add tests for the authenticated SSE branch of /turn (handleTurnStream), which had no coverage: assert createIfAbsent:true, the tagged direct credential, and the dropped ambient token when a valid session token is presented over Accept: text/event-stream, plus a companion asserting createIfAbsent:false is unchanged for an unauthenticated SSE request. - Fix makeRuntimeReporter so a transient Redis connect failure no longer permanently disables the shared, process-lifetime reporter: the catch now clears the memoised ready/index state so the next call retries, instead of forever awaiting an already-rejected promise. Add a test that stubs a first-attempt connect failure followed by a successful retry. Both fixes verified by mutation testing (temporarily reintroducing each bug, confirming the new test fails, then restoring). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The /v1 surface is delivered as a document rather than as prose, and a drift test asserts it and src/routes.ts describe the same route table in both directions -- without it, 'API as a product' degrades into documentation that lies (spec §9.3 test 3). The test also pins the credential-name pattern to the one the code enforces, and asserts no /v1 response schema returns a credential value. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
A plain Deployment, not Knative: it holds a signing key, mints tokens for cron-fired runs with no client present, and is the trusted tier -- scale-to-zero would buy nothing and cost a cold start on every list (spec §3.1). The RBAC is the containment. The serving Role grants get/create/update/patch/delete on Secrets and OMITS list, so a bug cannot enumerate users; list lives in a maintenance Role bound to nothing. That is only expressible because the credential store has its own namespace -- Kubernetes RBAC filters by resourceNames, never by label (spec §6.5). The KEK comes from a different Secret than anything it opens. SH_REQUIRE_AUTH defaults to false and the manifest is deliberately not in the base kustomization: MU1 ships opt-in, and flipping both is MU2 (spec §4.3.1, §10). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Two real GitHub logins, because the whole point is that the subject is attested by GitHub rather than asserted by the caller -- the script cannot fabricate two subjects. It runs with SH_REQUIRE_AUTH=true so the property demonstrated is the real one rather than the permissive default, and restores false on exit including on an aborted run. The load-bearing claim is Claim 9: a subject with no stored credential gets credential_required while the deployment's own ANTHROPIC_AUTH_TOKEN is mounted on the Service. The narration also says out loud what slice 1 does NOT isolate -- the sandbox pool is shared (spec §8.2) -- because a demo that quietly implies isolation it lacks is worse than no demo. The cluster-free test cannot run the demo, but it can pin the things that make it honest or dangerous: the skip-not-fail gates, the flag being set and restored, no token in the transcript, and the shared-pool disclaimer being present. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
harness-egress-policy.yaml is default-deny egress on the harness pod and its allowlist predates MU1: DNS, Redis 6379, relay 8443, 0.0.0.0/0 on 443 and 6443. The per-turn credential exchange targets http://sh-control-plane.default.svc:8080 -- TCP 8080, which no rule there permits -- so on any egress-enforcing cluster (OVN-Kubernetes on OCP, modern kindnet) the exchange fetch is dropped and every authenticated turn ends in 503 credential_unavailable. Fail-closed, but MU1's central data path is dead. NetworkPolicies union, so ship an ADDITIVE policy in control-plane.yaml and leave the base file untouched: MU1 is opt-in, and opting out must stay exactly "do not apply control-plane.yaml" rather than widening the base allowlist for every deployment that never runs a control plane. control-plane-manifest.test.ts said nothing about egress, which is why eighteen task reviews missed this. It now asserts the policy allows the control plane's own pod labels on the port PARSED OUT of SH_CONTROL_PLANE_URL (derived, not transcribed), that its podSelector equals harness-egress-policy.yaml's -- two independently authored files compared -- and that the base allowlist still does not contain that port. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…spec §9.2) Spec §9.2 promises "Redis down => session routes 503, while /v1/credentials stays up, because §7.1 put them in different stores". The code broke the promise: putCredential and deleteCredential patch a Kubernetes Secret and then await index.audit(), which is a Redis write, and OwnershipIndex.guard turns any transport failure into redis_unavailable (503). So with Redis down the Secret was written successfully and the caller was told 503 -- a status that lies, and a user who cannot repair a broken credential during an outage. Preserve the spec's property rather than narrowing it to the code: the audit stream is the right thing to sacrifice for the repair path. auditBestEffort() swallows the audit's failure on those TWO routes only -- not on audit globally, and never on the session routes, whose 503 is honest because their own state is in the Redis that is down. The swallow logs route + credential NAME to stderr so an audit gap is discoverable; the value is never passed to it at all. The §9.2 test exercised only listCredentials, the one credential route that touches no Redis, so it could not fail on this. It is now two-sided, and its fixture is fixed too: it built the outage by overriding the INDEX's methods, which bypasses guard and yields plain Errors, so nothing ever exercised the redis_unavailable mapping the promise rests on. It now wraps a real OwnershipIndex around a Redis client whose every command rejects. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…HROUGH itself PASSTHROUGH was a hand-written, unexported Set of eight codes, and its covering test restated four of them in a second hand-written list -- so half the set was untested and the two lists could drift apart silently. A typo in one member degrades that refusal to credential_unavailable, and a mistyped code reaching statusFor makes res.writeHead(undefined) throw inside the error path, turning an intended refusal into a 500 with a stringified error. Export it; iterate it in the test rather than restating it, deriving each reply's status from statusFor so the fixture cannot drift from the taxonomy; and assert every member is in the real CP_ERROR_CODES, which is the assertion that makes a typo unshippable. Both directions checked: typoing 'unauthorized' fails the CP_ERROR_CODES assertion, and short-circuiting the PASSTHROUGH.has() branch fails the propagation loop. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
demo-multiuser.test.sh checked almost everything with `grep -q '<string>'` over the demo's own text, which a MENTION anywhere satisfies -- comments included. Two holes the review demonstrated: `SH_REQUIRE_AUTH=true` also appears in the demo's header comment at :20, so deleting the real `set_ksvc_env SH_REQUIRE_AUTH=true` at :133 left the check green (verified: 4 remaining matches, all in comments or echo strings); and `asserts a cross-tenant 404` grepped for the literal `Claim 4`, a label rather than a behaviour. Convert the three load-bearing ones to mocked-kubectl call-log assertions, the pattern every sibling in this directory already uses (Makefile:15-19): the flag patched to true, restored to false by the EXIT trap on an aborted run, and `apply -f control-plane.yaml` actually invoked -- plus the flip-before-restore ordering, which is what makes the restore reachable at all. The demo is driven with its live gates satisfied and every external tool mocked, so it reaches the device-flow login and dies there, which is after all three. The rest are anchored to a command position (`^[^#]*= credential_required`, the real cp_code line) so a comment can no longer satisfy them. The secret-leak guard is untouched -- it was verified correct in both directions. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…e text The test at model-gateway.test.ts asserted the source text of run-turn.ts contains 'process.env.ANTHROPIC_AUTH_TOKEN' and 'process.env.ANTHROPIC_BASE_URL'. Both identifiers appear exactly once today, so it passed by luck of the current file: it pinned that an identifier exists, not that it is the right-hand side of a `||`, and deleting `config?.anthropicAuthToken ||` from run-turn.ts:331 left it green. The sibling tests already cover both fallbacks behaviourally, so two thirds of it was redundant and the last third was weak. Replaced with two behavioural assertions on the one construct that had no behavioural proxy -- P5's write-once seed at run-turn.ts:336-338. It seeds ANTHROPIC_API_KEY from the SUBJECT'S credential in a fresh pod, and it does not overwrite one already set. That pins the seed (P5's to remove, not MU1's) and makes its reach an asserted property rather than an unexamined one: in direct mode the first authenticated turn writes that subject's key into the process environment for the pod's lifetime. Reachability is narrow -- service.yaml makes ANTHROPIC_API_KEY a required secretKeyRef -- but it belongs in a test. run-turn.ts is deliberately unchanged. Both directions checked: deleting the seed fails the first, making it unconditional fails the second. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The mechanical items from deferred-findings.md, applied exactly as scoped. B1 k8s-secret-store: a typed guard replaces `JSON.parse(raw) as SecretJson`, and a `guard()` mirroring OwnershipIndex.guard maps a kubectl/API failure to credential_unavailable (503) instead of letting it escape as a plain Error and become 500 internal_error. The taxonomy already had the code and nothing mapped to it. kubectl's stderr never reaches the message -- it can name a token. B2 listByOwner: nextCursor now comes from the tail ZSET MEMBER's score (new zScore on CpRedisLike) rather than the last surviving record, so a ghost at a page tail is no longer re-visited on every later page, and an all-ghost page no longer truncates the walk to null. C1/C2 two unused imports. D1 a redundant re-encode assertion. D4 the "tombstone before delete" test asserts the recorded op ORDER, which is the property; final state was identical either way. E1 errors.test.ts loops CP_ERROR_CODES against an independently transcribed status map, closing four codes that only had a `>= 400` check. E2..E7: the null-header/payload regression, consumer 'control-plane' (four cases, previously zero), zRange/zScore in the outage test so listByOwner is covered, the clientId constructor guard, the no-tab selector reply, and token_invalid (not just 401) on the route-level bad-token test. F1 the redundant `key!` and a no-interpolation template literal -- which required making `bad()` a function declaration, since TS does not narrow through a never-returning arrow const (the footgun credential-store.ts already documents on its own `invalid()`). F2 asserts asymmetricKeyType === 'ed25519' at parse time. F3 one `invalid(...)` call style. F4 a named `isCredentialBinding` guard replaces the double cast. F5 a new `unregisterKind` plus an afterEach, so registerKind no longer widens the module-level KINDS map for the rest of the file. F7 the two notsecret markers. F9 the orphaned JSDoc moves to makeRuntimeReporter, which had none, rather than being discarded. F11 optional:true on the control-plane side's SH_EXCHANGE_TOKEN ref, and the header now says `kubectl describe pod` and explains which failure mode writes a log and which does not. `iss` is now verified, not merely minted. ALLOW_OPERATOR_FALLBACK is renamed SH_ALLOW_OPERATOR_FALLBACK. openapi.yaml's description says it covers the control plane only, since the data plane also serves /v1 paths. Dropped as instructed: B3, F6, F8, F10 (withdrawn -- both tokens are 10 chars). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
server.ts read and JSON.parsed the body before authorize(), so an unauthenticated PUT /v1/credentials/x with malformed JSON answered 400 invalid_json instead of 401 -- telling an anonymous caller something about the route's body handling before it was established that it may talk to the route at all. No information of consequence leaked and the 64 KiB cap bounded the read, but authentication belongs in front of it. `authorize` reads only headers, so the order is free. Every route here is new in MU1, so no existing caller depends on the old codes. Two tests, because the reorder could also have swallowed the invalid_json path rather than moving it: unauthenticated + malformed is 401 token_required, and authenticated + malformed is still 400 invalid_json. The request helper gained a rawBody option so a test can send bytes JSON.parse will reject. The data plane's readBody (knative-server/src/server.ts) is deliberately untouched: it has no size cap at all, which is a larger and pre-existing hole needing its own limit decision. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…e ADR The env var was renamed for prefix consistency in 0a0c0fd (pre-merge is the only cheap moment: renaming after operators adopt it is a breaking change). These two references were the last occurrences of the bare name outside docs/plans/, so without this the spec and the ADR document a variable no deployment reads. Mechanical and factual: the identifier only, on the two lines that name it. No surrounding prose touched, and nothing in §3.6 or §8.1 -- the two spec statements the whole-branch review found false of the shipped code remain the repo owner's to correct, and are deliberately untouched here. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Security review of the MU1 branch found three statements in the design docs no longer match the shipped code; the code is correct, the docs were stale. - Spec §8.1's "Token forgery" row claimed the harness cannot mint a token at all. True at the sandbox tier; false from code execution in the harness pod, where an unscoped pods/exec grant into `default` reaches the control-plane pod's environment and its Ed25519 private key (service.yaml:113-127, control-plane.yaml:138,200-214). Row qualified to the tier that actually holds; ADR-0033's matching Positive bullet updated the same way. - Spec §8.2 named only the shared sandbox pool as an unguaranteed property. Added a second item naming the namespace-collocation exposure above, why it's bounded to harness-tier code execution and not the sandbox tier, and the intended fix (move sh-control-plane and its Secrets into sh-credentials, split the pod-phase RBAC). Tracked as follow-up, not done in slice 1. ADR-0033 gets a matching Follow-up-owed bullet. - Spec §3.6 claimed P5's lock-down invariant wants environment scoping "which MU1 never writes." MU1 does: run-turn.ts:336-338's write-once-if-absent seed, now resolving upstreamCredential first (:329-332), writes a real per-subject key into the process environment on the first direct-mode turn. Corrected to say so, cited against harness/test/model-gateway.test.ts's pinning test, and framed as P5's construct (MU1 forbidden from removing it) — MU1's already-declared divergence from P5 §5 is wider than stated, not a second undeclared one. No code, manifest, or test changed. pnpm -r test and make test-deploy totals unchanged (1375 passed / 17 skipped). Full diff rationale in .superpowers/sdd/2026-09-09-mu1-multi-user-control-plane/spec-correction-report.md. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ontrol plane Adds a guard against the flag flip landing before the control plane moves out of namespace default. While collocated, the harness ServiceAccount's unrestricted pods/exec lets anyone with code execution in the harness pod read the session-signing key, the credential KEK and the exchange token from sh-control-plane's /proc/1/environ (spec 8.2). The assertion is a conjunction read from both manifests so it stays green through today's false+default state and the intended true+sh-credentials end state, failing only on the dangerous combination. A canary case pins the current shipped values and is expected to need updating in the same change that performs the namespace move. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The comment cited control-plane.yaml:129 and :191-205 for the collocation mechanism; the Deployment's `namespace: default` is at :138 and the three secret env injections run :200-214. Both had drifted +9 earlier in the branch. The comment exists so a future reader can find the mechanism, so a stale citation is the one defect that makes it useless. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
The section was written so a link could be dropped in once the follow-up existed. Names rossoctl#248 as a blocker on SH_REQUIRE_AUTH=true, and points at the CI tripwire that refuses the combination until it lands. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
cwiklik
left a comment
There was a problem hiding this comment.
First pass on 628feaa. 58 files, +9722/−46, 40 commits — reviewed the security-critical path in full (token mint/verify, envelope encryption, the exchange, the ownership choke point, the kubectl argv builders, RBAC) rather than by summary, and spot-checked the rest.
One must-fix, three suggestions. The must-fix is not in the crypto or the authz — those hold up. It is a keyset parse that runs per request outside a try block, which turns an operator typo into a 500 on every /turn including unauthenticated ones, and falsifies a property token.ts states in a comment.
What I verified rather than took on trust
The body makes a lot of specific claims. These check out against the tree:
| Claim | Verified |
|---|---|
RBAC omits list; maintenance Role bound to nothing |
control-plane.yaml:80 is get,create,update,patch,delete; credentials-maintenance appears exactly once in the whole deploy/ tree — its own definition, no RoleBinding |
| Every Secret access is a get by exact name | secretNameFor is sh-cred-<sha256(subject)[0:16]>; K8sSecretStore.list() is a single get filtered in process, so the list verb genuinely is not needed |
| A compromised harness can verify but not mint | makeSigner refuses a non-Ed25519 and a public key; data plane only ever calls parseKeyset/verifyToken |
| The three secrets are separately mounted | sh-session-token-key, sh-credential-kek, sh-exchange-token — three distinct Secrets, and a test pins the exchange name/key parity across both tiers |
/turn auth covers the SSE path |
resolveTurnAuth is at server.ts:147, before the wantsStream branch at :153, and auth is threaded into handleTurnStream. This is the bypass #239's spec got wrong by parking :174; here it is right |
| Deleting a session actually revokes | exchange.ts:75-82 re-checks existence, owner match and tombstone on every turn, so local verification does not outlive a delete |
| 404 never 403 for another user's session | assertOwner:85 collapses unknown and non-owner into one session_not_found; admin deliberately does not bypass it |
| The #248 tripwire is real | It is a conjunction read from both manifests with .toBe(false), plus a canary pinning false+default so changing either half alone still fails |
Two things worth calling out as load-bearing and correct: verifyToken pins alg before the key lookup and parses claims only after cryptoVerify, so a forged exp cannot produce a token_expired oracle; and because the Secret name is a hash, nothing caller-controlled reaches spawn('kubectl', args) in leading-dash position — argv injection is closed by construction rather than by validation.
The unauthorized and KEK items below are both cases where the design was careful in one place and did not carry the same care to its neighbour. Neither is a bug today; both get materially more expensive after MU1 is switched on once.
Author: pdettori (MEMBER — maintainer)
Areas reviewed: TypeScript (control-plane, knative-server, harness), Kubernetes/YAML, Shell, Docs, Tests, Build
Agent/IDE config (.claude/.vscode): none — grepped both +++ b/ and rename to forms; 0 renames anywhere in the diff
Commits: 40, all signed off, all Assisted-By per house convention, conventional prefixes throughout
CI status: passing (12/12 on 628feaa — DCO, CodeQL, Trivy, shellcheck, hadolint, dependency-review, lint, proto, deploy-scripts all green)
Base: 45a218d; mergeable_state: blocked pending review
| return; | ||
| } | ||
|
|
||
| const deps = turnAuthDeps(); |
There was a problem hiding this comment.
must-fix — turnAuthDeps() is outside the try that starts on the next-but-one line, and it can throw.
turnAuthDepsFromEnv calls parseKeyset (turn-auth.ts:64), which throws a plain Error — not a CpError — on three conditions: a malformed entry (token.ts:103), a non-Ed25519 key (:112), and a kid that does not match its key (:119). Because the throw lands outside the try at :146, writeAuthError never sees it. It propagates to the route's own handler at :637:
res.writeHead(500, JSON_HEADERS).end(JSON.stringify({ error: String(err) }));So a malformed SH_SESSION_TOKEN_PUBLIC_KEYS produces, for every POST /turn and POST /v1/turn:
500 {"error":"Error: SH_SESSION_TOKEN_PUBLIC_KEYS entry '<kid>' is not a base64 Ed25519 SPKI ..."}
Three separate problems, in increasing order of how much they matter:
-
Internal error text to an arbitrary caller. Low severity on its own — the keyset holds public keys, and the design is explicit that those are not secrets — but it is the pattern
errors.ts:89-92deliberately refuses ("an arbitrary error's text can carry a Redis connection string or a presented token"). The control plane hardened this; the data plane's new auth path feeds the unhardened site. -
It breaks the unauthenticated path too. The throw happens before anything looks at
requireAuthor at whether a token was presented. A deployment adopting MU1 that fumbles the keyset does not get "MU1 is broken" — it gets/turnreturning 500 for everyone, including the 14 existing no-token deploy scripts. The whole opt-in design exists so that turning MU1 on cannot disturb them, and this is the one path where a config error does. -
token.ts:63-68claims this cannot happen, and that claim is the rationale for the curve assertion:Asserting the curve here fails at PARSE time instead:
parseKeysetruns at startup on both tiers, so a mislabelled key becomes a boot error naming the real problem.It runs at startup on one tier.
main.ts:57parses at control-plane boot, so the property holds there. On the data planeparseKeysetis reached only throughturnAuthDepsFromEnv, whichserver.ts:98calls per request — there is no startup call anywhere inpackages/knative-server/src. And/healthzand/readyzdo not touch the keyset, so the pod goes Ready and stays Ready while every turn 500s. That is precisely the "failure arrives per request on a healthy-looking deployment" outcome the comment says it prevents.
The easiest trigger is the one the keyset design exists to support: rotate a key, publish the new one alongside the old, forget to update the kid. parseKeyset:118-122 rejects that — correctly — and on this tier the rejection surfaces as a total /turn outage with no boot-time signal.
Fix, two halves, both small. They address different problems, so I would take both:
- Validate at startup. Call
parseKeyset(process.env.SH_SESSION_TOKEN_PUBLIC_KEYS)once beforeserver.listen(...)(:653) and let it throw. That is what makestoken.ts:68's "on both tiers" true, and it converts an operator typo from a silent per-request 500 into a crashloop with the reason in the container log. - Keep the per-request read, but make it typed. Move
const deps = turnAuthDeps()inside thetryat:146and haveturnAuthDepsFromEnvwrap theparseKeysetcall so a keyset failure becomesnew CpError('credential_unavailable', 'the token keyset is not usable')→ 503 with no raw text. Defence in depth for the case where the env changes after boot, which is the whole reason the read is per-request.
If you would rather not add a boot check, the second half alone fixes the 500 and the disclosure; but then token.ts:63-68 needs its "on both tiers" claim corrected, because a future reader will otherwise rely on a boot error that does not exist.
There was a problem hiding this comment.
Fixed in 206b538, both halves.
I reproduced the 500 before changing anything — a test that posts to /turn with no token after setting SH_SESSION_TOKEN_PUBLIC_KEYS=garbage returned exactly 500 with the stringified parse error, on the unauthenticated path, as you described.
- Boot check.
assertKeysetUsable(process.env)instartServer, beforecreateServer— so nothing binds with an unusable keyset. It deliberately rethrowsparseKeyset's own message, since that one is read by an operator in a container log and its whole value is naming the bad entry. - Typed per-request read.
keysFromEnvwraps the parse and throwsCpError('credential_unavailable', 'the token keyset is not usable');const deps = turnAuthDeps()moved inside the try at:146. Fixed message, so no entry text reaches a caller.
On your point 3: rather than just adding the call, I rewrote the token.ts comment to name both call sites — main.ts via configFromEnv, and turn-auth.ts's assertKeysetUsable from startServer — and to state that /healthz and /readyz never touch the keyset, so dropping either call returns it to a per-request failure on a Ready pod. The prose claim is what drifted; naming the sites makes the next drift visible. Same paragraph added to spec §5.2.
Also tightened a test this exposed: turn-auth.test.ts had a bare expect(...).toThrow() on a malformed keyset, which passed for either a plain Error or the typed one — i.e. it could not have caught this. It now asserts the code and the 503, that the message carries no entry text, and covers all three parseKeyset failure modes rather than one, including the kid-mismatch rotation footgun you called out as the easiest trigger.
| 'session_not_found', | ||
| 'token_invalid', | ||
| 'token_expired', | ||
| 'unauthorized', |
There was a problem hiding this comment.
suggestion — unauthorized is the one member of this set that is never about the caller, and passing it through reports a deployment misconfiguration as the user's fault.
Every other member is caller-attributable: credential_required/credential_ambiguous/credential_not_found are the subject's credential state, endpoint_unresolved is their credential's endpoint, session_not_found is their session, token_invalid/token_expired are their token. A caller can act on all seven.
unauthorized on /internal/credentials has exactly two sources, and neither involves the caller:
checkExchangeAuth(exchange.ts:48-62) — the shared bearer, i.e.SH_EXCHANGE_TOKEN- the defensive re-check at
handlers.ts:389— a routing bug
The user's token is checked separately inside exchangeCredential, and it fails as token_invalid / token_expired / session_not_found. So unauthorized reaching this tier means precisely one thing: the harness cannot authenticate to its own control plane.
What the user sees is statusFor('unauthorized') = 401 (errors.ts:47), which reads as "your token is bad." It is not, and no user action fixes it — they re-run the device flow, get a brand-new token, and it fails identically. Meanwhile the same class of fault reported one line earlier is 503: credential_unavailable covers "control plane unreachable" (:114), "returned a non-JSON body" (:122) and "returned an unknown credential mode" (:137). A control plane that is unreachable is a 503; a control plane that rejects the harness is a 401. Same fault, opposite blame.
Two practical consequences:
- The outage is invisible where you would look for it. 5xx alerting and retry/backoff see nothing; the dashboard shows a 401 spike that looks like users fumbling tokens. This bites hardest on first rollout and on exchange-token rotation — exactly when
SH_EXCHANGE_TOKENis most likely to be wrong on one side, which is why the manifest test pins the name/key parity across both tiers in the first place. - If any client treats 401 as "token is dead, re-authenticate", a one-sided rotation makes every client discard a valid session token and stampede the device flow. I have not checked the clients here, so treat that as a possibility rather than a claim — but it is the reason I would not leave the status wrong.
Fix: delete 'unauthorized' from this set. It then falls through to throw new CpError('credential_unavailable', 'control plane returned ${res.status}') at :130 — a 503, with the status preserved in the message for whoever reads the log. One line, and it puts this failure in the same bucket as the three neighbours it belongs with.
Worth a line in the PASSTHROUGH doc comment too: the invariant that makes this set safe is "every code here is attributable to the caller," and stating it is what stops the next code being added on the weaker test of "the control plane can return it."
There was a problem hiding this comment.
Done in 206b538 — 'unauthorized' deleted from the set.
I checked the two sources before removing it. exchange.ts:54 and handlers.ts:390 are the only ones reachable via /internal/credentials; identity.ts throws unauthorized in five places but all are on the device-flow routes, which the data plane never calls. And exchangeCredential fails the user's own token as token_invalid / session_not_found well before any of that. So the reclassification is safe in the sense that matters: no caller-attributable failure gets swallowed by it.
It now falls through to credential_unavailable at :130 with the status in the message, and there is a test asserting the message still contains 401 — reclassifying should not cost the operator the diagnosis.
Took the doc-comment suggestion too, and made it the lead paragraph rather than a footnote: the invariant is that every member is caller-attributable, with the explicit note that "the control plane can return this code" is the weaker test that let unauthorized in. Pinned by expect(PASSTHROUGH.has('unauthorized')).toBe(false) so the decision is a test and not just prose.
On your unverified possibility — a client treating 401 as "token is dead, re-authenticate": I did not chase it either, and it does not change the fix, so I have left it out of the comment rather than assert something I have not checked.
| // reporter for the rest of the process's life: clear the memoised state so the NEXT call | ||
| // retries from scratch, rather than forever awaiting an already-rejected `ready` (fix round 1, | ||
| // Important 2). Display-only data, so the turn itself must never fail because this did. | ||
| ready = undefined; |
There was a problem hiding this comment.
suggestion — clearing the memoised state does not close the client it discards, so this leaks a Redis connection per failure — the same class of leak sharedRuntimeReporter was built to prevent.
The round-1 fix is right about the bug it targets: without clearing, ready stays a rejected promise forever and the reporter is dead for the life of the process. But client is block-scoped at :257 and is never referenced again, so after ready = undefined nothing holds it and nothing has called quit().
The post-connect case is the one that definitely leaks. Take the scenario the comment itself names — a Redis restart mid-rollout:
connect()resolves,indexis set, calls succeed.- Redis restarts.
putRuntimethrows. The catch clearsreadyandindex. - The client from step 1 is still connected (or reconnecting under node-redis's default
reconnectStrategy) and is now unreachable — no reference, noquit(). - The next call builds a fresh client. Steady state is one live client plus one orphan per clearing event.
Each flap adds an orphan, and each orphan keeps retrying on its own timer. Bounded in practice by pod lifetime — and this is display-only data on a best-effort path, which is why this is a suggestion and not a must-fix — but it is a slow leak in the one function whose sibling comment (:218-224) is entirely about not opening a connection per turn.
Hoist the client so the catch can close it:
let client: ReturnType<typeof createClient> | undefined;
let index: OwnershipIndex | undefined;
let ready: Promise<void> | undefined;
return async (sessionId, fields) => {
try {
if (!ready) {
client = createClient({ url: redisUrl });
const c = client;
ready = c.connect().then(() => {
index = new OwnershipIndex(c as unknown as CpRedisLike);
});
}
await ready;
await index?.putRuntime(sessionId, fields);
} catch {
// Close the client we are about to orphan, or a flapping Redis accumulates one connected,
// retrying client per failure. quit() may itself reject; that must not mask the retry.
void client?.quit().catch(() => undefined);
client = undefined;
ready = undefined;
index = undefined;
}
};The const c = client capture matters: without it the .then closes over the mutable client, which the catch has already set to undefined by the time a slow connect() resolves, and index silently never gets built.
There was a problem hiding this comment.
Fixed in 206b538, with one deliberate divergence.
Not quit(). In node-redis 6 quit() is marked @deprecated use .close instead, and disconnect() likewise defers to destroy() (@redis/client/dist/lib/client/index.d.ts:424-439). Of the two live options, close() "waits for pending commands" — against a server that has just gone away, which is the case that lands here — and destroy() "rejects all commands immediately". For a client being abandoned on a failure path, immediate is what we want, so it is destroy().
That also means no promise to swallow, but destroy() throws on a client that never opened, so it is wrapped — and the state clearing happens before the close attempt, so a throw there cannot skip the retry your round-1 fix exists for.
I took the const c = client capture as written. It is not strictly reachable today — ready is assigned synchronously right after, so no other call can enter the if before the .then — but with client now mutable it is a one-line guard against exactly the silent failure you describe, and the comment says why rather than leaving a future reader to re-derive it.
Four tests, all red before the change:
- post-connect failure (your "definitely leaks" case): destroy called once, and the retry builds a fresh client
connect()rejecting: destroy called- five consecutive flaps: five clients, each destroyed exactly once — the accumulating-orphan steady state
- a
destroy()that throws: the turn still resolves and the next call still retries
The existing mock only exposed connect/hSet, so it would have passed with any close call at all, including one that threw. It now records a destroy per client.
| */ | ||
| export const KEK_BYTES = 32; // AES-256 | ||
| const IV_BYTES = 12; // GCM standard nonce | ||
| const VERSION = 'v1'; |
There was a problem hiding this comment.
suggestion — the wire form is versioned but carries no key identity, so there is no KEK rotation path. Cheap to add now; not addable later without the old KEK.
The contrast with the token keyset is stark and, I think, unintentional. parseKeyset takes a list specifically so key rotation needs no flag day, and token.ts:81-83 spells out the procedure ("publish the new key alongside the old, roll the Service, then switch signing key"). Rotation is discussed eight times across the spec and token.ts. For the KEK: git grep -niE 'kek.*rotat|rotat.*kek|re-?seal' across docs/ and packages/ returns nothing. SH_CREDENTIAL_KEK is a single value, kekFromBase64 accepts exactly one, and the sealed form has a version but no key id.
Concretely, today:
- Changing
SH_CREDENTIAL_KEKmakes every stored credential undecryptable at once. There is no dual-key read window, so there is no rotation — only a cutover that breaks everything sealed before it. - A rotation in progress is indistinguishable from an attack.
open()'s single opaque message (:65-70) is the right call for a caller, but it means "sealed under the previous KEK" and "tampered ciphertext" produce the samefailed to decrypt credential '<name>'. An operator mid-rotation cannot tell which they are looking at. - Recovery is not an operator action. There is deliberately no read-back path anywhere in
/v1(handlers.ts:320-326), so nobody — including the control plane's own admin surface — can export and re-seal. Re-sealing requires the old KEK; without it, every user must re-enter every credential by hand. A leaked KEK therefore forces exactly the outcome the design otherwise avoids: full manual re-entry, at the worst possible moment.
The v1 prefix suggests at-rest evolution was already on your mind — it just stopped at format rather than key identity. Two options, either fine:
- Key id in the wire form:
v1.<kekid>.<iv>.<tag>.<ct>, withkekidderived from the KEK the waykeyIdForderives one from a public key.open()then selects the right KEK and can say "sealed under an unknown key id" — a diagnosis, not a guess — without telling a caller anything about which bytes they got right. - Accept a KEK list, newest-first for
seal, try-each foropen, mirroringparseKeyset. No format change, so no migration; rotation becomes "add the new KEK, re-seal lazily on next write, drop the old one."
The reason to do it in this PR rather than a follow-up: the moment MU1 seals its first production credential, adding a key id becomes a migration that itself needs the old KEK and a re-seal pass that the no-read-back rule forbids. Right now there is no ciphertext anywhere and it is a format decision. That asymmetry — free today, unbounded later — is the whole argument; if you would rather not expand this PR, a ## 9-style scope note recording the gap and the trigger would at least make it a decision rather than an omission.
There was a problem hiding this comment.
Taken, in this PR, as your second option — the KEK list. 206b538.
Your "free today, unbounded later" argument is the one that decided it: there is no ciphertext anywhere yet, so this is a configuration decision now and a migration-needing-the-old-KEK after the first production seal. Doing it later is the one option that is actually expensive.
keksFromBase64 replaces kekFromBase64 and parses a comma-separated ring, newest first; seal uses keks[0], open tries each. K8sSecretStore holds keks: Buffer[]. Rotation is: prepend, roll, let writes re-seal forward, drop the retired key.
Why not the key id in the wire form. I considered it and think the two options are not symmetric. A key id does not enable rotation by itself — open still needs a list to select from — so the list is the necessary half and the key id is the diagnostic addition on top. And it is a format change, which is precisely the cost your own argument says to avoid incurring later; incurring a smaller version of it now to improve an error message did not seem worth it. If the diagnosis becomes a real operational need it can go in as v2 alongside a v1 reader, which the version prefix already allows.
Two things I made sure the ring did not quietly weaken:
- It is not an oracle. One opaque
failed to decrypt credential '<name>'after the whole ring fails, byte-identical to the single-wrong-key and tampered-ciphertext messages. Trying N keys must not turn one outcome into N distinguishable ones; there is a test asserting the exact message for a 3-key wrong ring and for a 1-key one. - The AAD still binds against every key. It is re-bound per attempt, so a relabelled ciphertext is refused by the retired key too, not just the primary. That was the failure mode worth checking — a ring is a set of chances for the relabelling defence to be skipped.
Also: an all-empty value is refused at parse (an empty ring would have had seal reach for keks[0] and encrypt under undefined), seal([]) refuses explicitly rather than surfacing as a node crypto error naming neither the ring nor the credential, and a bad entry names its position — an operator adding a second KEK mid-rotation is exactly who needs to know which of the two is wrong.
Tests include a full cycle through the store, since that is what an operator performs rather than seal/open in isolation: write under the old key → read with [new, old] → next write re-seals forward → [new] alone reads it → [old] alone no longer does. Plus a credential under a dropped key throwing rather than reading as absent — a miss must not look like "no such credential", or a user sees it in list() and cannot use it with no signal to the operator.
Documented in spec §6.5 (including why the ring is in config and not on the wire) and in control-plane.yaml's header, with the three-step rotation procedure spelled out next to the kubectl create secret line.
Addresses the four findings from the first review pass on rossoctl#249. Each was verified against the tree before changing anything; all four held up. MUST-FIX — a keyset parse outside the try, and no boot check anywhere. `turnAuthDeps()` was built before `handleTurn`'s try, and `turnAuthDepsFromEnv` calls `parseKeyset`, which throws on a malformed entry, a non-Ed25519 key, and a kid that does not match its key. That throw therefore skipped `writeAuthError` and surfaced through the route's own catch as `500 {"error":"Error: SH_SESSION_TOKEN_PUBLIC_KEYS entry ..."}` — internal error text to an arbitrary caller, and, because the throw lands before anything reads `requireAuth`, on the unauthenticated path too. The whole point of MU1 being opt-in is that turning it on cannot disturb the 14 existing no-token deploy scripts, and this was the one path where a config error did. `token.ts` also justified its curve assertion with "parseKeyset runs at startup on both tiers". It ran at startup on one: `main.ts` parsed at control-plane boot, but on the data plane the only call was per request, and neither /healthz nor /readyz touches the keyset — so the pod went Ready, stayed Ready, and 500ed every turn. Exactly the outcome that comment claims to prevent. Both halves of the reviewer's fix, since they address different problems: - `assertKeysetUsable`, called from `startServer` before anything binds, so an operator typo is a crashloop naming the bad entry. This is what makes token.ts's "both tiers" claim true; the comment now names the two call sites rather than asserting the property in prose, so the next drift is visible. - the per-request read stays, but `keysFromEnv` converts a parse failure into `CpError('credential_unavailable')` — 503, fixed message, no entry text — for the case the per-request read exists for: the env changing under a serving pod. SUGGESTION — `unauthorized` reported a deployment fault as the caller's. Every other PASSTHROUGH member is caller-attributable. On `/internal/credentials`, `unauthorized` has exactly two sources — `checkExchangeAuth`'s shared bearer and `handlers.ts`'s defensive re-check — and neither involves the caller; the user's own token is checked inside `exchangeCredential` and fails as token_invalid / token_expired / session_not_found. So it meant one thing: the harness cannot authenticate to its own control plane. Passing it through made that a 401 reading as "your token is bad", so a user re-runs the device flow, gets a fresh token, and fails identically, while 5xx alerting and retry/backoff see nothing — worst during first rollout and exchange-token rotation. Dropped from the set, so it falls through to `credential_unavailable` alongside its three neighbours (unreachable, non-JSON body, unknown mode) with the status kept in the message for the log. The doc comment now states the invariant, so the next code is not admitted on the weaker "the control plane can return it". SUGGESTION — a Redis connection leaked per failure. `makeRuntimeReporter`'s catch cleared `ready`/`index` so the next call retries, but `client` was block-scoped and never closed: after clearing, nothing held it and nothing had closed it. A Redis restart mid-rollout left it connected and reconnecting on its own timer, so steady state was one live client plus one orphan per flap — a slow leak in the one function whose sibling comment is about not opening a connection per turn. Client hoisted and closed in the catch, with the `const c` capture the reviewer flagged, without which a slow connect() resolving after the catch would build `index` against a discarded client. `destroy()` rather than the suggested `quit()`: quit is deprecated in node-redis 6 and `close()` waits for pending commands against a server that may be gone. It throws on a client that never opened, so it is guarded — the state clearing must not be skippable. SUGGESTION — the KEK had no rotation path. Now a ring. `SH_CREDENTIAL_KEK` was a single value: changing it made every stored credential undecryptable at once, with no dual-read window, and a rotation in progress was indistinguishable from an attack. Worse, `/v1` deliberately exposes no read-back path, so nothing — not even the control plane's own admin surface — could export and re-seal: the only recovery was every user re-entering every credential by hand, the outcome the design otherwise avoids, at the worst possible moment. `keksFromBase64` now parses a comma-separated ring, newest first; `seal` uses the first key and `open` tries each. Rotation is prepend, let writes re-seal forward, drop the retired key. Done now rather than later for the reason the review gives: no ciphertext exists yet, so this is a free configuration decision today and a migration needing the old KEK — which the no-read-back rule forbids — after the first production seal. No wire-format change, so no migration: a key id in the sealed value would let `open` select instead of try, but that is the format change whose cost is the whole argument for acting now. Try-each is a failed GCM verification or two on a ring of that size. The ring is not an oracle: one opaque message after the whole ring fails, identical to a tampered ciphertext, and the AAD is re-bound per attempt so a relabelled ciphertext is refused by every key, not only the primary. An empty ring is refused at parse rather than reaching createCipheriv as undefined, and a bad entry names its position — which is what an operator adding a second key actually needs. Tests. Every fix is pinned by a test that fails without it. The keyset work also tightened an existing `.toThrow()` that passed for either a plain Error or the typed one, and covers all three parseKeyset failure modes rather than one. The KEK work adds a full rotation cycle through the store — read under the retired key, re-seal forward on write, retire the old key — plus the AAD holding against every key in the ring, and a credential under a dropped key throwing rather than reading as absent. pnpm -r test: 1403 passed / 17 skipped across 10 packages (was 1377/17). pnpm -r typecheck, make lint (tree clean after), make test-deploy: all green. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
cwiklik
left a comment
There was a problem hiding this comment.
Round 2 on 206b538. All four round-1 findings are fixed, and I checked each against the code rather than against the replies. The must-fix is closed on both halves.
Approving. One non-blocking suggestion below, and it is about the operational story around the new KEK ring rather than its cryptography — which holds up.
The four fixes, verified
| Round-1 finding | Status | What I checked |
|---|---|---|
must-fix server.ts keyset parse outside the try |
fixed, both halves | assertKeysetUsable(process.env) at server.ts:657, before createServer. let deps: TurnAuthDeps at :150, assigned inside the try and definitely-assigned afterwards because the catch returns at :157. keysFromEnv yields a CpError, which makes writeAuthError:104's throw err rethrow — the thing that actually produced the 500 — unreachable for a keyset fault. token.ts now names both call sites instead of asserting the property in prose. turn-auth-route.test.ts:334 covers the SSE branch too, which is where I would have expected the fix to stop short. |
unauthorized in PASSTHROUGH |
fixed | Gone from the set (turn-auth.ts:129-137), so it falls through to credential_unavailable at :173 with the status kept in the message. The invariant is now the lead paragraph, and turn-auth.test.ts:365 pins has('unauthorized') === false — so re-admitting it fails a test rather than needing a reviewer to notice. |
| Redis client leaked per failure | fixed | State cleared at :318-321 before the destroy() attempt at :327-331, so a throwing close cannot skip the retry. That ordering was the thing worth verifying and it is right. const c = client capture at :306. Concurrent-failure case is safe too: the second catch sees client === undefined and no-ops rather than double-destroying. |
| KEK had no rotation path | fixed, as a ring | seal uses keks[0] (:70), open loops (:95). All four guards hold: the opaque throw is outside the loop at :113 and carries no key count, with a test anchoring /^failed to decrypt credential 'a'$/ across both a 3-key and a 1-key ring; setAAD is genuinely inside the loop at :98, with a test that relabels against [newK, oldK]; an all-empty value is refused at parse; seal([]) is refused at :71. k8s-secret-store.test.ts:160 walks the full cycle including step 4 — the retired key alone no longer opens it — which is what makes "re-seals forward" a demonstrated property rather than a claim. |
On the two sub-suggestions you declined, both hold up. destroy() over quit() is better than what I proposed — I had not checked node-redis 6's deprecation or close()'s pending-command behaviour, and your reason for preferring it is the right one. Rejecting the wire-format key id also holds: the ring is what creates the dual-read window, and the format cost is real. The suggestion below is deliberately the version of that idea that needs no format change, because your argument against the format change is correct.
Author: pdettori (MEMBER — maintainer)
Areas reviewed (incremental 628feaa..206b538): TypeScript (control-plane, knative-server), Kubernetes/YAML, Docs, Tests
Agent/IDE config (.claude/.vscode): none — grepped both +++ b/ and rename to forms on the incremental diff; 0 renames
Commits: 41 (1 new: 206b538), signed off, Assisted-By per house convention, fix(mu1): prefix
CI status: passing — 12/12 green on 206b538 (DCO, CodeQL, Trivy, shellcheck, hadolint, dependency-review, lint, proto, deploy-scripts)
Incremental diff: 13 files, +614/−80
| # # | kubectl apply -f - | ||
| # # Roll the Service so the new Revision picks it up. | ||
| # # b. Let writes re-seal forward: every PUT /v1/credentials/{name} reseals under the new key. | ||
| # # c. Once nothing is left under the old key, drop it from the list and roll again. |
There was a problem hiding this comment.
suggestion — step (c) is the one step an operator has to decide, and nothing in the system tells them when it is true.
The ring is the right shape and its guards are real — I verified all four. This is about the procedure's terminating condition, which is currently unobservable:
openreturns only the plaintext. The ring index that succeeded is discarded (envelope.ts:100-103).- Nothing counts or logs a non-primary open. The whole control plane has four
console.*calls; none is inenvelope.tsork8s-secret-store.ts. list()deliberately never touches the KEK (k8s-secret-store.ts:190), so the metadata path cannot answer it either.- There is no read-back path in
/v1, so nobody can sweep the store to check. - The audit record carries subject, credential and decision — not the key that opened it.
So "once nothing is left under the old key" is a guess, and when the guess is wrong the outcome is the one the ring was added to prevent, narrowed to whichever subset of users had not re-PUT since the rotation began. Their credential is intact, undecryptable, and — correctly, per your own test's reasoning — throws rather than reading as absent. Recovery is still manual re-entry.
The part that makes it worse than it needs to be: the operator cannot tell whose. The only artifact is server.ts:128's
[control-plane] unhandled Error: failed to decrypt credential 'my-anthropic'
That is the credential name — user-chosen, and it collides freely across subjects. Nothing makes my-anthropic unique, and it is the obvious name for everyone to pick. The subject appears nowhere. So after dropping the key too early, the operator knows some users are broken and cannot enumerate them.
Fix, and specifically not the key id you rejected — this needs no wire-format change. Have open report which ring index succeeded (return it alongside the plaintext, or take an onKeyIndex?: (i: number) => void), and have K8sSecretStore.get log or count it when i > 0, keyed by subjectHash(subject) — already computed, and it discloses no login, which is the property k8s-secret-store.ts:28 already relies on. Two things follow:
- Step (c) becomes a positive signal: "no credential has opened under a non-primary key for N days." Today the only signal is the failure.
- When it does go wrong anyway, the log names the subject hash, which is enough to identify the user who needs to re-enter.
Your rejection of the wire-format key id and this are not the same question. A key id does not enable rotation — the ring does, and you are right that the format cost is not worth paying for key selection. But the key id was also the only thing that would have made completing a rotation observable, and dropping it left that hole open. An index report closes it for free, because it never leaves the process and changes no bytes at rest.
If you would rather not add it here, the honest alternative is to change this line and spec §6.5's "drop the retired key once nothing is left under it" to something an operator can actually perform — keep the retired key for a full credential-lifetime window, say, or require every user to re-PUT before step (c). As written, both documents instruct a check that cannot be carried out.
There was a problem hiding this comment.
Taken, as the index report rather than the doc-only alternative. 8d9d225.
Your framing is right that these are two separate questions and that the format cost was the only reason to refuse the first. An index that never leaves the process costs nothing at rest, so there was no argument left against it.
open returns { plaintext, keyIndex }, and only on success — a failure still yields the one opaque message with no index and no ring size, so the thing I checked hardest is that this does not walk back "a ring must not turn one outcome into N." K8sSecretStore.get logs every keyIndex > 0, keyed by subjectHash(subject).
Log rather than count, since there is no metrics surface to count into and inventing one for this would be the larger change. And deliberately not deduplicated, which was the one design decision in here worth arguing: a once-per-(subject, credential) cache is the obvious tidying, and it would break the signal. "No non-primary open for N days" is then satisfiable by a long-lived pod that logged once on day one while the credential is still stale — a false positive on exactly the condition the line exists to establish. So the steady state is silent and, during a rotation, the volume is the backlog. There is a test pinning the silence, because that is the half that makes it a signal.
On the failure path, the subject hash goes into the message rather than into a second log line: one artifact, no duplication with server.ts:128, and the original error is kept as cause. It reaches no caller — writeError reduces a non-CpError to a bare internal_error with no message at all, which is what makes enriching it safe. I did not reclassify the caller-visible status; a dropped KEK arguably reads better as credential_unavailable than as internal_error, but that is a different question from this one and you did not raise it.
Both documents now name the check instead of implying one. control-plane.yaml step (c) and spec §6.5 give the positive terminating condition, that dropping early is unrecoverable because /v1 has no read-back path, and that the hash in the log is the sh-cred-<hash> suffix — so "enumerate whom to tell" is a concrete instruction rather than a hope.
Four tests, and I checked each against a mutation of the code it covers rather than trusting that it was red once:
- the index report uses a three-key ring, because a boolean "was it the primary" passes a two-key test and still cannot say which retired key is in use (mutation: report
keyIndex + 1) - the log line names the subject hash, the credential and the ring position (mutation: log the raw subject — caught; the raw subject is a login, the hash is not)
- silence when the primary opens (mutation: drop the
> 0guard) - the failure message, asserted exactly rather than by substring — which is also what pins the absence of the raw subject, instead of asserting that separately (mutation: drop the hash)
One assertion I had to tighten before it was worth anything: expect(line).toContain('1') for the ring position is satisfied by the subject hash on its own, since that is hex. It is toContain('ring index 1') now.
pnpm -r test — 1407 passed / 17 skipped; control-plane 306 → 310, which is the four above. pnpm -r typecheck, make lint (tree clean after), make test-deploy, all green.
The ring made rotation possible; nothing made its last step decidable. `open`
computed the ring index and discarded it, nothing counted a non-primary open,
`list()` never touches the KEK, `/v1` has no read-back path to sweep with, and
the audit record carries the decision but not the key. So "drop the retired key
once nothing is left under it" was a guess, and the only signal it had been
guessed wrong was the outage that followed.
- `open` returns `{ plaintext, keyIndex }`. On success only: a failure still
yields one opaque message with no index and no ring size, so trying N keys
cannot become N distinguishable outcomes.
- `K8sSecretStore.get` logs every `keyIndex > 0`, keyed by `subjectHash`.
Deliberately not deduplicated -- the terminating condition is "no non-primary
open for a credential-lifetime window", and a once-per-process log would let a
long-lived pod satisfy it while credentials were still stale.
- The decrypt failure gains the subject hash. `open` names the credential, but a
credential name is user-chosen and collides freely across subjects, so an
operator who dropped a key early knew some users were broken and could not
enumerate which. Nothing reaches a caller: `writeError` reduces a non-CpError
to a bare `internal_error` with no message.
No wire-format change, so no migration: the index never leaves the process.
`control-plane.yaml` step (c) and spec §6.5 previously instructed a check that
could not be carried out; both now name the positive signal.
Four tests, each verified to fail against a mutation of the code it covers:
the three-key index report (a boolean "was it primary" passes a two-key test),
the log line, silence in steady state, and the failure message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…and six more Ten review comments: two must-fix, seven suggestions, one nit. All addressed. ## must-fix: Redis published on every interface `-p 6379:6379` binds 0.0.0.0 in podman, and the image runs with no --requirepass, no ACL and no TLS. On THIS deployment that is the execution path, not data at rest: env/supervisor.env.example ships SH_SANDBOX_DISCOVERY=records, and select-sandbox.ts honours it by never listing pods, so the only inventory of executors is a set of Redis records. Whoever can write them chooses the sandbox every turn dispatches to -- receiving the user's prompts and repository contents and returning whatever they like as the agent's output. Admission control, the fail-closed relay token and RestrictAddressFamilies are all bypassed because none of them sits in that path. Now `-p 127.0.0.1:6379:6379`. Nothing loses access: both env templates already point at redis://127.0.0.1:6379, and the sandbox containers reach the RELAY, not Redis. The invariant was already written down for the admin listener; it simply had not been applied to the listener that exposes session state. setup-vm.test.sh now asserts both directions, with comment lines stripped first because the script deliberately quotes the unsafe form in a comment (the guard fired on its own documentation at first). ## must-fix: the wedge was only closed for connections that END The close-only `load` report left the HELD case open. S x W sockets handed off, issuing no turn, and kept alive drive every slot's estimate to S; main.ts then refuses before hand-off, so no turn arrives, so no `load` arrives, so nothing reconciles -- 429s until a worker crashes. At S=1 that is ONE held connection, and it needs no attacker: a load balancer or monitoring probe on a persistent connection does it by accident. It is also invisible while it happens, because `spurious_refusals` can only rise on a `load` reporting lower than the refusal estimate, and the wedge is the state where no `load` arrives. Now reported on accept as well as on close. The trade, stated in the comment because it inverts a deliberate bias: a real turn's connection momentarily reports its pre-turn count, so the estimate dips for one IPC round trip and the pool can over-admit -- but that is bounded by the round trip, accepted by §3.9, and already counted, where the wedge was unbounded and uninstrumented. The new integration test holds a keep-alive connection open with no turn on it and asserts the estimate still returns to 0 and a second connection still gets 200. Verified non-vacuous: removing the accept-side report fails exactly that test and nothing else. Four unit tests' expectations gained the accept report, each with a note on why. ## suggestions and nit - `void route(socket)` now has a `.catch` that logs and destroys the socket. `route` is async, so a throw is a rejected promise; unhandled, Node's default ends the process, KillMode=control-group takes every worker, and Restart=always brings back a supervisor that lost every in-flight turn. `config.policy.pick` is injected, so this is reachable. Same treatment for `void supervisor.close()` on shutdown, where an unhandled rejection would skip the drain the close() is for; it exits non-zero so a failed drain is distinguishable in the journal. - SH_STATS_INTERVAL_MS was the one numeric env parse outside config.ts's readInt. NaN -> setInterval(fn, NaN) -> a hot timer in the process whose event-loop lag E8 measures. Now `statsIntervalMs`, rejecting non-integers and non-positives, with tests including one proving the old expression really did yield NaN. - SH_SANDBOX_DISCOVERY is now validated at BOOT. Its only caller was on the turn path, so a typo booted cleanly, passed /health, and failed every turn -- the shape rossoctl#249 closed as a must-fix, with SH_ROUTING_POLICY validated at boot two doors away. Placed in the worker rather than the supervisor because resolveDiscoverySource lives in @sh/harness and packages/supervisor ships `dependencies: {}` deliberately; the worker already depends on the harness and is where assertKeysetUsable, the cited precedent, lives. A throw there still surfaces as a crashloop with the reason in the journal. - The relay token no longer enters podman's argv. `-e SANDBOX_TOKEN` (no `=`) takes the value from podman's own environment, so `ps` cannot read what /proc/<pid>/cmdline used to expose. The tests now assert all three halves -- by-name present, value absent from argv, value actually delivered -- which needed the podman mock to record its own environment, since argv alone cannot tell "passed safely" from "not passed at all". - resume.integration.test.ts now skips without REDIS_URL instead of failing. vitest picks it up unconditionally, so a fresh clone saw a red suite with a connection error rather than "1 skipped"; CI hid it by having Redis. Gated on the variable rather than a probe, following handoff.integration.test.ts's convention. - New admin-allowlist.test.ts pins ENV_ALLOWLIST secret-free by NAME SHAPE rather than a list of known-bad names, so it catches the variable nobody thought of, plus a behavioural check that /metrics filters rather than passing the environment through, plus a check proving the pattern matches the names it is meant to catch. - head.ts no longer re-concatenates and re-scans the whole accumulation on every 'data' event. The peer chose the multiplier: at one byte per packet up to MAX_HEAD_BYTES that is 1+2+...+16384 bytes copied and about as many scanned, ~134 MB touched plus a 16384-entry array for 16 KB of input. It is reachable BEFORE admission (main.ts decides saturation before awaiting readHead) with nothing capping concurrency, and it lands in the CPU E8 measures on the routing hop -- in the arm whose whole purpose is comparing sticky routing against leastInFlight. Each chunk is now scanned once with a <=3-byte carry, so the work is linear; `chunks` is untouched, so finish() still concatenates once and the over-cap bytes are still kept. Three tests cover the only thing that can regress -- a terminator straddling a boundary -- and removing the carry fails exactly those two. Verified: harness 403 passed, supervisor 110 passed / 2 skipped, knative-server 331 passed, test-deploy 23 ok, typecheck and lint clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…, and one more All nine comments on rossoctl#250 verified against the code and fixed. Two turned out worse than reported; one new defect surfaced while pinning them. Boot-time validation (medium): worker.ts bypasses startServer, so rossoctl#249's assertKeysetUsable never ran on this deployment — a malformed SH_SESSION_TOKEN_PUBLIC_KEYS booted clean and 503'd every authenticated /turn. Called it alongside the existing SH_SANDBOX_DISCOVERY check. Shutdown drain (medium): KillMode=control-group SIGTERMed every worker alongside the supervisor, and worker.ts installs no SIGTERM handler, so close()'s ordered drainAll -> stop accepting -> awaitIdle drained a pool that was already dead. KillMode=mixed, which is what that sequence assumes. Handle-less conn (medium-low): accept() returned above the load report, so handOff's +1 credit for a handle-less conn was permanent — the same unbounded wedge the 30-line comment below it closes. Report moved above the guard. Abandoned pre-read (reported low, measured worse): readHead's 'closed' outcome was ignored. The predicted cost was W handoff_retries plus a failure; measured, send() does not throw at all, so both counters stay at zero and the WORKER dies instead (worker_exit code 1, restarts 1) — every turn it multiplexed with it. Return early. Hand-off exhaustion (low): destroy() gave an ECONNRESET where main.ts answers 429 for the identical state, and skipped noteRefusal() so the refusal could never reconcile as spurious. noteRefusal + refuse. Computed fallbacks (low): readInt returned fallback unchecked; os.cpus() may be empty, so SH_WORKERS could be 0 — forks nothing, isSaturated([]) is true, 429s forever with no error. Bounds now cover fallbacks, and defaultWorkers clamps. HOME (medium): every Knative manifest running this code sets HOME=/tmp with a writable mount; the unit set none under ProtectHome=true and a --no-create-home user. Environment=HOME=/tmp, paired with the existing PrivateTmp. Reboots (medium): the podman containers had no --restart and no generated unit while both services are WantedBy=multi-user.target. --restart=always plus podman-restart.service (podman-run(1): --restart alone does not survive a reboot), non-fatal if that unit is absent. Redis's missing volume is now stated rather than discovered. README (low): the token instruction tee'd into a file setup-vm.sh had not created yet, and the 1-4 list read as one invocation. Documents the real two-pass first run. Additionally, and not from the review: ChildProcess.send() to a closed channel does not throw — it emits 'error' a tick later, so every try/catch around a send in pool.ts guarded the one shape this failure never takes. On the ordinary shutdown path with any worker already dead, close() -> drainAll() killed the supervisor by uncaughtException before awaitIdle, losing the in-flight turns on every other worker. drainAll now checks connected, and each child gets an 'error' listener. Found because the new abandoned-pre-read test made a dead worker exist at teardown for the first time. Every fix has a test; the two measured behaviours are recorded in the comments and test rationale rather than the predicted ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Implements the MU1 multi-user control plane from
docs/specs/2026-09-08-multi-user-control-plane-design.md(ADR-0033). Before this, the deployment had one shared upstream API key and no notion of a user.
Opt-in and inert by default.
deploy/knative/control-plane.yamlis deliberately not in the basekustomization, and
SH_REQUIRE_AUTHdefaults tofalse— so existing single-user deployments see nobehaviour change. The 14 existing
deploy/knativescripts that drive/turnand/runswithout a tokenkeep working; a present-but-bad token is 401 in either mode. Flipping the default is a later milestone.
What it adds
packages/control-plane(new) — the authenticated/v1surface: Ed25519 session tokens(
alg: EdDSA, hand-rolled onnode:crypto), AES-256-GCM envelope encryption over per-user KubernetesSecrets, a Redis session-ownership index with ordered cascade delete, GitHub OAuth device flow for
identity, and a per-turn credential exchange on
/internal/credentials.packages/knative-server—/turnresolves the caller's own credentialfrom a session token, on both the buffered and SSE paths.
harness/src/run-turn.ts— strictly additive: a taggedUpstreamCredentialunion and one new termat the front of the auth chain. P5's env fallbacks and its write-once
ANTHROPIC_API_KEYseed areuntouched; removing them is P5's to do.
docs/api/openapi.yaml— the/v1contract as a checked-in document, pinned by a drift test thatcompares it against
ROUTES,CP_ERROR_CODESandCREDENTIAL_NAME_REin both directions.deploy/knative/demo-multiuser.sh— a two-account live demo asserting 10 numbered claims, gated onMULTIUSER_LIVE_SMOKE, with a cluster-free test of the properties that make it honest.Design decisions worth a reviewer's attention
only public keys, as plain config — a compromised harness can verify but not mint. The keyset is a
comma-separated list so rotation needs no flag day.
inference/sandbox-egress/control-plane), not onvendor. A multi-secret-field kind is refused for
inferenceat write time, because the exchangedelivers one Bearer token and
basicwould otherwise send a username upstream.subject|name, so relabelling a Secret's annotations cannot repoint one user's ciphertext atanother's name.
list. Secret names derive from the subject, so every access is a get by exact name;reaching another user's Secret requires already knowing their subject.
listlives in a maintenanceRole bound to nothing.
kubectlunavailable, control plane unreachable, exchange 401 —every one refuses the turn rather than falling back to the deployment-wide key.
Known gap, tracked and guarded
#248 — the control plane currently runs in
default, where the harness ServiceAccount holdsunrestricted
pods/exec, so code execution in the harness pod can read the signing key from/proc/1/environ. Spec §8.2 documents this honestly rather than leaving it implicit, and §8.1'stoken-forgery row is qualified to the sandbox tier. It is not reachable from the sandbox tier where
model code runs, and it is inert while MU1 is opt-in with auth off.
A CI tripwire in
packages/knative-server/test/control-plane-manifest.test.tsrefuses the combinationSH_REQUIRE_AUTH=trueand adefault-namespace control plane, so the flag cannot be flipped without#248 landing first.
Verification
pnpm -r test— 1377 passed / 17 skipped across 10 packages.pnpm -r typecheck,make lint(treeclean after),
make test-deploy, andpre-commit run --all-filesacross all 9 hooks: all green.Built task-by-task with a per-task review gate, then a whole-branch review, one fix wave and a scoped
re-review. That final review returned
CHANGES REQUESTEDon three findings — all indeploy/YAML, nonein the TypeScript — and the two blocking ones are fixed here:
NetworkPolicy: the base egress policy default-denied TCP 8080, so on any egress-enforcingcluster (including every OCP profile) the exchange hop was dropped and MU1 was non-functional;
stay up — the audit is now best-effort on those two routes, and logged so the gap is discoverable.
Roughly twenty-five defects were found and fixed during development. The dominant class, by a wide margin,
was a test that cannot fail — so several commits here replace weak assertions with derived ones
(comparing against the code's own exported values) rather than adding new coverage on top.