From 0f47e0c666e27bcc7811976708dddcb822ef0647 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 18:31:04 +0200 Subject: [PATCH] Publish reviewed bounded JWT ready handoff and retained owner history Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- ...nbao-negative-token-producer-decision.json | 82 ++++ ...penbao-negative-token-producer-decision.md | 356 ++++++++++++++++++ ...-key39-founder-source-design-decision.json | 100 +++++ ...11-key39-founder-source-design-decision.md | 158 ++++++++ ...-10-11-key39-v9-v4-keycape-source-ack.json | 74 ++++ ...ey39-v9-v4-keycape-source-ack.sources.json | 168 +++++++++ ...10-11-key39-v9-v4-keycape-work-record.json | 32 ++ scripts/openbao_negative_private_handoff.py | 266 +++++++++++++ scripts/openbao_negative_private_launcher.py | 238 ++++++++++++ .../test_openbao_negative_private_handoff.py | 228 +++++++++++ .../test_openbao_negative_private_launcher.py | 145 +++++++ ...st_openbao_negative_private_preparation.py | 91 +++++ ...Y-WP-0039-openbao-negative-probe-tokens.md | 64 ++++ 13 files changed, 2002 insertions(+) create mode 100644 docs/openbao-negative-token-producer-decision.json create mode 100644 docs/openbao-negative-token-producer-decision.md create mode 100644 docs/proposals/2026-10-11-key39-founder-source-design-decision.json create mode 100644 docs/proposals/2026-10-11-key39-founder-source-design-decision.md create mode 100644 docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.json create mode 100644 docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.sources.json create mode 100644 docs/reviews/2026-10-11-key39-v9-v4-keycape-work-record.json create mode 100644 scripts/openbao_negative_private_handoff.py create mode 100644 scripts/openbao_negative_private_launcher.py create mode 100644 scripts/test_openbao_negative_private_handoff.py create mode 100644 scripts/test_openbao_negative_private_launcher.py create mode 100644 scripts/test_openbao_negative_private_preparation.py diff --git a/docs/openbao-negative-token-producer-decision.json b/docs/openbao-negative-token-producer-decision.json new file mode 100644 index 0000000..d3b806a --- /dev/null +++ b/docs/openbao-negative-token-producer-decision.json @@ -0,0 +1,82 @@ +{ + "proposal_id": "KEY-WP-0039-T02/private-producer-handoff-v4", + "status": "owner_choice_required", + "native_authority": false, + "native_readiness": false, + "lifecycle_manifest_sha256": "84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14", + "recommended_human": { + "client_id": "netkingdom-bootstrap-console", + "audience": "netkingdom-bootstrap-console", + "scope": "openid", + "callback": "http://127.0.0.1:8876/oidc/callback", + "existing_client_purpose_reuse_admitted": false, + "fresh_mfa_verified": false + }, + "coding_agent": { + "client_id": "codex-railiance-platform", + "audience": "codex-railiance-platform", + "scope": "openbao:login", + "subject": "service:codex:railiance-platform", + "tenant": "tenant:platform", + "secret_env": "KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET", + "live_registration_verified": false, + "existing_custody_admission_receipt": null, + "consumer_delivery_coordinates": null, + "alternative_if_missing": "Platform-owned new narrow admission proposal; do not silently provision" + }, + "handoff": { + "adapter": "scripts/openbao_negative_private_handoff.py", + "pipe_fd": 3, + "private_tmpfs_jwt_files_approved": false, + "exact_launcher_source_digest": null, + "ssh_transport_admission_receipt": null, + "destination": "railiance01 Linux main-thread V5 stdin", + "no_pty": true, + "caller_files_explicit_consumption": true, + "delivery_timeout_seconds": 5, + "decoded_claim_checks_are_signature_verification": false, + "launcher": "scripts/openbao_negative_private_launcher.py", + "ready_schema": "key39-private-input-ready/v1", + "ready_before_any_token_load_consume_write": true, + "ready_requires_nonce_source_envelope_health_window_match": true, + "marker_is_authenticated_authority": false, + "launcher_or_transport_admitted": false, + "remote_child_sha256": "24b7b803e4eb7e6e7c0700a069dad0ec315ee7eb5538a88b9e56147f8fab5cdd", + "remote_wrapper_sha256": "76ac013d1a676a0dfa58aea1ce87b0c3e72483ccd21f586a22c5475e9132f6c1", + "stdio_alias_rejection": "device/inode aliases of0/1/2 and data/control pipes", + "exclusive_same_uid_custody_required": true, + "atomic_inode_delete_guaranteed": false, + "credential_copy_cleanup_verified": false, + "credential_files_remain_possible": true, + "private_unknown_implies_sticky_consumer_unknown": true, + "preparation_total_seconds": 5, + "original_budget_shared_from_main_through_contract_spawn_ready_and_handoff": true, + "source_files_nonblocking_nofollow_regular_stable": true, + "source_contract_envelope_max_bytes": 65536, + "source_hash_file_max_bytes": 262144 + }, + "node_route": { + "selected_reviewed_origin": null, + "allowed_v5_origins": [ + "http://127.0.0.1:18200", + "http://openbao.openbao.svc:8200" + ], + "owner_route_receipt": null, + "public_health_preflight_guard_source_digest": null, + "health_before_tokens_credentials_mutations_restarts": true, + "workstation_observation_is_node_route_evidence": false + }, + "decisions_required": [ + "Founder existing console reuse versus new client", + "NetKingdom current registration/MFA/callback/runtime/guarded health ordering", + "Platform coding-agent live/custody/consumer admission and node route", + "Exact trusted launcher and private-file allowance", + "Fresh exact founder live proposal decision after all evidence", + "Separate founder directSSH control profile, exact launcher and continuously exclusive sameUID private tmpfs custody; marker is not a grant" + ], + "previous_producer_manifest_sha256": "569672ecdb3f1f5b46122183dc3b82e83ff168c07c87db7211ec26bc1ce6184a", + "previous_producer_consumer_refusal_sha256": "a1ed14e6b2db1ae77c3fd638fd7e47b5bd50c25eebba418e31aba89bb4b7f88e", + "lifecycle_proposal_id": "KEY-WP-0039-T02/attended-lifecycle-v9-ready-input", + "previous_pair_source_refusal_sha256": "8353eb8aae11be8ce874887d6a5ca10bf37c6fba73c3f85f0644a7546dd7d33f", + "previous_source_refusal_sha256": "38abf660701194f85934a076a0c01d3047e6bfe790e02ba1b0e4616e356634e6" +} diff --git a/docs/openbao-negative-token-producer-decision.md b/docs/openbao-negative-token-producer-decision.md new file mode 100644 index 0000000..e44cbcb --- /dev/null +++ b/docs/openbao-negative-token-producer-decision.md @@ -0,0 +1,356 @@ +# KEY39 private token producer and node-route decision packet + +Proposal `KEY-WP-0039-T02/private-producer-handoff-v1`, source preparation only. +Lifecycle V5 is unchanged (manifest d1f997624db46693a7126a6eb6435f6b3a353d5bd67dcc8a539683e4b7147285). +This packet names concrete supported flows and a reviewable local handoff. It +supplies no admitted credential route, authenticated approval or native readiness. +T01 stays done and T02 open; no new workplan or task is created. + +## Existing flows and exact owner choice + +Choose existing `netkingdom-bootstrap-console` for the human class refusal if +NetKingdom/founder approve this one attended use of their existing client and +literal `http://127.0.0.1:8876/oidc/callback` listener. Its source registration is +public authorization_code, scopes openid/profile/email/groups, default audience +netkingdom-bootstrap-console. Request only `openid`; do not request approval or +OpenBao permissions. `keycape login` verifies RS256 issuer/access audience/scopes, +ID-token client audience/nonce and matching subject. It binds before emitting a +browser URL, validates exact callback host/path/state, uses S256 PKCE, and has a +five-minute attempt bound. The provider handles password/MFA. Fresh deployed +MFA/registration evidence is required; the handoff requires human aal2/mfa=true. +Do not infer MFA from the client fragment: it has no per-client mfaRequired pin. +Do not run the bootstrap console simultaneously on port8876. The same client's +localhost callback is not substituted: the CLI requires a literal loopback IP. + +Concrete producer invocation after that decision, on the attended workstation: + +``` +keycape login --issuer https://kc.coulomb.social \ + --client-id netkingdom-bootstrap-console --audience netkingdom-bootstrap-console \ + --scope openid --redirect-uri http://127.0.0.1:8876/oidc/callback \ + --out /OWNER-PRIVATE-TMPFS-DIR/key39-human.json +``` + +The private directory is new owner-controlled mode0700 tmpfs outside Git; the +CLI creates the file mode0600 exclusively. No invocation is performed here. +Current browser access-token lifetime is server TokenLifetime; per-client +TokenLifetime is used only for client_credentials. Do not promise a new one-minute +human lifetime by adding that field to a browser registration. + +Alternative only if the owner rejects existing-client use: request one separately +reviewed public client `key39-negative-human`, audience of the same name, exact +callback `http://127.0.0.1:8877/oidc/callback`, scopes [openid], grantTypes +[authorization_code], mfaRequired true, no role/serviceSubject/secretRef. That +requires a new source registration, reviewed native merge/rollback and changed +handoff audience binding. It is not part of V5's six temporary clients and must +not silently enter that mutation. The current adapter intentionally refuses it. +Reuse of informed-decision-approver is unsuitable for this CLI: its registered +callback is HTTPS decisions.coulomb.social, not literal loopback. OpenBao admin +is another source-compatible callback but its admin relying-party purpose is +unnecessary for this probe and is not the selected plan. + +Coding-agent producer is exactly the existing accepted source identity: + +``` +keycape service-token --issuer https://kc.coulomb.social \ + --client-id codex-railiance-platform --audience codex-railiance-platform \ + --scope openbao:login --secret-env KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET \ + --out /OWNER-PRIVATE-TMPFS-DIR/key39-agent.json +``` + +Subject service:codex:railiance-platform, tenant:platform, roles [coding-agent], +principal_type service; fifteen-minute JWT, no refresh or ID token. The secret +must arrive from an existing **admitted** owner-controlled delivery. No client +secret request or generation occurs here. A source fragment is not live +registration or consumer entitlement. The inspected Platform operational identity +is AppRole; its OpenBao token cannot substitute for this JWT. Its historical +coding-agent prose still names tenant:coulomb, whereas current KeyCape source +uses tenant:platform. Platform must reconcile its exact proposed/installed +metadata, not use that historical prose as evidence. + +No reviewed coding-agent client-side custody reader/consumer delivery receipt +was found in the inspected CCR/registry sources. Platform must either return +exact existing admission coordinates and fresh live registration/delivery evidence +or own a new narrow custody/issuer/consumer admission proposal. Do not borrow +secrets-engine's credential, widen its role, read raw KV, or mint an ephemeral +coding-agent lookalike to manufacture this case. If unavailable, the joint +session remains blocked and coding-agent refusal inconclusive; V5 requires both +private token inputs and cannot silently skip the case. + +## Private handoff adapter + +`scripts/openbao_negative_private_handoff.py` adapts the two existing CLI files +to V5 private stdin JSON keys human_token and coding_agent_token. It makes no +network request, login, secret retrieval, registration, deployment or executor +launch. The source is Linux main-thread only. Before file consumption it requires +an inherited **writable pipe FD >=3**, never stdout/stderr, a new owner mode0700 +non-symlink directory outside Git, single-link mode0600 regular files owned by +the current UID, bounded reads and unchanged inode/size/timestamps. JSON rejects +duplicate/nonfinite keys; token length is bounded. Both files must be distinct. +A second inode/metadata compare immediately precedes each unlink. + +The adapter checks decoded issuer/audience/principal/agent claims and at least +300 seconds remaining lifetime, plus human MFA shape. These are not signature +verification or authenticated custody: its tests deliberately use unsigned +synthetic JWT shapes. The founder must run the pinned current KeyCape CLI and +retain exclusive custody continuously; arbitrary downloaded/preexisting files +are inadmissible despite matching mode or claims. CLI signature verification is +the issuer-verification boundary. Require a fresh remaining-lifetime check before +native execution and ensure token validity through the actual presentation; the +five-minute adapter floor is not a grant for a longer session. + +Explicit `--consume-owner-files` authorizes removal of precisely these inspected +caller-owned files. After both load successfully, the adapter consumes them +before writing the pipe and closes its inherited endpoint on every exit. Partial +unlink failure records possible retained files; partial/broken/timed-out delivery +records unknown delivery and must stop before native mutation, with no retry. +Delivery has a hard five-second SIGALRM bound; preexisting timer refuses and +handler/timer state is restored. Receipts contain booleans and fixed failure +codes only; a pipe write completion does not prove downstream validation or native +acceptance. No stdout token path exists. A private pipe still requires a trusted +consumer: the adapter cannot authenticate its peer, approval JSON or native go. + +The founder-reviewed launcher must create the pipe and start a pinned destination +wrapper before running the adapter with that endpoint inherited as FD3. The +wrapper may invoke the already approved SSH transport to railiance01 and forward +stdin without PTY, shell tracing, tee, terminal/stdout copies, token argv/env, +logging, agent custody or persistent remote files. The destination reads the +private JSON with V5; no additional file copy. Exact workstation producer and +node wrapper paths, hashes, SSH host identity/transport grant, consumer startup +order, refusal gating and receipt handling must be owner-reviewed first. This +packet does not ship or admit such a launcher and cannot claim a full handoff. +Token files must be consumed before starting V5; on failed preparation the owner +removes remaining exact files. This packet requires founder choice to admit the +two bounded caller-owned JWT files: V5's no-copy rule for generated six client +secrets is unchanged. Python memory/Unix unlink are not secure-zeroization claims; +use an owner tmpfs directory and process termination. Issued JWTs expire normally. +No issuer-side JWT revocation is claimed. Any OpenBao acceptance remains V5's +bounded self-revoke/Platform reconciliation obligation. + +## Mandatory route and decision ordering + +The child is a Linux main-thread process on railiance01 using its k3s kubeconfig. +Workstation loopback18200 tunnel evidence does not prove node loopback18200; +openbao.openbao.svc normally resolves from Pods, not necessarily the host. Keep +native proposal gated until Platform/NetKingdom verify one of V5's exact origins +**from the actual node process placement**, using a separately reviewed founder +port-forward path if required. Do not expand V5 origins or use workstation role +observations as route proof. + +Before any token exchange, temporary credential generation, Secret creation, +registry/env mutation or restart, the owner preflight must run a bounded public +Bao health request from that exact placement to the selected origin. It follows +no redirect or ambient proxy, caps response bytes and total elapsed time, sends +no auth header and prints only normalized initialized/unsealed/health booleans. +Verify acceptable active/standby health semantics independently, current TLS or +local port-forward ownership, no unintended public bind and non-secret issuer +connectivity. No auth, KV/data path or raw error output. The V5 transport currently +allows login/revoke only; this additional public health/route preflight belongs +in the reviewed launcher/NetKingdom guard, not an unreviewed V5 edit. + +Owner sequence: select source plan -> independent KeyCape/NetKingdom/Platform +reviews -> approved exact launcher plus route/health preflight -> admitted +producer/custody and human browser attempt -> private-pipe delivery -> destination +validates complete stdin/envelope/source/runtime/role receipts -> native action +only under fresh exact founder authorization -> V5 cleanup/revoke plus producer +cleanup. Any unavailable route/custody/client/complete input keeps T02 open. + +## Exact acknowledgments still required + +- KeyCape: selected client/claims/CLI verification, lifetime and MFA source; + this packet is preparation, not a formal source acknowledgment of a launcher. +- NetKingdom: actual existing registration/image, client reuse and callback + listener ownership, browser/workstation/node placement, guarded public health + preflight ordering and exact restart/cleanup runtime pins. +- Platform: coding-agent existing custody/consumer entitlement or new proposal, + corrected tenant metadata, route/port-forward ownership, fresh bounded role/ + leeway/effective-policy observation, session revoke and unknown reconciliation. +- Founder: existing-client purpose reuse versus new registration, explicit private + tmpfs JWT-file allowance, admitted exact launcher/transport/target/window/cleanup + obligation and actual supervised custody. Source acceptance is not that go. + +18 synthetic adapter checks pass: successful private write and file consumption, +stdout/stderr/regular/read-only FD refusal, explicit consumption, symlink/hardlink/ +public mode/parent/Git/oversize/duplicate JSON refusal, wrong audience/MFA/tenant/ +role/AppRole/expiry refusal, replacement before unlink, hard write timeout with +signal restoration and value-safe partial/broken delivery. Original V5/failed +review histories and all source/current owner records remain retained. + +# V2 correction and exact child-ready integration + +The V1 packet text above is retained history. Proposal +`KEY-WP-0039-T02/private-producer-handoff-v2` supersedes its unsafe no-ready +adapter and its precise-inode-removal wording. V1's immutable manifest239aaa +and NetKingdom consumer refusal a1ed14e6b2db1ae77c3fd638fd7e47b5bd50c25eebba418e31aba89bb4b7f88e +remain recorded as the same correction trial. The exact compatible consumer is +`KEY-WP-0039-T02/attended-lifecycle-v9-ready-input`, separately pinned below; +V5/V6/V7/V8 source/review history is never implicitly repinned. No owner admission +or founder go is supplied by V2. + +The KeyCape launcher is `scripts/openbao_negative_private_launcher.py`. It +validates its own and its adjacent adapter's exact source hashes plus the local +copy of the exact remote envelope. The remote child/wrapper paths and hashes +must occur in that envelope; proposal/target/source owner receipts, finite +window/health pin and founder/file-allowance receipt references must match the +reviewed contract. These fields are procedural constraints only: editable JSON +cannot authenticate approval. The founder must authenticate/retain the actual +exact disposition and own this entire launcher/custody/host context. + +The source launcher alone creates SSH stdin/stdout pipes, a fresh random256-bit +nonce and fixed direct SSH argv to railiance01. It uses no PTY, strict existing +host-key checking, batch mode, no agent forwarding, no remote forwarding, finite +connection/keepalive bounds, and a minimal environment that excludes confidential +client credentials. It does not create or request a certificate, token, credential +or route. This is a proposed **separate founder-admitted direct SSH profile**: +the normal warden silent lane has different stdout rules and is not silently +repurposed. The founder/transport owner must review actual node host identity, +SSH certificate/session entitlement and pinned remote paths before execution. +No SSH command was invoked during preparation. + +On railiance01 the independently NetKingdom-owned pinned wrapper creates a +local dedicated child-ready pipe and launches the exact pinned Python child +with inherited SSH stdin. No invented extra SSH file-descriptor forwarding is +used. Only after exact source/envelope checks, original action-window start and +pinned public Bao health succeeds does the child emit a bounded value-free +marker to that local pipe, close it, and enter the V8 bounded private receiver. +The wrapper validates that local marker and forwards exactly one newline JSON +marker on SSH stdout. SSH stdout stays open until child completion; Key reads +only to the first newline, never waits for SSH stdout EOF. Child stdout/stderr +stay suppressed; protected receipt remains exclusive on the node. + +Ready schema `key39-private-input-ready/v1` has exactly these keys: +schema, session_nonce, proposal_id, target, executor_sha256, envelope_sha256, +bao_health_identity_sha256, valid_until_epoch, receive_budget_seconds. Marker +must bind the new nonce and exact consumer proposal/target/source/envelope/health +pin and epoch; receive budget must be finite nonboolean, positive and <=15s. +Marker size is <=4096 bytes. An old/missing/malformed/mismatched/failed/expired +ready marker produces **zero token file loads, zero consumption, zero private +bytes**, then closes local endpoints. A readiness marker is not authentication, +JWT verification, custody admission, routing authority or a live grant. It is +trusted only because the reviewed launcher captures the intended pinned wrapper +under the separately admitted authenticated host/session context. There is no +standalone adapter CLI accepting a ready file, boolean or user-provided marker. + +The adapter validates two distinct kernel pipe identities: private data endpoint +write-only, control endpoint read-only, each FD>=3 and each device/inode distinct +from stdin/stdout/stderr and from each other. Actual duplicates of stdout/stderr +are refused even when their descriptor number is >=3. Captured SSH stdout is a +separate control pipe owned by the launcher; it never becomes token output. + +The launch preparation cap is5s. The adapter has one total5s deadline covering +ready wait, caller-file load, cleanup and delivery. After ready this existing +timer only clips downward against its remaining budget, received child budget +and current remaining approval lifetime. Advertised child budget is not compared +as <=current remaining wall time (transport necessarily consumes time); the +actual delivery timer clips to both. Before each write and after completion the +same absolute expiry/monotonic delivery bound is rechecked. No retries or clock +reset; stdin closes to provide complete EOF. A partial write, transport break or +expiry remains unknown and cannot be native proof. Launcher waits only for a +value-free child/transport exit under remaining action +300s cleanup +30s margin; +local transport teardown never proves remote custody or process reconciliation. +Native receipt/unknown-session acceptance remains Platform/NetKingdom's. + +## Honest file-cleanup boundary + +Owner mode0700/0600, no symlink/hardlink/Git and unchanged identity/size/time +checks remain. **The compare and unlink are not atomic inode-conditioned delete.** +Continuously exclusive same-UID custody of the directory, no concurrent writers +and no attacker sharing that UID are mandatory for this proposed file approach. +No helper boolean can prove that condition. The founder must control the new +private tmpfs directory/process context and retain exclusive custody; no shared +agent/shell/daemon may modify it. Any known interference requires abort and owner +reconciliation before another attempt. + +V2 removes V1's exact-inode/atomic implications. Its retained stat-to-unlink race +check deliberately shows that a concurrent same-UID replacement can still be +removed; that is outside the admitted exclusive-custody assumption and is not +represented as a fixed filesystem capability. Even after nominal path removal, +receipts retain `atomic_inode_delete_guaranteed=false`, +`exclusive_same_uid_custody_required=true`, `credential_copy_cleanup_verified=false` +and `credential_files_remain_possible=true`. `caller_path_removal_completed` +means only these unlink calls completed under that assumption. It is not token +copy inventory, secure erase or cleanup proof. Owner verification/process +termination/tmpfs teardown remains required; issued JWTs expire normally. +If that exclusive context cannot be admitted, do not use these private files; +separately implement/review a process-to-process in-memory producer instead. + +The existing human-client reuse versus new registration, live MFA, coding-agent +client-side custody/consumer delivery, exact authenticated launcher/SSH profile, +private tmpfs files/exclusive custody, actual node route/provider/role/leeway, +quiet restart window and fresh founder disposition are still unapproved choices. +This source packet does not resolve them. Source-pinned readiness eliminates +premature token transport only within the admitted trusted launcher context. + +Exact compatible consumer manifest SHA256 84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14; +child 24b7b803e4eb7e6e7c0700a069dad0ec315ee7eb5538a88b9e56147f8fab5cdd; +node wrapper 76ac013d1a676a0dfa58aea1ce87b0c3e72483ccd21f586a22c5475e9132f6c1. +31 distinct supplied producer/launcher synthetic checks pass; 74 supplied +compatible consumer source checks pass. No actual SSH, login, tokens, custody +or native route was used. Fresh independent consumer and Platform source +reviews of this exact V2 and V9 remain necessary before founder choice. + +# V3 remote-unknown reporting correction + +Proposal `KEY-WP-0039-T02/private-producer-handoff-v3`. Exact NetKingdom V9 +manifest84542ddc and child/wrapper bytes are unchanged; this is only a new Key +launcher reporting revision in the same preparation trial. Platform V2 pair +review finding RPF-KEY39-PAIR-V2-F01 is retained unchanged, SHA256 +8353eb8aae11be8ce874887d6a5ca10bf37c6fba73c3f85f0644a7546dd7d33f. + +After copying adapter outcome, `private_input_delivery_unknown=true` immediately +sets sticky `consumer_outcome_unknown=true` and `consumer_process_completed=false` +before any failure/early return. A complete payload can reach the remote child +before local write/EOF confirmation times out. Terminating local SSH cannot +prove that no remote operation occurred, that the child stopped or that remote +custody/cleanup completed. No retry or subsequent local success rewrites this +attempt. Native acceptance and unknown resolution stay independent owner duties. + +33 distinct checks pass, including actual full kernel-pipe JSON write followed +by SIGALRM interruption before confirmation, and an actual4096-byte partial +write/timeout; both retain remote unknown after local terminate. No actual SSH, +protected consumer or credentials were involved. All V2/V1 source, unsuccessful +reviews and previous narrow issuer acknowledgments remain frozen history. +Exact source return and all prior native/client/custody/transport/file choices +remain required. Source reporting correction is not runtime admission. + +# V4 source-acquisition and shared preparation deadline correction + +Proposal `KEY-WP-0039-T02/private-producer-handoff-v4`, same trial. The exact +Net V9 manifest84542ddc and child/wrapper are unchanged. NetKingdom V3 consumer +refusal NET-PRODUCER-V3-F01 stays frozen, SHA256 +38abf660701194f85934a076a0c01d3047e6bfe790e02ba1b0e4616e356634e6. V1/V2/V3 source and earlier acknowledgments/refusals remain history. + +Actual CLI now starts one absolute monotonic5-second preparation deadline before +contract acquisition. Initial source read, source/envelope/hash checks and SSH +spawn then ready/file/consumption/write phase all use only remaining time from +that original deadline. Phase timers may disarm/rearm, but each is clipped to +original remaining time; launch never restarts the budget spent reading contract. +After ready, child receive budget and absolute approval expiry only clip further. +Once private input is delivered, existing separate action/cleanup completion +wait remains; it cannot renew preparation or mutation authority. + +Contract/envelope reads use bounded4096-byte chunks, at most65536 bytes, absolute +paths, no observed symlink ancestry, O_NOFOLLOW/O_NONBLOCK, single-link regular +files with no group/world write, and stable device/inode/UID/mode/link/size/time +comparisons. FIFOs/devices/symlinks/empty/oversized files refuse before private +file access/SSH. Source digest reads share that path with at most262144 bytes. +All actual CLI source acquisition runs under the finite remaining preparation +alarm; a stalled filesystem call cannot escape this total deadline. Observed +path/source races refuse; continuously trusted exclusive custody remains required +and no atomic pathname/exec protection or authenticated metadata grant is claimed. + +40 distinct producer/launcher checks pass, including actual CLI FIFO/symlink/ +oversize refusal, delayed regular read under the hard timer with restored signals, +a main contract-read delay followed by launch delay that cannot renew the budget, +source drift/chunk bounds and earlier actual full/partialwrite stickyunknown. +Nine retained real paired handshake regressions also pass using actual pinned +Net wrapper and child main with only local fake SSH/health/execute substitutions: +no network, Kubernetes, protected credentials or native mutation. The original +Net fixture files are copied unchanged except the Key stage path, solely as +source regression evidence, never a new foreign formal owner review. + +Fresh exact independent pair source returns and all unadmitted human/MFA/client/ +coding-agent custody/consumer/directSSH/exclusive tmpfs/route/provider/role/leeway/ +readiness-Argo/maintenance-exception/founder/native-acceptance duties still gate +use. No source-only helper choice, ready marker or editable approval JSON supplies +that authority. T01done/T02open; actual native outcome remains pending. diff --git a/docs/proposals/2026-10-11-key39-founder-source-design-decision.json b/docs/proposals/2026-10-11-key39-founder-source-design-decision.json new file mode 100644 index 0000000..2be7eb8 --- /dev/null +++ b/docs/proposals/2026-10-11-key39-founder-source-design-decision.json @@ -0,0 +1,100 @@ +{ + "schema": "founder-source-design-choice/v1", + "proposal_id": "KEY-WP-0039-T02/founder-source-design-v1", + "status": "pending_founder_source_direction_only", + "submitted": false, + "memo_sha256": "1816e48b44eb874e4c97db82ee56ed27c23083ddcfc322d9de9876132e802688", + "source_pair": { + "key_producer_manifest_sha256": "d6f641dc1e4aae7d36658d4199d89af14d2fd7a4bd4e526856eb340234f2d33e", + "net_consumer_manifest_sha256": "84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14", + "net_qualified_consumer_review_sha256": "9566955b77db4a0b041887ef4a6aff6c7a02ef30cc0727a4deb6c29bbdc79518", + "platform_fresh_exact_pair_source_return": "required separately" + }, + "choices": [ + { + "id": "human_producer", + "recommended": "existing_bootstrap_console_purpose_reuse_source_direction", + "options": [ + "existing_bootstrap_console_purpose_reuse_source_direction", + "new_narrow_public_client_source_proposal", + "defer" + ], + "selected": null, + "exact_existing_client": "netkingdom-bootstrap-console", + "exact_audience": "netkingdom-bootstrap-console", + "scope": "openid", + "callback": "http://127.0.0.1:8876/oidc/callback", + "pending": [ + "Net owner purpose reuse/live registration", + "Sole intended workstation CLI listener; no existing port8876 occupant", + "Actual provider MFA receipt; verified aal2/mfa claims" + ] + }, + { + "id": "coding_agent", + "recommended": "return_existing_admission_or_prepare_narrow_platform_proposal", + "options": [ + "return_existing_admission_or_prepare_narrow_platform_proposal", + "defer" + ], + "selected": null, + "client_audience": "codex-railiance-platform", + "subject": "service:codex:railiance-platform", + "tenant": "tenant:platform", + "scope": "openbao:login", + "custody_admission_receipt": null, + "consumer_coordinates": null, + "new_ccr_number": null, + "approle_jwt_substitution_allowed": false + }, + { + "id": "transport", + "recommended": "retain_proposed_founder_direct_ssh_source_profile", + "options": [ + "retain_proposed_founder_direct_ssh_source_profile", + "redesign_and_review", + "defer" + ], + "selected": null, + "host": "railiance01", + "compatible_with_warden_silent_lane": false, + "authenticated_host_session_entitlement_receipt": null, + "runtime_paths": null, + "granted": false + }, + { + "id": "private_material", + "recommended": "conditionally_retain_exclusive_owner_tmpfs_file_source_design", + "options": [ + "conditionally_retain_exclusive_owner_tmpfs_file_source_design", + "review_in_memory_producer_design", + "defer" + ], + "selected": null, + "exclusive_same_uid_custody_verified": false, + "file_allowance_receipt": null, + "atomic_unlink_claimed": false, + "secure_zeroization_claimed": false + } + ], + "permitted_effect_of_source_direction": "Owner source/design preparation and exact independent review only", + "cost_and_risk_scope": { + "external_spend_authorized_eur": 0, + "native_cost_risk_decision": "separate future scoped proposal required", + "source_risks": [ + "Purpose reuse/MFA/callback listener unadmitted", + "Coding-agent custody missing", + "Distinct SSH profile/session identity unadmitted", + "SameUID private-file exclusivity non-atomic/no zeroization", + "Provider/plugin/role/leeway and readiness/Argo/maintenanceexception facts pending" + ] + }, + "native_authorized": false, + "credentials_or_login_authorized": false, + "ssh_or_portforward_authorized": false, + "registrations_or_restart_authorized": false, + "architecture_whole_v0_9_accepted": false, + "agent_autonomy_promoted": false, + "standing_privilege_or_default_flip": false, + "KEY39_T02_closed": false +} diff --git a/docs/proposals/2026-10-11-key39-founder-source-design-decision.md b/docs/proposals/2026-10-11-key39-founder-source-design-decision.md new file mode 100644 index 0000000..ad23cbd --- /dev/null +++ b/docs/proposals/2026-10-11-key39-founder-source-design-decision.md @@ -0,0 +1,158 @@ +# Founder source-design choice for KEY39 private input + +Proposal ID: `KEY-WP-0039-T02/founder-source-design-v1`. +Disposition: pending founder choice. Scope: source-design direction only. + +Recommended: retain the existing bootstrap-console human client for one attended +negative-class producer, require Platform to return exact existing coding-agent +admission or a new narrow owner proposal, and retain the reviewed direct SSH +ready-channel design for later separate admission. Approving that direction +allows owner source preparation and review. It does not allow a login, credential +request/read, client registration, port-forward, SSH session, native preflight, +issuer restart or mutation. + +## Exact source under consideration + +Key producer V4 manifest: +`d6f641dc1e4aae7d36658d4199d89af14d2fd7a4bd4e526856eb340234f2d33e`, +`/tmp/keycape-jwt-producer-handoff-v4-stage/manifest.json`. + +Net consumer V9 manifest: +`84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14`, +`/tmp/keycape-jwt-ready-v9-stage/manifest.json`. + +Launcher `46e57504b3185cc13f1656901a0ca02cc3887527cb09566e270f57b76099ab9c`; +adapter `faf63a3e9504dcbb46847a114275f83d508521e095410a47f92f02b86ba82b1b`; +child `24b7b803e4eb7e6e7c0700a069dad0ec315ee7eb5538a88b9e56147f8fab5cdd`; +node wrapper `76ac013d1a676a0dfa58aea1ce87b0c3e72483ccd21f586a22c5475e9132f6c1`. + +NetKingdom's exact qualified consumer source agreement: +`9566955b77db4a0b041887ef4a6aff6c7a02ef30cc0727a4deb6c29bbdc79518`. +Its 40 maker and 13 independent checks establish source behavior with synthetic +transport/health/private tokens. Platform's fresh exact-pair source return is +required separately. KeyCape's issuer/interface judgment stays inside its INTENT +Tooling boundary. No source return is a protected-operation entitlement. + +## Choices to record + +| Choice | Recommended source direction | Alternative and consequence | +|---|---|---| +| Human producer | Owner-permitted reuse of `netkingdom-bootstrap-console` for one attended CLI session; request `openid` only. | If NetKingdom rejects purpose reuse, propose public `key39-negative-human`, audience same name, literal callback `http://127.0.0.1:8877/oidc/callback`, scopes [openid], authorization_code, mfaRequired true. This needs a new source/registration/rollback packet and changed adapter audience. No registration is implied. | +| Coding-agent input | Require Platform's exact existing `codex-railiance-platform` live registration and client-side custody/consumer delivery receipts. | If missing, stop this native case and let Platform prepare a narrow custody/issuer/consumer admission proposal with coordinates/owners settled first. No invented CCR number or entitlement; AppRole is not a JWT substitute. | +| Transport source profile | Retain proposed founder-owned direct SSH captured-control profile to railiance01, with pinned child/wrapper/envelope and exact-child-ready gate. | If that profile cannot be independently admitted, keep the native session gated and redesign/review a compatible transport. Do not reuse the warden silent lane or eager shell piping. | +| Caller-token material | Source-design option: two owner-created private tmpfs files, mode0700 directory/mode0600 single-link files, then explicit consumption and pipe-only transfer. | If continuously exclusive same-UID custody cannot be established, do not use the file design; separately review an in-memory producer. Source approval does not admit either custody mechanism. | + +No answer, partial answer or rejected choice defaults to a gated native session. +The recommendation is a direction for source preparation, not an automatic +registration, credential generation or admission. + +## Human identity, callback and MFA contract + +Exact candidate client/audience: `netkingdom-bootstrap-console`. +Issuer: `https://kc.coulomb.social`. Requested scope: `openid`. +Literal callback: `http://127.0.0.1:8876/oidc/callback`. + +The current source registration is public authorization_code and lists this +literal callback separately from localhost. KeyCape CLI requires literal +loopback, binds `127.0.0.1:8876` before emitting its browser URL, and accepts +only GET, exact Host `127.0.0.1:8876`, exact path `/oidc/callback`, one matching +state and one code. Fresh state/nonce/S256 PKCE, five-minute attempt timeout, +RS256 discovery/JWKS verification, access audience/requested scopes and ID-token +client audience/nonce/matching subject remain the existing source contracts. +Do not alias localhost, change a callback string or redirect a browser to the +node. Producer and browser are on the founder-owned workstation. + +Before any future attempt, NetKingdom must confirm this exact client is live, +accept its proposed CLI purpose reuse, establish that no bootstrap console or +other listener owns port8876, and identify the sole intended CLI listener/process +in the attended workstation context. Bind failure stops, never kills or adopts +another listener. A public callback and source registration alone are not that +runtime evidence. + +This registration has no per-client mfaRequired pin. Earlier deployment-default +MFA statements are historical, not current proof. The future owner receipt must +confirm actual provider/client MFA behavior; producer handoff requires verified +human claims `assurance.level=aal2` and `assurance.mfa=true`, not an unenforced +caller label. The CLI cryptographically verifies returned tokens; the adapter's +decoded shape check does not. A human token may differ on audience/tenant/roles +as well as principal_type, so this is human-class refusal, not isolated proof +of principal_type enforcement. Browser access-token lifetime uses the server +lifetime; adding per-client tokenLifetime does not shorten that flow. + +## Coding-agent contract and actual missing custody + +Exact accepted source identity: +client/audience `codex-railiance-platform`, subject +`service:codex:railiance-platform`, tenant `tenant:platform`, roles [coding-agent], +principal_type service, scope `openbao:login`, fifteen-minute JWT. +Secret reference name: `KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET`. +This is a reference, never a value or a request for one. + +Source acceptance does not prove the client is live or supply consumer-side +secret injection. No admitted coding-agent custody/consumer receipt was found +in the inspected source set. Platform must return exact existing admission and +fresh registration/delivery ownership or prepare a separate narrow proposal. +Historical Platform coding-agent prose names tenant:coulomb and describes an +operational AppRole; it cannot supersede current tenant:platform issuer source +or manufacture the requested JWT. Do not skip this case, borrow secrets-engine's +credential, retrieve raw KV, widen an AppRole/policy or make a temporary coding- +agent lookalike. Full KEY39 native evidence remains incomplete until settled. + +## Proposed source profile and trust limits + +The source launcher creates captured stdin/data and stdout/control pipes for a +fixed direct SSH command to railiance01: no PTY, strict existing host-key checking, +batch mode, no agent forwarding or remote forwarding, bounded connect/keepalive +and credential-excluding environment. The node wrapper creates a local ready +pipe; SSH does not forward arbitrary extra file descriptors. Child/wrapper/ +envelope hashes, exact target `sso/keycape`, proposal, fresh nonce, public-health +identity hash, original finite window and receive budget bind the returned marker. +No marker means zero caller-token loads/consumption/private bytes. Caller STDIO +aliases are refused by kernel pipe identity. Source acquisition and handoff share +one original five-second preparation budget; V8's bounded complete EOF receive +and original action/cleanup limits remain. Uncertain delivery stays remote +consumer uncertainty after local SSH termination. + +This captured control stdout profile is **incompatible with the existing warden +silent-output lane**. It is not an exception inferred from shell prefixes or +source review. Future direct SSH admission must name the authenticated founder +identity/session/certificate entitlement, exact host key/transport/runtime paths, +producer/consumer process identities and custody/window/cleanup obligations. +No certificate, credential, SSH access or profile activation is requested here. +The ready marker synchronizes source readiness; it does not authenticate a peer, +issuer, provider, founder decision or privileged grant by itself. + +The file option requires continuously exclusive same-UID directory custody, no +concurrent writer and owner tmpfs/process teardown. Stat/unlink is non-atomic. +Receipts explicitly keep atomic delete and copy cleanup unverified and possible +copies true. Unix unlink/Python memory are not secure-zeroization guarantees; +remaining or unknown copies/sessions require owner reconciliation. If these +assumptions cannot be established, the file option must remain unused. + +## Facts still required before a separate native decision + +NetKingdom/Platform must verify the actual same-node route. Workstation +loopback18200 does not prove node loopback18200; Pod DNS is not assumed for the +host. A public health cluster digest detects changes but does not authenticate +the provider. Actual admitted route/Service/cluster identity, freshly observed +installed JWT plugin, role/config/effective policies and all expiration, +clock-skew and not-before leeway interactions are pending native facts. + +Issuer readiness_state, actual Argo ownership and production-tier mapping must +be established authoritatively. Absence of a declaration is not nonproduction. +If adopted/production-approved, temporary direct CAS changes require an explicit +reviewed one-use maintenance exception, close-down entry and reconciliation. +Headroom, quiet restart window, exact runtime revision, approved temporary names +and cleanup/recovery remain required. + +Future native founder go must be a distinct authenticated scoped disposition for +exact approved source/runtime/target/window/route/producer/credential custody and +bounded abort/revoke/restoration/recovery duties. Source-design approval today +cannot be replayed as that go. Actual eight refusals including expiry positive +control, revoked unexpected acceptance, baseline restoration/removed clients/ +temporary-object absence/private-copy reconciliation remain required afterward. + +This memo approves no architecture-wide v0.9 acceptance, agent autonomy promotion, +standing privilege/default flip, routing activation, native KEY39 closure, +Platform/Mason acceptance or Secrets Engine full adoption. It leaves T01done and +T02open under the existing work record, with all failed/revised source history. diff --git a/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.json b/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.json new file mode 100644 index 0000000..553e90a --- /dev/null +++ b/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.json @@ -0,0 +1,74 @@ +{ + "schema": "owner-source-review/v1", + "review_id": "KEY-ACK-20261011-KEY39-V9-PRODUCER-V4", + "reviewer": "codex acting in KeyCape issuer-owner scope", + "role_scope": "KeyCape INTENT authentication/issuance/claims and caller token lifecycle only", + "decision": "approved", + "qualification": "Exact bounded issuer/private interface source compatibility only; all native/transport/custody/founder admissions remain false", + "consumer_proposal_id": "KEY-WP-0039-T02/attended-lifecycle-v9-ready-input", + "consumer_manifest_sha256": "84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14", + "producer_proposal_id": "KEY-WP-0039-T02/private-producer-handoff-v4", + "producer_manifest_sha256": "d6f641dc1e4aae7d36658d4199d89af14d2fd7a4bd4e526856eb340234f2d33e", + "work_record": { + "task": "KEY-WP-0039-T03", + "status": "done", + "same_task_refinement": true, + "current_owner_work_record_sha256": "57d5f1a9cd5d15737d47a9273bf01355ba818b2748e1a43fd7ff2138bed50f4f", + "completed_inspection_receipt_path": "/tmp/keycape-jwt-v9-v4-owner-decision-stage/key-cape/docs/reviews/2026-10-11-key39-v9-v4-keycape-work-record.json", + "completed_inspection_receipt_sha256": "b5074fdc22eb8f62ae035254bfb8311272cf8a7ec6ed14b60eebd14f5ef4046c", + "completed_before_formal_acknowledgment": true + }, + "scope_accepted": [ + "Original finite source/window/public health gates precede nonce-bound ready and complete bounded private input; action window is not renewed", + "V4 actual CLI regular bounded nofollow/nonblock stable source acquisition starts under one ORIGINAL5s preparation budget shared with launch/ready/filewrite", + "Exact child/wrapper/envelope/health/window source marker matches private Key reader before ANY token load/consume/write; fixed owner-created SSH data/control pipes and stdio alias refusal", + "Private uncertain write remains sticky remote uncertainty after local transport teardown; full/partial failure histories retained", + "Existing issuer/client claim design, original runner and explicit unauthenticated shape/ready metadata limits unchanged", + "Non-atomic unlink requires continuous exclusive sameUID custody; copy cleanup remains unverified/possible" + ], + "findings": [], + "fresh_qualified_net_consumer_review": { + "path": "/tmp/netkingdom-key39-producer-v4-consumer-review-stage/owner-review.json", + "sha256": "9566955b77db4a0b041887ef4a6aff6c7a02ef30cc0727a4deb6c29bbdc79518", + "source_only": true, + "not_a_KeyCape_Net_runtime_review": true + }, + "validation": { + "key_source_tests_retained": 40, + "net_exact_pair_independent_checks": 13, + "own_fresh_interface_tests": 3, + "all_passed": true, + "own_checks_path": "/tmp/keycape-jwt-v9-v4-owner-decision-stage/evidence/test_keycape_ready_interface.py", + "own_checks_sha256": "ba85e5ff2cda5c7c2cc86f50bde67bca027e2e63be1c5195f06764b8e1cd5716", + "synthetic_unsigned_private_fixtures_only": true, + "native_or_credentials": false + }, + "excluded": [ + "Net formal runtime/route/port-forward/restarts/cleanup acceptance", + "Platform formal provider/plugin/role/policy/leeway/custody/PEP/backend acceptance", + "Founders actual identity/session/source/runtime/route/window/native decision", + "Human client purpose/MFA/listener and coding-agent live custody/consumer admission", + "Direct SSH profile, file allowance/exclusive UID context, authenticated transport/runtime authority", + "Whole architecture v0.9 acceptance, autonomy promotion, standing privileges/default flips or native task closure" + ], + "pending_platform_exact_pair_return": "Required separately; old V2pairrefusal is not repinned", + "founder_source_design_memo": { + "path": "/tmp/keycape-jwt-v9-v4-owner-decision-stage/key-cape/docs/proposals/2026-10-11-key39-founder-source-design-decision.md", + "sha256": "1816e48b44eb874e4c97db82ee56ed27c23083ddcfc322d9de9876132e802688", + "status": "unsubmitted source-direction choice only", + "native_go": false + }, + "sources_receipt_sha256": "2a7a587264ceeb902040417d0bc5c329af461590d8bc99edf818ec5699f0a5b0", + "prior_v2_bound_ack_sha256": "8293e18dcffe7b8e0748880753974219b99d347a173f75a32cc1aad2b4c30f95", + "preserved_refusals": [ + "a1ed14e6b2db1ae77c3fd638fd7e47b5bd50c25eebba418e31aba89bb4b7f88e", + "8353eb8aae11be8ce874887d6a5ca10bf37c6fba73c3f85f0644a7546dd7d33f", + "38abf660701194f85934a076a0c01d3047e6bfe790e02ba1b0e4616e356634e6" + ], + "same_trial_no_additional_scored_success": true, + "native_authority_granted": false, + "native_route_observed": false, + "private_producer_admission": false, + "founder_design_disposition_submitted": false, + "KEY39_T02_closed": false +} diff --git a/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.sources.json b/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.sources.json new file mode 100644 index 0000000..524f47a --- /dev/null +++ b/docs/reviews/2026-10-11-key39-v9-v4-keycape-source-ack.sources.json @@ -0,0 +1,168 @@ +{ + "owner": "key-cape", + "consumer_manifest_path": "/tmp/keycape-jwt-ready-v9-stage/manifest.json", + "consumer_manifest_sha256": "84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14", + "producer_manifest_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/manifest.json", + "producer_manifest_sha256": "d6f641dc1e4aae7d36658d4199d89af14d2fd7a4bd4e526856eb340234f2d33e", + "qualified_net_consumer_review_path": "/tmp/netkingdom-key39-producer-v4-consumer-review-stage/owner-review.json", + "qualified_net_consumer_review_sha256": "9566955b77db4a0b041887ef4a6aff6c7a02ef30cc0727a4deb6c29bbdc79518", + "exact_consumer_entries": [ + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/openbao-negative-session.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/openbao-negative-session.py", + "original_sha256": "da4e0ab767e0c3bebb48bcf5ab578b50845d9fa7211a6771661a0e1baa4314ab", + "staged_sha256": "24b7b803e4eb7e6e7c0700a069dad0ec315ee7eb5538a88b9e56147f8fab5cdd", + "previous_frozen_staged_sha256": "da4e0ab767e0c3bebb48bcf5ab578b50845d9fa7211a6771661a0e1baa4314ab" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/test_openbao_negative_session.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/test_openbao_negative_session.py", + "original_sha256": "01e23f2b4fc77824a161a57acb3a52d19aad846b3b6c0fd3abd81afa1dc2985c", + "staged_sha256": "01e23f2b4fc77824a161a57acb3a52d19aad846b3b6c0fd3abd81afa1dc2985c", + "previous_frozen_staged_sha256": "01e23f2b4fc77824a161a57acb3a52d19aad846b3b6c0fd3abd81afa1dc2985c" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/test_openbao_node_route.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/test_openbao_node_route.py", + "original_sha256": "9f3d8d4297c5ef68f6c6bf7ca21e981e37d7eceb836a60bc254d33b0f5129047", + "staged_sha256": "1c57053be6e8472174e4034fdfbf34b3e1729f5e239b3a9af89d9e59a08d6cb6", + "previous_frozen_staged_sha256": "9f3d8d4297c5ef68f6c6bf7ca21e981e37d7eceb836a60bc254d33b0f5129047" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/openbao-negative-node-route.md", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/openbao-negative-node-route.md", + "original_sha256": "a04c94512efec7a524b15a3eec61d86de5a41df872e59a3114093063a11e75a0", + "staged_sha256": "cad3e3dc481d45295cc014e48285e81614911dfb4a10523883f3044f6085f363", + "previous_frozen_staged_sha256": "a04c94512efec7a524b15a3eec61d86de5a41df872e59a3114093063a11e75a0" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/test_openbao_private_input.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/test_openbao_private_input.py", + "original_sha256": "a59c2cd4ec895c1718d52fd12f64666f1be45ae423fde476755b6d77a89280c2", + "staged_sha256": "84277edb5bd7858885173dea802b6bdc4f0f0c7785d6e200ee9a98d8553891a1", + "previous_frozen_staged_sha256": "a59c2cd4ec895c1718d52fd12f64666f1be45ae423fde476755b6d77a89280c2" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/openbao-negative-node-wrapper.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/openbao-negative-node-wrapper.py", + "original_sha256": null, + "staged_sha256": "76ac013d1a676a0dfa58aea1ce87b0c3e72483ccd21f586a22c5475e9132f6c1" + }, + { + "repo": "net-kingdom", + "path": "sso-mfa/k8s/keycape/test_openbao_private_ready.py", + "staged_path": "/tmp/keycape-jwt-ready-v9-stage/net-kingdom/sso-mfa/k8s/keycape/test_openbao_private_ready.py", + "original_sha256": null, + "staged_sha256": "8a7a033b1c509ba21397d1d74580c44f24592045b2b9de50fe87638607224277" + } + ], + "exact_producer_entries": [ + { + "repo": "key-cape", + "path": "docs/openbao-negative-token-producer-decision.json", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/docs/openbao-negative-token-producer-decision.json", + "original_sha256": null, + "staged_sha256": "7b3e4a53ac4af33c780f16c3f1aa9cde0154040ad2747a8d361dd79bbb7f435c", + "previous_frozen_staged_sha256": "ee5dccb8d3b263584127edff73a6f9292b29eb2725f982cdfdbd784141bdd98f" + }, + { + "repo": "key-cape", + "path": "docs/openbao-negative-token-producer-decision.md", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/docs/openbao-negative-token-producer-decision.md", + "original_sha256": null, + "staged_sha256": "38e4c9660ca57a1fb6e5ff04f214af61dd1a58abf564a5ed22216d909e27fc73", + "previous_frozen_staged_sha256": "50994f0882bac8c78227e8d8669f8be7ab9a07a8a35f1f8cc0bc5caeac275aa3" + }, + { + "repo": "key-cape", + "path": "scripts/openbao_negative_private_handoff.py", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/scripts/openbao_negative_private_handoff.py", + "original_sha256": null, + "staged_sha256": "faf63a3e9504dcbb46847a114275f83d508521e095410a47f92f02b86ba82b1b", + "previous_frozen_staged_sha256": "faf63a3e9504dcbb46847a114275f83d508521e095410a47f92f02b86ba82b1b" + }, + { + "repo": "key-cape", + "path": "scripts/openbao_negative_private_launcher.py", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/scripts/openbao_negative_private_launcher.py", + "original_sha256": null, + "staged_sha256": "46e57504b3185cc13f1656901a0ca02cc3887527cb09566e270f57b76099ab9c", + "previous_frozen_staged_sha256": "2a54568df541c38d43ca2482f037761770e6df206fb1cb88c1222ed7291f5e98" + }, + { + "repo": "key-cape", + "path": "scripts/test_openbao_negative_private_handoff.py", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/scripts/test_openbao_negative_private_handoff.py", + "original_sha256": null, + "staged_sha256": "e4eb600718e7d93b0e801ac3ab1427af9895a6e5d877c1134369e049e0849797", + "previous_frozen_staged_sha256": "e4eb600718e7d93b0e801ac3ab1427af9895a6e5d877c1134369e049e0849797" + }, + { + "repo": "key-cape", + "path": "scripts/test_openbao_negative_private_launcher.py", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/scripts/test_openbao_negative_private_launcher.py", + "original_sha256": null, + "staged_sha256": "243426a85075b3447adeb2ca320a13711409744f71837a06699de6ad5b04ee9d", + "previous_frozen_staged_sha256": "243426a85075b3447adeb2ca320a13711409744f71837a06699de6ad5b04ee9d" + }, + { + "repo": "key-cape", + "path": "scripts/test_openbao_negative_private_preparation.py", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/scripts/test_openbao_negative_private_preparation.py", + "original_sha256": null, + "staged_sha256": "8e4dd327c4a339a0151ecba57db5987965b5d536ca18123fb7c74a047a241548" + }, + { + "repo": "key-cape", + "path": "workplans/KEY-WP-0039-openbao-negative-probe-tokens.md", + "staged_path": "/tmp/keycape-jwt-producer-handoff-v4-stage/key-cape/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md", + "original_sha256": "57d5f1a9cd5d15737d47a9273bf01355ba818b2748e1a43fd7ff2138bed50f4f", + "staged_sha256": "f6113d269f127976518acb48eca818fd5d6f12421f4e4c742e8a84f49a14360b", + "previous_frozen_staged_sha256": "1385f0b692a031fd52046e1dd98c42ff7a5a4ca4d1c340551ec8751a07e80ee6" + } + ], + "completed_inspection_receipt_sha256": "b5074fdc22eb8f62ae035254bfb8311272cf8a7ec6ed14b60eebd14f5ef4046c", + "founder_source_memo_sha256": "1816e48b44eb874e4c97db82ee56ed27c23083ddcfc322d9de9876132e802688", + "founder_source_choices_sha256": "d2f6e2fc733d8092a27a1dafb2f77601cbff398c5606b12bb053b4cd7bcd4091", + "current_key_source_pins": [ + { + "path": "AGENTS.md", + "sha256": "77c8e1ac762e7c101cd09a3aa184993309ed057821bd53fb0d318979a45c9315" + }, + { + "path": "INTENT.md", + "sha256": "b22cb440691bb399b064ee10ab1a03e1e07838da1051bf6235fbfe2cf820eb10" + }, + { + "path": "src/internal/authclient/cli.go", + "sha256": "376a8e0d1d60825a8b3ef21d371e3fc353c0a34e461712a6899e264278edbe7f" + }, + { + "path": "src/internal/authclient/client.go", + "sha256": "61fb150349c5d3714450a90834c59af9a579a8a649c4149297a402cfcf6e39c5" + }, + { + "path": "src/internal/server/oidc/token.go", + "sha256": "8422cc9dbd39c88dfe835b43f6262cb120bc0d2e5b08898a151167b7555368d3" + }, + { + "path": "config/service-clients.example.yaml", + "sha256": "b7e17698a0946f387c481b7e27fe9ddea22cdd9db2e34e32a50457e02a34b18e" + }, + { + "path": "docs/native-authentication.md", + "sha256": "378a5fa8174580bde811b716ec8e987339013b9f8d7725cb398fa3b9f8d95655" + }, + { + "path": "workplans/KEY-WP-0039-openbao-negative-probe-tokens.md", + "sha256": "57d5f1a9cd5d15737d47a9273bf01355ba818b2748e1a43fd7ff2138bed50f4f" + } + ], + "current_net_human_fragment_source_pin": "5f727f3146016184da867ce3c304ac0b19f7ceee63c479f6ea2606f7183640f5" +} diff --git a/docs/reviews/2026-10-11-key39-v9-v4-keycape-work-record.json b/docs/reviews/2026-10-11-key39-v9-v4-keycape-work-record.json new file mode 100644 index 0000000..acf3e48 --- /dev/null +++ b/docs/reviews/2026-10-11-key39-v9-v4-keycape-work-record.json @@ -0,0 +1,32 @@ +{ + "record_kind": "completed-owner-source-inspection", + "workplan": "KEY-WP-0039", + "task": "KEY-WP-0039-T03", + "same_completed_task_refinement": true, + "status": "done", + "established_before_formal_source_acknowledgment": true, + "current_owner_work_record_path": "/home/worsch/key-cape/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md", + "current_owner_work_record_sha256": "57d5f1a9cd5d15737d47a9273bf01355ba818b2748e1a43fd7ff2138bed50f4f", + "exact_net_manifest_sha256": "84542ddcff00932fc7ce22dcb2626be8942a3c006c5098a62fdd6b2478d0cd14", + "exact_producer_manifest_sha256": "d6f641dc1e4aae7d36658d4199d89af14d2fd7a4bd4e526856eb340234f2d33e", + "scope": "KeyCape issuer/private caller-token interface only, not Net or Platform formal role", + "completed_checks": [ + "Exact source/manifest entries verified", + "Fresh qualified Net consumer source review verified exact95\u2026sha and pair fields", + "Three actual child emitter + current Key V4 reader tests pass without token file loads on wrong/expired marker", + "Current CLI issuer/client/callback/MFA source inspected", + "Source-design choices separated from native authority; staged existing T02 notes untouched" + ], + "validation": { + "key_maker_checks_retained": 40, + "net_independent_exact_pair_checks": 13, + "own_fresh_interface_checks": 3, + "no_network_credentials_native_or_owner_checkout_writes": true + }, + "prior_v2_bound_ack_preserved_sha256": "8293e18dcffe7b8e0748880753974219b99d347a173f75a32cc1aad2b4c30f95", + "producer_T02_notes_preserved_not_applied": true, + "states_retained": { + "T01": "done", + "T02": "open" + } +} diff --git a/scripts/openbao_negative_private_handoff.py b/scripts/openbao_negative_private_handoff.py new file mode 100644 index 0000000..e3a875e --- /dev/null +++ b/scripts/openbao_negative_private_handoff.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Owner-held verified KeyCape CLI files -> inherited pipe; never normal output. +No login, secret retrieval, network, executor launch or authenticated admission. +Decoded claims below are shape/lifetime checks, not signature verification. +""" +import argparse +import base64 +import contextlib +import fcntl +import json +import math +import os +from pathlib import Path +import signal +import stat +import threading +import time + +MAX_FILE = 32768 +MAX_TOKEN = 16384 + +class Refused(Exception): + pass + +def need(condition, code): + if not condition: + raise Refused(code) + +def unique(pairs): + out = {} + for key, value in pairs: + need(key not in out, 'duplicate_json_key') + out[key] = value + return out + +def decode(raw): + try: + return json.loads(raw, object_pairs_hook=unique, + parse_constant=lambda _: (_ for _ in ()).throw(Refused('nonfinite_json'))) + except Refused: + raise + except Exception: + raise Refused('invalid_json') from None + +@contextlib.contextmanager +def deadline(seconds): + need(type(seconds) in (int, float) and math.isfinite(seconds) and 0 < seconds <= 5, + 'invalid_delivery_budget') + need(threading.current_thread() is threading.main_thread(), 'main_thread_required') + need(signal.getitimer(signal.ITIMER_REAL) == (0.0, 0.0), 'existing_timer_refused') + previous = signal.getsignal(signal.SIGALRM) + def expired(*_): + raise Refused('delivery_timeout') + try: + signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, seconds) + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + +READY_SCHEMA = 'key39-private-input-ready/v1' +READY_KEYS = {'schema','session_nonce','proposal_id','target','executor_sha256', + 'envelope_sha256','bao_health_identity_sha256','valid_until_epoch', + 'receive_budget_seconds'} + +def finite(value): + try: + return type(value) in (int,float) and math.isfinite(value) + except (OverflowError,ValueError): + return False + +def endpoint(fd, writable): + need(type(fd) is int and fd >= 3, 'private_pipe_fd_required') + try: + identity = os.fstat(fd) + mode = fcntl.fcntl(fd,fcntl.F_GETFL) & os.O_ACCMODE + need(stat.S_ISFIFO(identity.st_mode) and mode == + (os.O_WRONLY if writable else os.O_RDONLY), 'private_pipe_fd_required') + for standard in (0,1,2): + try: + current = os.fstat(standard) + except OSError: + continue + need((identity.st_dev,identity.st_ino) != (current.st_dev,current.st_ino), + 'stdio_pipe_alias_refused') + return identity.st_dev,identity.st_ino + except OSError: + raise Refused('private_pipe_fd_required') from None + +def wait_ready(fd, expected): + need(isinstance(expected,dict) and set(expected) == READY_KEYS - {'schema','receive_budget_seconds'}, + 'expected_child_pins_required') + need(finite(expected['valid_until_epoch']) and expected['valid_until_epoch'] > time.time(), + 'ready_window_expired') + raw = b'' + while len(raw) < 4096: + chunk = os.read(fd,1) + need(chunk, 'child_ready_missing') + if chunk == b'\n': + break + raw += chunk + else: + raise Refused('child_ready_oversized') + marker = decode(raw) + need(isinstance(marker,dict) and set(marker) == READY_KEYS + and marker.get('schema') == READY_SCHEMA, 'child_ready_invalid') + need(all(marker.get(key) == value for key,value in expected.items()), 'child_ready_mismatch') + need(finite(marker['valid_until_epoch']) and finite(marker['receive_budget_seconds']) + and 0 < marker['receive_budget_seconds'] <= 15 + and marker['valid_until_epoch'] > time.time(), + 'ready_window_expired') + return marker + +def claims(token, kind, now): + need(isinstance(token, str) and 0 < len(token) <= MAX_TOKEN, 'invalid_token_shape') + parts = token.split('.') + need(len(parts) == 3 and all(parts), 'invalid_token_shape') + try: + obj = decode(base64.urlsafe_b64decode(parts[1] + '=' * (-len(parts[1]) % 4))) + need(isinstance(obj, dict), 'invalid_token_shape') + exp, iat = obj.get('exp'), obj.get('iat') + need(type(exp) in (int,float) and type(iat) in (int,float) + and math.isfinite(exp) and math.isfinite(iat) + and iat <= now + 30 and iat < exp and exp >= now + 300, 'token_window_invalid') + need(obj.get('iss') == 'https://kc.coulomb.social', 'token_source_invalid') + if kind == 'human': + need(obj.get('principal_type') == 'human' + and obj.get('aud') == 'netkingdom-bootstrap-console' + and isinstance(obj.get('sub'), str) and obj['sub'], 'human_shape_invalid') + assurance = obj.get('assurance', {}) + need(isinstance(assurance,dict) and assurance.get('level') == 'aal2' + and assurance.get('mfa') is True, 'human_mfa_shape_invalid') + else: + need(obj.get('principal_type') == 'service' + and obj.get('aud') == 'codex-railiance-platform' + and obj.get('sub') == 'service:codex:railiance-platform' + and obj.get('tenant') == 'tenant:platform' + and obj.get('roles') == ['coding-agent'] + and obj.get('scope') == 'openbao:login', 'coding_agent_shape_invalid') + except Refused: + raise + except Exception: + raise Refused('invalid_token_shape') from None + return token + +def load(path, kind, now): + p = Path(path) + need(p.is_absolute() and p.parent.resolve() == p.parent, 'private_path_required') + need(not any((ancestor / '.git').is_file() or (ancestor / '.git' / 'HEAD').exists() + for ancestor in p.parents), 'git_path_refused') + directory = os.open(p.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + ds = os.fstat(directory) + need(ds.st_uid == os.getuid() and stat.S_IMODE(ds.st_mode) == 0o700, + 'private_directory_required') + fd = os.open(p.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + try: + original = os.fstat(fd) + need(stat.S_ISREG(original.st_mode) and original.st_uid == os.getuid() + and stat.S_IMODE(original.st_mode) == 0o600 and original.st_nlink == 1 + and 0 < original.st_size <= MAX_FILE, 'private_file_required') + raw = b'' + while len(raw) <= MAX_FILE: + chunk = os.read(fd, MAX_FILE + 1 - len(raw)) + if not chunk: + break + raw += chunk + need(len(raw) <= MAX_FILE, 'private_file_oversized') + after = os.fstat(fd) + need((original.st_size,original.st_mtime_ns,original.st_ctime_ns) == + (after.st_size,after.st_mtime_ns,after.st_ctime_ns), 'private_file_changed') + obj = decode(raw) + need(isinstance(obj,dict) and obj.get('token_type','').lower() == 'bearer', + 'invalid_cli_token_file') + token = claims(obj.get('access_token'), kind, now) + return token, directory, p.name, original + finally: + os.close(fd) + except BaseException: + os.close(directory) + raise + +def consume(item): + # Comparison is not atomic inode-conditioned unlink. Continuous exclusive + # same-UID directory custody is mandatory; concurrent writers are outside scope. + _, directory, name, original = item + current = os.stat(name, dir_fd=directory, follow_symlinks=False) + need((current.st_dev,current.st_ino,current.st_uid,current.st_nlink,current.st_size, + current.st_mtime_ns,current.st_ctime_ns) == + (original.st_dev,original.st_ino,original.st_uid,original.st_nlink,original.st_size, + original.st_mtime_ns,original.st_ctime_ns), 'private_file_changed') + os.unlink(name, dir_fd=directory) + +def deliver(human_path, agent_path, fd, *, ready_fd=None, expected=None, + consume_owner_files=False, budget=5): + receipt = {'adapter_performs_native_backend_actions': False, 'native_accepted': False, + 'private_input_delivery_confirmed': False, 'caller_path_removal_completed': False, + 'exclusive_same_uid_custody_required': True, 'atomic_inode_delete_guaranteed': False, + 'child_ready_matched': False, + 'credential_files_remain_possible': True, 'credential_copy_cleanup_verified': False, + 'private_input_delivery_unknown': False} + items = [] + writing = False + try: + need(consume_owner_files is True, 'explicit_owner_file_consumption_required') + data_identity = endpoint(fd,True) + control_identity = endpoint(ready_fd,False) + need(data_identity != control_identity, 'data_control_pipe_alias_refused') + need(isinstance(expected,dict) and finite(expected.get('valid_until_epoch')), + 'expected_child_pins_required') + budget = min(budget, expected['valid_until_epoch'] - time.time()) + with deadline(budget): + marker = wait_ready(ready_fd,expected) + receipt['child_ready_matched'] = True + remaining_alarm = signal.getitimer(signal.ITIMER_REAL)[0] + remaining = min(remaining_alarm, marker['receive_budget_seconds'], + expected['valid_until_epoch'] - time.time()) + need(remaining > 0, 'ready_window_expired') + # Clip the existing timer; never restart or extend its original budget. + signal.setitimer(signal.ITIMER_REAL, remaining) + delivery_deadline = time.monotonic() + remaining + now = time.time() + items.append(load(human_path, 'human', now)) + items.append(load(agent_path, 'coding-agent', now)) + need((items[0][3].st_dev,items[0][3].st_ino) != + (items[1][3].st_dev,items[1][3].st_ino), 'distinct_files_required') + payload = json.dumps({'human_token':items[0][0], 'coding_agent_token':items[1][0]}, + separators=(',', ':')).encode() + b'\n' + # Unlink inspected caller-owned inodes; Python memory is not a zeroization guarantee. + need(time.time() < expected['valid_until_epoch'] and + time.monotonic() < delivery_deadline, 'ready_window_expired') + for item in items: + consume(item) + receipt.update(caller_path_removal_completed=True) + writing = True + offset = 0 + while offset < len(payload): + need(time.time() < expected['valid_until_epoch'] and + time.monotonic() < delivery_deadline, 'ready_window_expired') + written = os.write(fd, payload[offset:]) + need(written > 0, 'private_delivery_failed') + offset += written + need(time.time() < expected['valid_until_epoch'] and + time.monotonic() < delivery_deadline, 'ready_window_expired') + receipt['private_input_delivery_confirmed'] = True + except Refused as error: + receipt['failure_code'] = str(error) + except Exception: + receipt['failure_code'] = 'private_delivery_failed' + finally: + if writing and not receipt['private_input_delivery_confirmed']: + receipt['private_input_delivery_unknown'] = True + for item in items: + os.close(item[1]) + # Launcher owns these endpoints: EOF even after partial delivery. + for owned in {fd,ready_fd}: + if type(owned) is int and owned >= 3: + try: + os.close(owned) + except OSError: + pass + return receipt + +# Deliberately no standalone ready-file/ready-boolean CLI. Only the reviewed +# source launcher supplies its own captured child-control endpoint and exact pins. diff --git a/scripts/openbao_negative_private_launcher.py b/scripts/openbao_negative_private_launcher.py new file mode 100644 index 0000000..4eae60a --- /dev/null +++ b/scripts/openbao_negative_private_launcher.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Founder-held, source-pinned direct SSH launcher; no credentials on normal IO. +Ready marker is a procedural readiness check, NEVER authenticated live authority. +Use only after exact source/host/transport/client/custody/file allowance admission. +""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import subprocess +import stat +import time + +ADAPTER = Path(__file__).with_name('openbao_negative_private_handoff.py') +spec = importlib.util.spec_from_file_location('private_handoff', ADAPTER) +handoff = importlib.util.module_from_spec(spec) +spec.loader.exec_module(handoff) +PROPOSAL = 'KEY-WP-0039-T02/attended-lifecycle-v9-ready-input' +TARGET = 'sso/keycape' +PREPARATION_TOTAL_SECONDS = 5 +MAX_PUBLIC_BYTES = 65536 +MAX_SOURCE_BYTES = 262144 +CONTRACT_KEYS = {'schema','proposal_id','target','remote_host','remote_wrapper_path', + 'remote_wrapper_sha256','remote_child_path','remote_child_sha256','remote_envelope_path', + 'local_envelope_path','remote_receipt_path','founder_decision_id', + 'private_tmpfs_file_allowance_receipt','local_launcher_sha256','local_adapter_sha256'} + +def read_stable(path, limit): + # Always called under the shared preparation timer in the actual CLI graph. + # NONBLOCK + regular-file check refuses FIFO/device. Resolution refuses + # observed symlink ancestors; metadata compares detect observed source drift. + p = Path(path) + handoff.need(p.is_absolute() and p.resolve() == p, 'source_regular_path_required') + fd = os.open(p,os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + before = os.fstat(fd) + handoff.need(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 + and not stat.S_IMODE(before.st_mode) & 0o022 + and 0 < before.st_size <= limit, 'source_file_invalid') + chunks,size = [],0 + while size <= limit: + chunk = os.read(fd,min(4096,limit+1-size)) + if not chunk: + break + size += len(chunk) + handoff.need(size <= limit,'source_file_oversized') + chunks.append(chunk) + after = os.fstat(fd) + fields = lambda v:(v.st_dev,v.st_ino,v.st_uid,v.st_mode,v.st_nlink, + v.st_size,v.st_mtime_ns,v.st_ctime_ns) + handoff.need(fields(before) == fields(after) and size == before.st_size, + 'source_file_changed') + return b''.join(chunks) + finally: + os.close(fd) + +def digest(path): + return hashlib.sha256(read_stable(path,MAX_SOURCE_BYTES)).hexdigest() + +def remaining_preparation(preparation_deadline): + handoff.need(handoff.finite(preparation_deadline),'preparation_window_invalid') + remaining = preparation_deadline-time.monotonic() + handoff.need(0 < remaining <= PREPARATION_TOTAL_SECONDS,'preparation_window_expired') + return remaining + +def pin(value): + return isinstance(value,str) and re.fullmatch('[0-9a-f]{64}',value) is not None + +def remote_path(value): + handoff.need(isinstance(value,str) and len(value) <= 256 + and re.fullmatch('/[A-Za-z0-9_./-]+',value) is not None + and '..' not in Path(value).parts and not value.endswith('/'), 'remote_path_invalid') + return value + +def read_public(path): + raw = read_stable(path,MAX_PUBLIC_BYTES) + obj = handoff.decode(raw) + handoff.need(isinstance(obj,dict),'source_contract_invalid') + return raw,obj + +def validate_contract(contract): + handoff.need(isinstance(contract,dict) and set(contract) == CONTRACT_KEYS + and contract['schema'] == 'key39-private-launch-contract/v1' + and contract['proposal_id'] == PROPOSAL and contract['target'] == TARGET + and contract['remote_host'] == 'railiance01','source_contract_invalid') + for field in ('remote_wrapper_path','remote_child_path','remote_envelope_path','remote_receipt_path'): + remote_path(contract[field]) + handoff.need(len({contract[key] for key in ('remote_wrapper_path','remote_child_path', + 'remote_envelope_path','remote_receipt_path')}) == 4,'remote_paths_must_differ') + for field in ('remote_wrapper_sha256','remote_child_sha256','local_launcher_sha256','local_adapter_sha256'): + handoff.need(pin(contract[field]),'source_pin_required') + handoff.need(digest(__file__) == contract['local_launcher_sha256'] + and digest(ADAPTER) == contract['local_adapter_sha256'],'local_source_pin_changed') + handoff.need(all(isinstance(contract[k],str) and 0 < len(contract[k]) <= 256 + for k in ('founder_decision_id','private_tmpfs_file_allowance_receipt')), + 'founder_receipt_required') + raw,envelope = read_public(contract['local_envelope_path']) + handoff.need(envelope.get('proposal_id') == PROPOSAL and envelope.get('target') == TARGET + and envelope.get('disposition') == 'approved' + and envelope.get('founder_decision_id') == contract['founder_decision_id'], + 'source_envelope_mismatch') + expiry = envelope.get('valid_until_epoch') + window = envelope.get('maximum_session_seconds') + handoff.need(handoff.finite(expiry) and type(window) is int and 420 <= window <= 900 + and 0 < expiry-time.time() <= window,'source_window_invalid') + health = envelope.get('bao_health_identity_sha256') + handoff.need(pin(health),'source_health_pin_required') + hashes = envelope.get('source_hashes',{}) + handoff.need(isinstance(hashes,dict) and + hashes.get(contract['remote_child_path']) == contract['remote_child_sha256'] and + hashes.get(contract['remote_wrapper_path']) == contract['remote_wrapper_sha256'], + 'remote_source_pin_missing') + owners = envelope.get('owner_acknowledgments',{}) + handoff.need(isinstance(owners,dict) and all(isinstance(owners.get(k),str) and owners[k] + for k in ('key-cape','net-kingdom','railiance-platform')),'owner_source_receipts_required') + return hashlib.sha256(raw).hexdigest(),envelope + +def launch_and_deliver(contract,human_path,agent_path,*,consume_owner_files=False, + preparation_deadline=None): + receipt = {'native_accepted':False,'authenticated_authority_established_by_helper':False, + 'child_ready_matched':False,'private_input_delivery_confirmed':False, + 'consumer_process_completed':False,'consumer_outcome_unknown':False} + process = None + owned_fds = set() + if preparation_deadline is None: + preparation_deadline = time.monotonic() + PREPARATION_TOTAL_SECONDS + try: + with handoff.deadline(remaining_preparation(preparation_deadline)): + envelope_hash,envelope = validate_contract(contract) + nonce = secrets.token_hex(32) + expected = {'session_nonce':nonce,'proposal_id':PROPOSAL,'target':TARGET, + 'executor_sha256':contract['remote_child_sha256'],'envelope_sha256':envelope_hash, + 'bao_health_identity_sha256':envelope['bao_health_identity_sha256'], + 'valid_until_epoch':envelope['valid_until_epoch']} + command = ['python3',contract['remote_wrapper_path'], + '--child',contract['remote_child_path'],'--child-sha256',contract['remote_child_sha256'], + '--envelope',contract['remote_envelope_path'],'--envelope-sha256',envelope_hash, + '--receipt',contract['remote_receipt_path'],'--session-nonce',nonce] + argv = ['ssh','-T','-oBatchMode=yes','-oStrictHostKeyChecking=yes', + '-oForwardAgent=no','-oClearAllForwardings=yes','-oConnectTimeout=5', + '-oServerAliveInterval=5','-oServerAliveCountMax=1', + 'railiance01',shlex.join(command)] + # Do not pass arbitrary confidential producer environment to SSH. + allowed = ('PATH','HOME','USER','LOGNAME','LANG','LC_ALL','SSH_AUTH_SOCK') + environment = {key:os.environ[key] for key in allowed if key in os.environ} + process = subprocess.Popen(argv,stdin=subprocess.PIPE,stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL,env=environment,close_fds=True) + remaining_preparation(preparation_deadline) + # Distinct process-owned pipes; no arbitrary caller ready file or boolean. + write_fd = os.dup(process.stdin.fileno()); owned_fds.add(write_fd) + ready_fd = os.dup(process.stdout.fileno()); owned_fds.add(ready_fd) + process.stdin.close() + # Close parent's duplicate read endpoint; only owned ready_fd is used. + process.stdout.close() + delivery = handoff.deliver(human_path,agent_path,write_fd,ready_fd=ready_fd, + expected=expected,consume_owner_files=consume_owner_files, + budget=remaining_preparation(preparation_deadline)) + owned_fds.clear() # deliver owns and closes both endpoints + receipt.update(delivery) + # Private bytes may already have reached the remote child even if local + # write completion/EOF confirmation failed. Local SSH teardown proves no + # remote no-effects or cleanup outcome; never clear this unknown here. + if delivery['private_input_delivery_unknown']: + receipt['consumer_outcome_unknown'] = True + receipt['consumer_process_completed'] = False + if not delivery['private_input_delivery_confirmed']: + return receipt + # Value-free exit status only; child receipt and native acceptance remain owner duties. + wait_budget = max(0,min(envelope['valid_until_epoch']-time.time(), + envelope['maximum_session_seconds']-180)) + 330 + try: + rc = process.wait(timeout=wait_budget) + except subprocess.TimeoutExpired: + receipt.update(failure_code='consumer_completion_unconfirmed',consumer_outcome_unknown=True) + return receipt + receipt['consumer_process_completed'] = rc == 0 + receipt['consumer_outcome_unknown'] = rc != 0 + if rc != 0: + receipt['failure_code'] = 'consumer_completion_unconfirmed' + except handoff.Refused as error: + receipt['failure_code'] = str(error) + except Exception: + receipt['failure_code'] = 'private_launcher_incomplete' + if receipt['private_input_delivery_confirmed']: + receipt['consumer_outcome_unknown'] = True + finally: + for fd in owned_fds: + try: + os.close(fd) + except OSError: + pass + if process is not None: + # Local teardown never claims remote process/custody reconciliation. + try: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) + for stream in (process.stdin,process.stdout): + if stream is not None: + stream.close() + except Exception: + receipt['transport_cleanup_unconfirmed'] = True + receipt['consumer_outcome_unknown'] = True + return receipt + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--contract',required=True) + parser.add_argument('--human-file',required=True) + parser.add_argument('--agent-file',required=True) + parser.add_argument('--consume-owner-files',action='store_true') + args = parser.parse_args() + preparation_deadline = time.monotonic() + PREPARATION_TOTAL_SECONDS + try: + with handoff.deadline(remaining_preparation(preparation_deadline)): + _,contract = read_public(args.contract) + receipt = launch_and_deliver(contract,args.human_file,args.agent_file, + consume_owner_files=args.consume_owner_files,preparation_deadline=preparation_deadline) + except handoff.Refused as error: + receipt = {'native_accepted':False,'private_input_delivery_confirmed':False, + 'failure_code':str(error)} + except Exception: + receipt = {'native_accepted':False,'private_input_delivery_confirmed':False, + 'failure_code':'private_launcher_contract_invalid'} + print(json.dumps(receipt,separators=(',',':'))) + return 0 if receipt.get('consumer_process_completed') is True else 1 + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/test_openbao_negative_private_handoff.py b/scripts/test_openbao_negative_private_handoff.py new file mode 100644 index 0000000..c170618 --- /dev/null +++ b/scripts/test_openbao_negative_private_handoff.py @@ -0,0 +1,228 @@ +import base64 +import importlib.util +import json +import os +from pathlib import Path +import signal +import tempfile +import time +import unittest +from unittest.mock import patch +REAL_WRITE=os.write +spec = importlib.util.spec_from_file_location('handoff',Path(__file__).with_name('openbao_negative_private_handoff.py')) +s = importlib.util.module_from_spec(spec);spec.loader.exec_module(s) + +def jwt(kind='human', **changes): + obj={'iss':'https://kc.coulomb.social','iat':time.time()-1,'exp':time.time()+700, + 'principal_type':'human','aud':'netkingdom-bootstrap-console','sub':'fixture-person', + 'assurance':{'level':'aal2','mfa':True}} + if kind == 'agent': + obj.update(principal_type='service',aud='codex-railiance-platform', + sub='service:codex:railiance-platform',tenant='tenant:platform', + roles=['coding-agent'],scope='openbao:login') + obj.update(changes) + body=base64.urlsafe_b64encode(json.dumps(obj).encode()).decode().rstrip('=') + return 'fixture.'+body+'.fixture' + +class Tests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.dir=Path(self.tmp.name) + self.h=self.dir/'human.json';self.a=self.dir/'agent.json' + self.write(self.h,jwt());self.write(self.a,jwt('agent')) + def tearDown(self): + self.tmp.cleanup() + def write(self,p,token): + p.write_text(json.dumps({'access_token':token,'token_type':'Bearer','expires_in':700}));p.chmod(0o600) + def run_delivery(self,**kw): + r,w=os.pipe();cr,cw=os.pipe() + expected=self.expected() + REAL_WRITE(cw,json.dumps(dict(expected,schema=s.READY_SCHEMA,receive_budget_seconds=10)).encode()+b'\n') + os.close(cw) + receipt=s.deliver(kw.pop("human_path",str(self.h)),str(self.a),w,ready_fd=cr,expected=expected,consume_owner_files=True,**kw) + raw=os.read(r,65536);os.close(r) + return receipt,raw + def expected(self): + return {'session_nonce':'1'*64,'proposal_id':'KEY-WP-0039-T02/attended-lifecycle-v9-ready-input', + 'target':'sso/keycape','executor_sha256':'2'*64,'envelope_sha256':'3'*64, + 'bao_health_identity_sha256':'4'*64,'valid_until_epoch':time.time()+600} + def test_success_only_private_pipe_and_consumes_owned_files(self): + receipt,raw=self.run_delivery() + obj=json.loads(raw) + self.assertEqual(set(obj),{'human_token','coding_agent_token'}) + self.assertTrue(receipt['private_input_delivery_confirmed']) + self.assertFalse(receipt['native_accepted']) + self.assertNotIn('fixture',json.dumps(receipt)) + self.assertFalse(self.h.exists());self.assertFalse(self.a.exists()) + def test_stdout_stderr_and_regular_fd_refused_before_file_consumption(self): + for fd in (1,2): + receipt=s.deliver(str(self.h),str(self.a),fd,consume_owner_files=True) + self.assertEqual(receipt['failure_code'],'private_pipe_fd_required') + with tempfile.TemporaryFile() as f: + dup=os.dup(f.fileno()) + receipt=s.deliver(str(self.h),str(self.a),dup,consume_owner_files=True) + self.assertEqual(receipt['failure_code'],'private_pipe_fd_required') + self.assertEqual(f.tell(),0) + self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + def test_read_only_pipe_refused_before_consuming_files(self): + r,w=os.pipe() + receipt=s.deliver(str(self.h),str(self.a),r,consume_owner_files=True) + os.close(w) + self.assertEqual(receipt['failure_code'],'private_pipe_fd_required') + self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + def test_git_worktree_refused_before_delivery(self): + marker=self.dir/'.git';marker.write_text('gitdir: fixture') + receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'git_path_refused');self.assertFalse(raw) + def test_symlinked_parent_refused(self): + alias=self.dir/'alias';alias.symlink_to(self.dir,target_is_directory=True) + receipt,raw=self.run_delivery(human_path=str(alias/'human.json')) + self.assertEqual(receipt['failure_code'],'private_path_required') + self.assertFalse(raw);self.assertTrue(self.h.exists()) + + def test_explicit_consumption_required(self): + r,w=os.pipe();receipt=s.deliver(str(self.h),str(self.a),w) + self.assertEqual(receipt['failure_code'],'explicit_owner_file_consumption_required') + self.assertEqual(os.read(r,10),b'');os.close(r);self.assertTrue(self.h.exists()) + def test_symlink_refused_no_delivery(self): + target=self.dir/'target';self.h.rename(target);self.h.symlink_to(target) + receipt,raw=self.run_delivery();self.assertFalse(raw) + self.assertFalse(receipt['private_input_delivery_confirmed']);self.assertTrue(target.exists()) + def test_hardlinked_file_refused(self): + os.link(self.h,self.dir/'extra');receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_file_required');self.assertFalse(raw) + def test_public_mode_refused(self): + self.a.chmod(0o644);receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_file_required');self.assertFalse(raw) + def test_public_directory_refused(self): + self.dir.chmod(0o755);receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_directory_required');self.assertFalse(raw) + def test_oversize_file_refused_before_delivery(self): + self.a.write_bytes(b'x'*(s.MAX_FILE+1));receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_file_required');self.assertFalse(raw) + def test_wrong_human_audience_or_missing_mfa_refused(self): + for changes in ({'aud':'openbao-admin'},{'assurance':{'level':'aal1','mfa':False}}): + self.write(self.h,jwt(**changes));receipt,raw=self.run_delivery() + self.assertFalse(raw);self.assertFalse(receipt['private_input_delivery_confirmed']) + def test_approle_string_or_wrong_coding_claim_refused(self): + for token in ('hvs.not-a-jwt',jwt('agent',tenant='tenant:coulomb'),jwt('agent',roles=['secrets-engine'])): + self.write(self.a,token);receipt,raw=self.run_delivery() + self.assertFalse(raw);self.assertFalse(receipt['private_input_delivery_confirmed']) + def test_expired_bool_or_infinite_timestamp_refused(self): + for expiry in (time.time()-1,True,float('inf')): + self.write(self.a,jwt('agent',exp=expiry));receipt,raw=self.run_delivery() + self.assertFalse(raw);self.assertFalse(receipt['private_input_delivery_confirmed']) + def test_duplicate_json_refused(self): + self.a.write_text('{"token_type":"Bearer","access_token":"a","access_token":"b"}') + receipt,raw=self.run_delivery();self.assertEqual(receipt['failure_code'],'duplicate_json_key');self.assertFalse(raw) + def test_file_replacement_before_consumption_not_unlinked(self): + original=s.consume + def replace(item): + self.h.unlink();self.write(self.h,jwt()) + return original(item) + with patch.object(s,'consume',side_effect=replace):receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_file_changed');self.assertFalse(raw);self.assertTrue(self.h.exists()) + def test_blocked_partial_write_is_bounded_unknown_and_timer_restored(self): + previous=signal.getsignal(signal.SIGALRM);started=time.monotonic() + with patch.object(s.os,'write',side_effect=lambda *args:time.sleep(0.1)): + receipt,raw=self.run_delivery(budget=0.01) + self.assertLess(time.monotonic()-started,0.06) + self.assertEqual(receipt['failure_code'],'delivery_timeout') + self.assertTrue(receipt['private_input_delivery_unknown']);self.assertFalse(raw) + self.assertFalse(self.h.exists());self.assertFalse(self.a.exists()) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0));self.assertIs(signal.getsignal(signal.SIGALRM),previous) + def test_broken_pipe_fixed_error_no_raw_exception(self): + with patch.object(s.os,'write',side_effect=RuntimeError('protected raw value')): + receipt,raw=self.run_delivery() + self.assertEqual(receipt['failure_code'],'private_delivery_failed') + self.assertTrue(receipt['private_input_delivery_unknown']);self.assertNotIn('protected',json.dumps(receipt));self.assertFalse(raw) + +if __name__ == '__main__':unittest.main() + +class ReadyTests(unittest.TestCase): + setUp=Tests.setUp + tearDown=Tests.tearDown + write=Tests.write + expected=Tests.expected + run_delivery=Tests.run_delivery + # Concrete ready-channel counterexamples; base safety cases run only once. + def with_marker(self, marker, budget=0.05): + r,w=os.pipe();cr,cw=os.pipe();expected=self.expected() + raw=marker(expected) if callable(marker) else marker + REAL_WRITE(cw,raw);os.close(cw) + with patch.object(s,'load',wraps=s.load) as load: + receipt=s.deliver(str(self.h),str(self.a),w,ready_fd=cr,expected=expected, + consume_owner_files=True,budget=budget) + calls=load.call_count + data=os.read(r,65536);os.close(r) + return receipt,data,calls + def test_no_child_ready_or_mismatch_means_zero_file_load_and_private_bytes(self): + for marker in (b'', b'not-json\n', lambda e:json.dumps(dict(e,schema=s.READY_SCHEMA, + receive_budget_seconds=10,session_nonce='0'*64)).encode()+b'\n', + lambda e:json.dumps(dict(e,schema=s.READY_SCHEMA,receive_budget_seconds=True)).encode()+b'\n'): + receipt,data,calls=self.with_marker(marker) + self.assertFalse(receipt['private_input_delivery_confirmed']);self.assertFalse(data) + self.assertEqual(calls,0);self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + def test_no_ready_eof_hard_timeout_zero_file_load_or_consumption(self): + r,w=os.pipe();cr,cw=os.pipe();started=time.monotonic() + try: + with patch.object(s,'load') as load: + receipt=s.deliver(str(self.h),str(self.a),w,ready_fd=cr,expected=self.expected(), + consume_owner_files=True,budget=0.01) + load.assert_not_called() + self.assertLess(time.monotonic()-started,0.06) + self.assertEqual(receipt['failure_code'],'delivery_timeout');self.assertFalse(os.read(r,10)) + self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + finally:os.close(cw);os.close(r) + def test_stdout_and_stderr_pipe_duplicates_refused(self): + for standard in (1,2): + r,w=os.pipe();backup=os.dup(standard) + try: + os.dup2(w,standard);duplicate=os.dup(standard) + receipt=s.deliver(str(self.h),str(self.a),duplicate,consume_owner_files=True) + finally:os.dup2(backup,standard);os.close(backup);os.close(w) + self.assertEqual(receipt['failure_code'],'stdio_pipe_alias_refused') + self.assertEqual(os.read(r,10),b'');os.close(r) + self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + def test_data_and_control_same_pipe_refused(self): + r,w=os.pipe();readback=os.dup(r) + receipt=s.deliver(str(self.h),str(self.a),w,ready_fd=r,expected=self.expected(),consume_owner_files=True) + self.assertEqual(receipt['failure_code'],'data_control_pipe_alias_refused') + self.assertEqual(os.read(readback,10),b'');os.close(readback) + def test_oversize_ready_refused_before_load(self): + receipt,data,calls=self.with_marker(b'x'*4096+b'\n') + self.assertEqual(receipt['failure_code'],'child_ready_oversized');self.assertFalse(data);self.assertEqual(calls,0) + def test_received_short_budget_clips_timer_before_token_load(self): + marker=lambda e:json.dumps(dict(e,schema=s.READY_SCHEMA,receive_budget_seconds=0.01)).encode()+b'\n' + original=s.load + def delayed(*args):time.sleep(0.1);return original(*args) + started=time.monotonic() + with patch.object(s,'load',side_effect=delayed):receipt,data,calls=self.with_marker(marker) + self.assertLess(time.monotonic()-started,0.06);self.assertFalse(data) + self.assertEqual(receipt['failure_code'],'delivery_timeout');self.assertTrue(self.h.exists()) + def test_real_partial_write_stays_unknown_and_conservative_cleanup(self): + import fcntl + self.write(self.h,jwt(padding='h'*7000));self.write(self.a,jwt('agent',padding='a'*7000)) + r,w=os.pipe();fcntl.fcntl(w,fcntl.F_SETPIPE_SZ,4096) + cr,cw=os.pipe();e=self.expected();REAL_WRITE(cw,json.dumps(dict(e,schema=s.READY_SCHEMA, + receive_budget_seconds=10)).encode()+b'\n');os.close(cw) + receipt=s.deliver(str(self.h),str(self.a),w,ready_fd=cr,expected=e,consume_owner_files=True,budget=0.015) + data=os.read(r,65536);os.close(r) + self.assertEqual(len(data),4096);self.assertTrue(receipt['private_input_delivery_unknown']) + self.assertFalse(receipt['private_input_delivery_confirmed']) + self.assertTrue(receipt['credential_files_remain_possible']) + self.assertFalse(receipt['credential_copy_cleanup_verified']) + def test_stat_unlink_race_retained_as_exclusive_custody_violation_not_atomic_claim(self): + original=s.os.stat;replaced=[] + def race(path,*args,**kwargs): + current=original(path,*args,**kwargs) + if path=='human.json' and kwargs.get('dir_fd') is not None and not replaced: + self.h.unlink();self.write(self.h,jwt(sub='replacement-fixture')) + replaced.append(True) + return current + with patch.object(s.os,'stat',side_effect=race):receipt,data=self.run_delivery() + self.assertTrue(replaced);self.assertFalse(self.h.exists()) + self.assertTrue(receipt['caller_path_removal_completed']) + self.assertFalse(receipt['atomic_inode_delete_guaranteed']) + self.assertTrue(receipt['exclusive_same_uid_custody_required']) + self.assertTrue(receipt['credential_files_remain_possible']) + self.assertFalse(receipt['credential_copy_cleanup_verified']) diff --git a/scripts/test_openbao_negative_private_launcher.py b/scripts/test_openbao_negative_private_launcher.py new file mode 100644 index 0000000..e1d98a3 --- /dev/null +++ b/scripts/test_openbao_negative_private_launcher.py @@ -0,0 +1,145 @@ +import importlib.util +import json +import os +from pathlib import Path +import shlex +import tempfile +import time +import unittest +from unittest.mock import patch +spec=importlib.util.spec_from_file_location('launcher',Path(__file__).with_name('openbao_negative_private_launcher.py')) +l=importlib.util.module_from_spec(spec);spec.loader.exec_module(l) +spec=importlib.util.spec_from_file_location('fixtures',Path(__file__).with_name('test_openbao_negative_private_handoff.py')) +f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f) + +class FakeProcess: + def __init__(self,argv,contract,envelope_hash,envelope,marker='valid'): + self.argv=argv;self.rc=None;self.terminated=False;self.waits=[] + self.read_data,w=os.pipe();self.stdin=os.fdopen(w,'wb',buffering=0) + r,cw=os.pipe();self.stdout=os.fdopen(r,'rb',buffering=0) + args=shlex.split(argv[-1]);nonce=args[args.index('--session-nonce')+1] + ready={'schema':'key39-private-input-ready/v1','session_nonce':nonce, + 'proposal_id':l.PROPOSAL,'target':l.TARGET,'executor_sha256':contract['remote_child_sha256'], + 'envelope_sha256':envelope_hash,'bao_health_identity_sha256':envelope['bao_health_identity_sha256'], + 'valid_until_epoch':envelope['valid_until_epoch'],'receive_budget_seconds':15} + if marker=='wrong':ready['session_nonce']='0'*64 + if marker!='missing':os.write(cw,json.dumps(ready).encode()+b'\n') + os.close(cw) + def wait(self,timeout): + self.waits.append(timeout);self.rc=-15 if self.terminated else 0;return self.rc + def poll(self):return self.rc + def terminate(self):self.terminated=True + def kill(self):self.terminated=True + +class Tests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.dir=Path(self.tmp.name) + self.h=self.dir/'human.json';self.a=self.dir/'agent.json' + for p,token in ((self.h,f.jwt()),(self.a,f.jwt('agent'))): + p.write_text(json.dumps({'access_token':token,'token_type':'Bearer'}));p.chmod(0o600) + self.envelope={'proposal_id':l.PROPOSAL,'target':l.TARGET,'disposition':'approved', + 'founder_decision_id':'fixture-founder','valid_until_epoch':time.time()+600, + 'maximum_session_seconds':900,'bao_health_identity_sha256':'4'*64, + 'source_hashes':{'/run/key39/child.py':'2'*64,'/run/key39/wrapper.py':'3'*64}, + 'owner_acknowledgments':{k:'fixture-source' for k in ('key-cape','net-kingdom','railiance-platform')}} + self.e=self.dir/'envelope.json';self.e.write_text(json.dumps(self.envelope)) + self.contract={'schema':'key39-private-launch-contract/v1','proposal_id':l.PROPOSAL, + 'target':l.TARGET,'remote_host':'railiance01','remote_wrapper_path':'/run/key39/wrapper.py', + 'remote_wrapper_sha256':'3'*64,'remote_child_path':'/run/key39/child.py', + 'remote_child_sha256':'2'*64,'remote_envelope_path':'/run/key39/approved.json', + 'local_envelope_path':str(self.e),'remote_receipt_path':'/run/key39/receipt.json', + 'founder_decision_id':'fixture-founder','private_tmpfs_file_allowance_receipt':'fixture-file-allowance', + 'local_launcher_sha256':l.digest(l.__file__),'local_adapter_sha256':l.digest(l.ADAPTER)} + self.processes=[] + def tearDown(self): + for p in self.processes: + os.close(p.read_data) + self.tmp.cleanup() + def run_launcher(self,marker='valid'): + def popen(argv,**kwargs): + _,envelope=l.read_public(self.e) + self.assertEqual(kwargs['stdin'],l.subprocess.PIPE) + self.assertEqual(kwargs['stdout'],l.subprocess.PIPE) + self.assertEqual(kwargs['stderr'],l.subprocess.DEVNULL) + self.assertNotIn('KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET',kwargs['env']) + self.assertIn('-oStrictHostKeyChecking=yes',argv);self.assertIn('-T',argv) + p=FakeProcess(argv,self.contract,l.digest(self.e),envelope,marker);self.processes.append(p);return p + with patch.object(l.subprocess,'Popen',side_effect=popen) as spawn, \ + patch.dict(os.environ,{'KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET':'PRIVATE-FIXTURE'}): + receipt=l.launch_and_deliver(self.contract,str(self.h),str(self.a),consume_owner_files=True) + return receipt,spawn.call_count + def test_complete_pinned_child_marker_then_private_pipe_delivery_no_actual_ssh(self): + receipt,count=self.run_launcher();self.assertEqual(count,1) + self.assertTrue(receipt['child_ready_matched']);self.assertTrue(receipt['consumer_process_completed']) + self.assertFalse(receipt['native_accepted']);self.assertFalse(receipt['authenticated_authority_established_by_helper']) + raw=os.read(self.processes[0].read_data,65536);self.assertEqual(set(json.loads(raw)),{'human_token','coding_agent_token'}) + self.assertNotIn('fixture-person',json.dumps(receipt));self.assertFalse(self.h.exists());self.assertFalse(self.a.exists()) + def test_missing_or_wrong_marker_retains_files_and_writes_zero_tokens(self): + for marker in ('missing','wrong'): + receipt,count=self.run_launcher(marker) + self.assertEqual(count,1);self.assertFalse(receipt['child_ready_matched']) + self.assertEqual(os.read(self.processes[-1].read_data,65536),b'') + self.assertTrue(self.h.exists());self.assertTrue(self.a.exists()) + def test_changed_local_source_pin_refuses_before_transport_and_file_read(self): + self.contract['local_adapter_sha256']='0'*64 + receipt,count=self.run_launcher();self.assertEqual(count,0) + self.assertEqual(receipt['failure_code'],'local_source_pin_changed');self.assertTrue(self.h.exists()) + def test_wrong_host_or_remote_command_path_refuses_before_transport(self): + for field,value in (('remote_host','other-node'),('remote_wrapper_path','/tmp/x;cat'), + ('remote_child_path','/run/key39/../child.py')): + old=self.contract[field];self.contract[field]=value + receipt,count=self.run_launcher();self.contract[field]=old + self.assertEqual(count,0);self.assertFalse(receipt['private_input_delivery_confirmed']);self.assertTrue(self.h.exists()) + def test_expired_or_wrong_founder_source_contract_no_spawn_no_file_access(self): + for key,value in (('valid_until_epoch',time.time()-1),('founder_decision_id','other')): + current=json.loads(self.e.read_text());current[key]=value;self.e.write_text(json.dumps(current)) + receipt,count=self.run_launcher();self.assertEqual(count,0);self.assertTrue(self.h.exists()) + self.e.write_text(json.dumps(self.envelope)) + + def test_full_pipe_payload_then_confirmation_timeout_keeps_remote_unknown(self): + original_deliver=l.handoff.deliver + original_write=os.write + def interrupted_delivery(*args,**kwargs): + kwargs['budget']=0.01 + def write_then_stall(fd,payload): + written=original_write(fd,payload) + time.sleep(0.1) + return written + with patch.object(l.handoff.os,'write',side_effect=write_then_stall): + return original_deliver(*args,**kwargs) + with patch.object(l.handoff,'deliver',side_effect=interrupted_delivery): + receipt,count=self.run_launcher() + self.assertEqual(count,1) + data=os.read(self.processes[0].read_data,65536) + self.assertEqual(set(json.loads(data)),{'human_token','coding_agent_token'}) + self.assertTrue(receipt['private_input_delivery_unknown']) + self.assertFalse(receipt['private_input_delivery_confirmed']) + self.assertTrue(receipt['consumer_outcome_unknown']) + self.assertFalse(receipt['consumer_process_completed']) + self.assertTrue(self.processes[0].terminated) + self.assertFalse(self.h.exists());self.assertFalse(self.a.exists()) + self.assertFalse(receipt['native_accepted']) + def test_real_partial_pipe_write_unknown_is_sticky_after_local_terminate(self): + import fcntl + for p,token in ((self.h,f.jwt(padding='h'*7000)),(self.a,f.jwt('agent',padding='a'*7000))): + p.write_text(json.dumps({'access_token':token,'token_type':'Bearer'}));p.chmod(0o600) + original_process=FakeProcess + original_deliver=l.handoff.deliver + def small_pipe(*args,**kwargs): + proc=original_process(*args,**kwargs) + fcntl.fcntl(proc.stdin.fileno(),fcntl.F_SETPIPE_SZ,4096) + return proc + def bounded(*args,**kwargs): + kwargs['budget']=0.015 + return original_deliver(*args,**kwargs) + with patch.dict(globals(),{'FakeProcess':small_pipe}),patch.object(l.handoff,'deliver',side_effect=bounded): + receipt,count=self.run_launcher() + data=os.read(self.processes[0].read_data,65536) + self.assertEqual(len(data),4096) + self.assertTrue(receipt['private_input_delivery_unknown']) + self.assertTrue(receipt['consumer_outcome_unknown']) + self.assertFalse(receipt['consumer_process_completed']) + self.assertTrue(self.processes[0].terminated) + self.assertFalse(receipt['native_accepted']) + +if __name__=='__main__':unittest.main() diff --git a/scripts/test_openbao_negative_private_preparation.py b/scripts/test_openbao_negative_private_preparation.py new file mode 100644 index 0000000..8344b22 --- /dev/null +++ b/scripts/test_openbao_negative_private_preparation.py @@ -0,0 +1,91 @@ +import importlib.util +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch +spec=importlib.util.spec_from_file_location('prep_launcher',Path(__file__).with_name('openbao_negative_private_launcher.py')) +l=importlib.util.module_from_spec(spec);spec.loader.exec_module(l) + +class Tests(unittest.TestCase): + def actual_cli(self,path): + started=time.monotonic() + result=subprocess.run([sys.executable,l.__file__,'--contract',str(path), + '--human-file','/NEVER-READ-HUMAN','--agent-file','/NEVER-READ-AGENT'], + capture_output=True,timeout=0.4) + self.assertEqual(result.returncode,1);self.assertEqual(result.stderr,b'') + self.assertLess(time.monotonic()-started,0.25) + value=json.loads(result.stdout);self.assertFalse(value['native_accepted']) + self.assertFalse(value['private_input_delivery_confirmed']) + return value + def test_actual_main_fifo_refused_without_waiting_for_writer(self): + with tempfile.TemporaryDirectory() as folder: + path=Path(folder)/'contract.fifo';os.mkfifo(path,0o600) + self.assertEqual(self.actual_cli(path)['failure_code'],'source_file_invalid') + def test_actual_main_symlink_contract_refused(self): + with tempfile.TemporaryDirectory() as folder: + original=Path(folder)/'contract.json';original.write_text('{}') + path=Path(folder)/'alias';path.symlink_to(original) + self.assertEqual(self.actual_cli(path)['failure_code'],'source_regular_path_required') + def test_actual_main_oversize_contract_refused(self): + with tempfile.TemporaryDirectory() as folder: + path=Path(folder)/'contract.json';path.write_bytes(b'x'*(l.MAX_PUBLIC_BYTES+1)) + self.assertEqual(self.actual_cli(path)['failure_code'],'source_file_invalid') + def main_with(self,path,**patches): + out=io.StringIO() + argv=['launcher','--contract',str(path),'--human-file','/NEVER-READ-HUMAN', + '--agent-file','/NEVER-READ-AGENT'] + with patch.object(sys,'argv',argv),patch.object(sys,'stdout',out), \ + patch.object(l.subprocess,'Popen') as spawn,patch.object(l.handoff,'load') as load: + rc=l.main();spawn.assert_not_called();load.assert_not_called() + self.assertEqual(rc,1) + return json.loads(out.getvalue()) + def test_actual_main_slow_regular_read_has_hard_total_bound_and_signal_restore(self): + with tempfile.TemporaryDirectory() as folder: + path=Path(folder)/'contract.json';path.write_text('{}') + original=l.os.read + def slow(fd,size):time.sleep(0.1);return original(fd,size) + previous=signal.getsignal(signal.SIGALRM);started=time.monotonic() + with patch.object(l,'PREPARATION_TOTAL_SECONDS',0.01),patch.object(l.os,'read',side_effect=slow): + receipt=self.main_with(path) + self.assertEqual(receipt['failure_code'],'delivery_timeout') + self.assertLess(time.monotonic()-started,0.06) + self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0));self.assertIs(signal.getsignal(signal.SIGALRM),previous) + def test_actual_main_launch_does_not_renew_budget_spent_acquiring_contract(self): + with tempfile.TemporaryDirectory() as folder: + path=Path(folder)/'contract.json';path.write_text('{}') + original=l.read_public + def acquire(path):time.sleep(0.02);return original(path) + def slow_validate(contract):time.sleep(0.02);return '0'*64,{} + started=time.monotonic() + with patch.object(l,'PREPARATION_TOTAL_SECONDS',0.03), \ + patch.object(l,'read_public',side_effect=acquire), \ + patch.object(l,'validate_contract',side_effect=slow_validate): + receipt=self.main_with(path) + self.assertEqual(receipt['failure_code'],'delivery_timeout');self.assertLess(time.monotonic()-started,0.065) + def test_observed_source_change_refused_and_reads_are_bounded(self): + with tempfile.TemporaryDirectory() as folder: + path=Path(folder)/'contract.json';path.write_text('{}') + original=l.os.read;changed=[];sizes=[] + def mutate(fd,size): + sizes.append(size) + if not changed:path.write_text('{"x":1}');changed.append(True) + return original(fd,size) + with patch.object(l.os,'read',side_effect=mutate): + with self.assertRaisesRegex(l.handoff.Refused,'source_file_changed'):l.read_public(path) + self.assertTrue(all(0