diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 10b538e..aed43c8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -361,7 +361,7 @@ | task | NK-WP-0056-T16 | wait | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T17 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T18 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | -| task | NK-WP-0056-T19 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | +| task | NK-WP-0056-T19 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T20 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T21 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | diff --git a/docs/evidence/2026-11-03-nk56-t19-approval-exercise/README.md b/docs/evidence/2026-11-03-nk56-t19-approval-exercise/README.md new file mode 100644 index 0000000..a72fb7f --- /dev/null +++ b/docs/evidence/2026-11-03-nk56-t19-approval-exercise/README.md @@ -0,0 +1,68 @@ +# NK-WP-0056-T19: executor approval exercise against the real services (2026-11-03) + +Agent-run, local only, synthetic keys, everything removed afterwards: +`python3 bounded-executor/exercise_approval_identity.py --receipt FILE`. It follows secrets-engine's +`tools/exercise_approval_identity.py`. + +**Setup:** + +- **KeyCape:** a disposable container on the pinned image `9e082dca` (`sso-mfa/k8s/keycape/deployment.yaml`). It is + configured with two clients: + - the `client-from-case` output for case `nk-bind-executor-approval`; + - key-cape's example `approval-engine-operator`, which acts as the revoker and as a service that tries to approve. +- **approval-engine:** source `f41a9ca`, in-process, with its `JWTAuthenticator` against KeyCape's JWKS. +- **Executor:** the real code. That covers `cli.run_request`, `KeyCapeClientCredentials`, `ApprovalEngine`, and + `executor.Run.preflight` and `consume`. Only the OpenBao lane is replaced: the generated client secret is handed to + the client directly. The T18 tests cover that lane. + +Receipt: [`receipt.json`](receipt.json). It is value-free; the harness asserts that no generated secret appears in it. +It records the image, the source commits, the executed files' hashes and the case hash. The commits are +`issuer_contract_commit` (key-cape), approval-engine, and net-kingdom `e1ef4bf` (executor code from `625ad65`). + +| Check | Result | +| --- | --- | +| The request phase creates the approval as `service:nk-bind-executor` through a verified KeyCape JWT | passed | +| Create with another actor | 403 | +| Preflight before approval | refused, `approval_status` | +| A service (`approval-engine-operator`) approves a human-control approval | 403, no entry | +| A human aal2 entry approves | `approved` | +| The executor's preflight accepts KeyCape's aal2 entry | passed (after the fix below) | +| Consume | `consumed`; the read-back shows the admission digest and this run's id | +| Retry with the same digest | idempotent | +| A second run on the consumed approval | refused before consume | +| A different digest | 409; the consumption is unchanged | +| A changed admission | refused, `approval_binding` | +| Revoke before consume | refused by the executor and by approval-engine (409). The executor's own token cannot revoke (403) | +| A human aal1 entry | accepted by approval-engine (no floor declared), refused by the executor (`approval_entry`) | +| The executor client asks for `approval:approve` | KeyCape refuses with `invalid_profile_usage` | +| A token for another scope | 403 on create and on consume | +| A wrong client secret | refused at the token endpoint | + +**Limitations:** + +- approval-engine runs from source, not from the deployed image. +- The founder's entry is seeded through approval-engine's store. It uses the exact assurance object KeyCape mints and + the API persists, but there is no human PKCE login. +- There is no informed-decision presentation; the T21 tests cover it. +- Plain HTTP on loopback. + +## Finding that changed the executor + +**Every real founder approval would have been refused.** approval-engine stores an entry's `assurance` as the +verified token's claim, serialized as JSON: `{"at", "level", "methods", "mfa", "source"}`. KeyCape mints that object +in `token.go` `assuranceClaim`, and approval-engine stores it in `api.py`. The executor compared it with the bare +string `"aal2"`, which the fakes supplied. Against the real store, preflight refused with `approval_entry`. + +The fix (`625ad65`): `executor.entry_is_aal2` parses the object and requires level `aal2` or `aal3` with `mfa: true`. +Anything else fails closed, including a bare `"aal2"`. The fakes use the real shape, and a unit test covers the +refusals. + +## Consequences + +- **The pinned executor image needs rebuilding.** The image pinned in the rapp by T18 (`c67cb044`, `b90951a`) has the + defect. It must be rebuilt from `625ad65` or later and re-pinned before T06. +- **Open for the founder.** approval-engine accepts an aal1 human entry unless the approval declares + `required_assurance` (APPROVAL-WP-0007-T01). Today the executor is the only aal2 guard. The request phase could + declare `required_assurance: {"level": "aal2"}` so approval-engine also enforces the floor at approve and consume + time. That needs the deployed approval-engine to be at schema v7 or later, and a check that informed-decision's + present_existing accepts the field. diff --git a/docs/evidence/2026-11-03-nk56-t19-approval-exercise/receipt.json b/docs/evidence/2026-11-03-nk56-t19-approval-exercise/receipt.json new file mode 100644 index 0000000..fdebb01 --- /dev/null +++ b/docs/evidence/2026-11-03-nk56-t19-approval-exercise/receipt.json @@ -0,0 +1,59 @@ +{ + "schema": "netkingdom.bounded-executor.approval-exercise/v1", + "task": "NK-WP-0056-T19", + "target": "disposable local processes; synthetic keys and credentials", + "started_at": "2026-11-03T22:15:58+00:00", + "keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:9e082dca78f65a4af3e92d3de3705b99e49ca9b298d9562ad0d37511c4306f8d", + "issuer_contract_commit": "82b471fc368a952fa4bbe83e8c8cfcfe56c443b9", + "approval_engine_commit": "f41a9ca82ce0e491e7846c5e2ae33fcbd3bde667", + "net_kingdom_commit": "e1ef4bf6b19b211c62f4ff1ff5c430af78d64250", + "executor_source_sha256": { + "cli.py": "b6cb53a7c71a06c61daf02ff1740d065cd754b1b5d2640571cce2b3f44ad8ff2", + "clients.py": "f0ce580f1beba91b7154051a35c088205de01f26d63a119a23bd926fe1c772cb", + "executor.py": "3da69db9cdd0ba7241246204dd92a57a84779f078ea3cd6d2705f3a8cc11b544" + }, + "case_sha256": "32ba48b4131e22c993bbf8e705bd4f371a0e11222b069279cd196f2b880003c5", + "keycape_client": { + "clientId": "nk-bind-executor-approval", + "audience": "approval-engine", + "allowedScopes": [ + "approval:create", + "approval:read", + "approval:consume" + ], + "grantTypes": [ + "client_credentials" + ], + "serviceSubject": "service:nk-bind-executor", + "tenant": "tenant:platform", + "tokenLifetime": "15m" + }, + "limitations": [ + "approval-engine runs from source in-process, not the deployed image", + "the founder's aal2 entry is seeded through approval-engine's store; no human PKCE login", + "the client secret is handed to the client directly; the OpenBao lane is covered by the T18 tests", + "no informed-decision presentation (T21 tests cover the memo against its real validator)", + "plain HTTP on loopback; production uses KeyCape's public HTTPS issuer" + ], + "checks": { + "request_phase_creates_as_executor_via_verified_keycape_jwt": true, + "create_with_another_actor_refused": true, + "preflight_refuses_unapproved": true, + "service_approve_of_human_control_refused": true, + "human_aal2_entry_approves": true, + "executor_preflight_accepts_keycape_aal2_entry": true, + "executor_consumes_once_and_confirms_by_read_back": true, + "same_digest_retry_idempotent": true, + "second_run_on_consumed_approval_refused_before_consume": true, + "different_digest_refused": true, + "changed_admission_refused_by_binding_digest": true, + "revoke_before_consume_refused_by_executor_and_engine": true, + "aal1_human_entry_refused_by_executor": true, + "executor_client_refused_approve_scope_by_issuer": true, + "token_for_another_scope_refused_by_engine": true, + "wrong_client_secret_refused": true + }, + "status": "passed", + "cleanup_complete": true, + "finished_at": "2026-11-03T22:16:00+00:00" +} diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index 51f4d81..2b23724 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -9,7 +9,7 @@ flavor: design owner: claude topic_slug: netkingdom created: "2026-10-30" -updated: "2026-10-31" +updated: "2026-11-03" related: - NK-WP-0054 - NK-WP-0040 @@ -805,7 +805,7 @@ also needs T20 and T21. ```task id: NK-WP-0056-T19 -status: todo +status: done priority: medium assignee: claude-code depends_on: [NK-WP-0056-T18] @@ -818,6 +818,22 @@ approval-engine's real source and KeyCape's pinned image with synthetic keys (se **Done when:** the exercise passes and its receipt is recorded. +**Done 2026-11-03** ([evidence](../docs/evidence/2026-11-03-nk56-t19-approval-exercise/README.md)). The script is +`bounded-executor/exercise_approval_identity.py`. It runs the real request phase, KeyCape client, preflight and consume +against KeyCape `9e082dca` and approval-engine `f41a9ca`. 16 checks pass. Covered: create, approve, consume, +idempotent retry, different digest, changed admission, revoke-before-consume, wrong scopes and wrong secret, a service +approving, and an aal1 entry. + +**Finding:** approval-engine stores an entry's assurance as KeyCape's JSON object, not a bare `aal2`. The executor +would have refused every real founder approval. Fixed in `625ad65` (`entry_is_aal2`). The request-phase tests no +longer expire with the example window. + +Follow-ups: + +- rebuild the executor image from `625ad65` and re-pin it in the rapp (T18's `c67cb044` has the defect); +- founder decision: should the request phase also declare `required_assurance: aal2`, so approval-engine enforces + the floor? + ## Executor audit sender ```task