diff --git a/docs/evidence/2026-09-14-openrouter-apply-request.json b/docs/evidence/2026-09-14-openrouter-apply-request.json new file mode 100644 index 0000000..ca806a5 --- /dev/null +++ b/docs/evidence/2026-09-14-openrouter-apply-request.json @@ -0,0 +1,81 @@ +{ + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service" + }, + "action": "apply", + "resource": { + "id": "catalog:openrouter-llm-connect", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "attributes": { + "stage": "prod", + "fields": [], + "policy_targets": [ + "se-prod-openrouter-llm-connect" + ], + "auth_targets": [ + "se-prod-openrouter-llm-connect" + ] + } + }, + "context": { + "purpose": "IR-WP-0004 read-only OpenRouter key authentication check; no inference", + "catalog_target": { + "kind": "kv", + "org": "coulomb", + "repo": "llm-connect", + "mount": "platform", + "path": "workloads/activity-core/llm-connect/llm-connect-provider-secrets", + "fields": [ + "OPENROUTER_API_KEY" + ], + "mount_management": "existing", + "consumers": [ + { + "name": "llm-connect-activity-core", + "auth": "kubernetes", + "claim": "serviceaccount:external-secrets/external-secrets", + "purpose": "sync llm-connect-provider-secrets for the activity-core deployment" + }, + { + "name": "intelligence-radar-key-check", + "auth": "approle", + "claim": "catalog:openrouter-llm-connect", + "purpose": "IR-WP-0004 read-only OpenRouter key authentication check; no inference" + } + ], + "delivery_modes": [ + "exec-env", + "read-check" + ], + "delivery_auth": { + "method": "approle", + "management": "engine", + "policy_name": "se-prod-openrouter-llm-connect", + "role_name": "se-prod-openrouter-llm-connect", + "token_ttl": "15m", + "token_max_ttl": "30m", + "secret_id_ttl": "15m", + "secret_id_num_uses": 1, + "token_num_uses": 8 + }, + "delivery_config": { + "exec_owner": { + "status": "pending", + "owner": "intelligence-radar-key-check", + "reason": "Pin tools/openrouter_key_check.py and its protected Python runtime, exact argv, private cwd and fixed environment after custody-owner admission. The pending binding refuses exec before consume or secret retrieval." + } + }, + "auth_capability": {}, + "workload_delivery": [ + { + "mode": "external-secrets", + "owner": "railiance-platform + llm-connect" + } + ] + }, + "human_control": true + } +} diff --git a/docs/evidence/2026-09-14-openrouter-approval-exercise.json b/docs/evidence/2026-09-14-openrouter-approval-exercise.json new file mode 100644 index 0000000..2f829ae --- /dev/null +++ b/docs/evidence/2026-09-14-openrouter-approval-exercise.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "target": "disposable local processes; synthetic credentials", + "started_at": "2026-09-13T22:42:40.649405+00:00", + "keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611", + "approval_engine_commit": "a0a602976eef818f36dde35f76f7f2e589bd051b", + "flex_auth_commit": "25712f2aad9c695ce4ce1060490432d2ffbf80ce", + "keycape_contract_commit": "6bcc2a5919799ceef68bd942d4f460ea24c87181", + "consumer_source_sha256": { + "approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5", + "approval_consume.py": "4515c74dbe1cea030cdccebf17b27642ad4f67f2aaee4f3a87e4bb2b9cfad295", + "approval_claim.py": "20b728d2d3e14ffc89fa21a137716015875f9a05c6af7ff48d07cf48d89503a9", + "authorization.py": "3ffcc76404af7d97d79219a5ed6fa6040516477857f530c8d5a5e45e38871def", + "config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955", + "service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c", + "cli.py": "1147d0e60850cfc38ee21e8564f51656d364ddc3cd8d6353a0c2dd6a0b78b8ec", + "catalog.py": "247c29314bcf86b3439d756ed911df62fb43c3c498b18ce5403704986a501b18", + "exec_owner.py": "10c21e7e37dd3d0aad41c4bb15df8f4a69c22d9046105a981a9613a91cd4c145", + "exec_delivery.py": "b142b60d875acf3dea1ba3ae99182a7ed7328d955dee5be2d12c977fc5646e8e", + "routing.py": "9d5050d7494a207394ce8251d8aac574ca0c08e3252639b5f05b373d6e35a076" + }, + "limitations": [ + "standalone Flex Auth source, not deployed pin", + "local Approval Engine source, not deployed image", + "no live custody or client-side read grant", + "no OpenBao effect or model execution", + "positive human entry seeded in disposable store; no human JWT/PKCE proof" + ], + "checks": { + "operator_issued_and_approved_via_verified_jwt": true, + "wrong_action_refused_before_consume": true, + "producer_origin_join_with_carried_claim": true, + "actual_consumer_claim_check_consume": true, + "same_digest_retry_idempotent": true, + "different_digest_refused": true, + "spent_claim_refused": true, + "operator_consume_scope_denied_by_issuer": true, + "wrong_secret_refused": true, + "no_access_token_file_created": true, + "registry_override_accepts_exact_submission": true, + "same_enriched_result_different_submission_refused": true, + "real_dual_control_denial_then_claim_check_consume": true, + "declared_control_refuses_real_keycape_service_bind": true, + "undeclared_control_refused_before_real_pdp_consume_backend": true, + "catalog_path_change_refused_before_consume_backend": true, + "catalog_mount_change_refused_before_consume_backend": true, + "catalog_owner_change_refused_before_consume_backend": true, + "catalog_limits_change_refused_before_consume_backend": true, + "exec_owner_substitution_refused_before_consume_backend": true, + "exec_owner_changed_environment_replay_refused_by_real_pdp_join": true, + "exec_owner_real_cli_consumes_before_fixture_backend": true, + "exec_owner_actual_child_excludes_parent_credentials": true, + "exec_owner_digest_preserved_by_real_evaluator": true, + "declared_human_intent_preserved_by_real_evaluator": true, + "declared_human_fixture_consumed_before_owner_delivery": true + }, + "flex_auth_binary_sha256": "d05d90212da8d762effb2b1b547aed29ab704791646714773d3fb57721a9b937", + "producer_input_sha256": { + "policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a", + "registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c" + }, + "exec_owner_scope": "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof", + "status": "passed", + "cleanup_complete": true, + "finished_at": "2026-09-13T22:43:02.850912+00:00" +} diff --git a/docs/evidence/2026-09-14-openrouter-live-pdp.json b/docs/evidence/2026-09-14-openrouter-live-pdp.json new file mode 100644 index 0000000..f7c4358 --- /dev/null +++ b/docs/evidence/2026-09-14-openrouter-live-pdp.json @@ -0,0 +1,22 @@ +{ + "image": "sha256:05a03a8790c2210c48ea92391441c77ddf640d0cd32f5ec09838f5393171fcbd", + "pod": "flex-auth-secrets-engine-6467b96c6f-ncl5t", + "scope": "PDP evaluation only; synthetic claim, no approval consume or OpenBao access", + "checks": { + "native_request_preserved": true, + "missing_caller_refused": true, + "wrong_caller_refused": true, + "foreign_owner_representation_refused": true, + "wrong_tenant_denied": true, + "recipient_cannot_be_lifecycle_subject": true, + "producer_approval_digest_pair": true, + "changed_path_cannot_reuse_approval_digest": true, + "changed_mount_cannot_reuse_approval_digest": true, + "changed_repo_cannot_reuse_approval_digest": true, + "other_deployment_specs_unchanged": true + }, + "decision_id": "decision:e8bec2d72f7df007", + "forward_stopped": true, + "caller_token_handling": "10 minute audience-bound tokens held in memory only; expire at issuer, not revoked by dropping local references", + "consumer_validation": "Actual secrets-engine validate_decision_envelope accepted the live response against the exact submission and v2 pin." +} diff --git a/docs/evidence/2026-09-14-openrouter-native-plan.txt b/docs/evidence/2026-09-14-openrouter-native-plan.txt new file mode 100644 index 0000000..61c3bc4 --- /dev/null +++ b/docs/evidence/2026-09-14-openrouter-native-plan.txt @@ -0,0 +1,20 @@ +Plan for lane 'openrouter-llm-connect' (stage=prod) + decision: + stage role: secrets-engine-prod + consumer policy: se-prod-openrouter-llm-connect + consumer approle: se-prod-openrouter-llm-connect + actions: + [kv-mount-check] platform (type=kv-v2, management=existing, mutation=none) + [policy] se-prod-openrouter-llm-connect (paths=platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets) + [approle] se-prod-openrouter-llm-connect (token_policies=se-prod-openrouter-llm-connect, auth=approle, token_ttl=15m, token_max_ttl=30m, token_num_uses=8, secret_id_ttl=15m, secret_id_num_uses=1) + + generated consumer policy (HCL): + # Generated by secrets-engine for policy "se-prod-openrouter-llm-connect" + + path "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { + capabilities = ["read"] + } + + path "platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { + capabilities = ["read"] + } diff --git a/docs/native-lane-cutover.md b/docs/native-lane-cutover.md index df7d775..fc74fd8 100644 --- a/docs/native-lane-cutover.md +++ b/docs/native-lane-cutover.md @@ -1,3 +1,15 @@ +> Current protocol correction (2026-09-14): follow claim → validated PDP Check → +> CAS consume, as implemented in docs/approval-consumption.md. ActionAuthorization +> is deferred and never ratified. The OpenRouter-specific current implementation, +> live PDP receipt and remaining service/reader gates are in +> docs/openrouter-native-access.md (SECRETS-WP-0010). + +> Current protocol correction (2026-09-14): follow claim → validated PDP Check → +> CAS consume, as implemented in docs/approval-consumption.md. ActionAuthorization +> is deferred and never ratified. The OpenRouter-specific current implementation, +> live PDP receipt and remaining service/reader gates are in +> docs/openrouter-native-access.md (SECRETS-WP-0010). + # Native lane cutover (SECRETS-WP-0006-T05 / T06) Status: procedure only. Live apply is still fail-closed until @@ -44,7 +56,7 @@ Provenance CCR: `CCR-2026-0003` (existing workload lane only). Required before OpenBao: -1. Canonical ActionAuthorization for `action=apply`, resource +1. Live approval-claim, validated PDP Check and CAS consume for `action=apply`, resource `catalog:openrouter-llm-connect`, stage `prod`, exact policy/auth targets `se-prod-openrouter-llm-connect`. 2. Successful approval-engine CAS consume of that request digest diff --git a/docs/openrouter-native-access.md b/docs/openrouter-native-access.md new file mode 100644 index 0000000..29ce286 --- /dev/null +++ b/docs/openrouter-native-access.md @@ -0,0 +1,82 @@ +# OpenRouter native preparation — 2026-09-14 + +SECRETS-WP-0010 implements the bounded owner packet from IR-WP-0004, +message `cfab5355-b0f9-4868-b4e6-61ea42c54b0f`. + +## Implemented + +Every action request now includes a deep copy of the non-secret catalog target +in `context.catalog_target`: custody mount/path, owning org/repo, declared fields, +consumers, mount management, delivery modes/config/auth and workload delivery. +The existing action, stage, purpose and human-control fields remain bound. +Approval object identifiers are excluded so issuance does not change its own +approval-free digest. Changed targets or token limits require a new approval; +existing approvals created before this change cannot authorize new submissions. +This binds proposed inputs; it does not turn catalog metadata into a grant. + +Plans now expose all AppRole limits, including the secret-ID TTL and use count. +The OpenRouter plan checks the existing mount and writes only the exact-path +policy and AppRole `se-prod-openrouter-llm-connect`. Limits: token TTL 15m, maximum +30m, secret-ID TTL 15m/single use, eight token uses. No KV mutation or ESO change. +See the non-secret plan and claim-free request under `docs/evidence/2026-09-14-*`. +Those are review inputs, not issued approvals; regenerate after configuring the +actual recipient because its target binding changes. + +`tools/openrouter_key_check.py` is the first proposed recipient. It performs one +TLS-verified GET to the fixed endpoint, ignores proxy configuration, follows no +redirects and makes no inference request. It emits only a fixed result and HTTP +status; no provider fields, labels, response bodies, key values or exception text. +The endpoint is OpenRouter's documented +[current-key information API](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key). +A successful check does not reconcile radar's existing spend reservation. + +`docs/proposals/openrouter-key-check.yaml` is an inactive proposed catalog overlay. +It retains the existing lane id/KV/policy/AppRole and ESO consumer, declares human +control, and names the key-check recipient with a pending exec-owner binding. +The active `catalog/openrouter-llm-connect.yaml` remains limited to its admitted +llm-connect purpose. The proposal is outside the active catalog and pending exec +refuses before consume/backend. Do not copy it into the active catalog until +custody-owner admission and the exact protected recipient installation are reviewed. + +## Verification + +410 tests passed, including disposable OpenBao coverage. New tests cover changed +path, mount, owner, consumer, limits, fields and delivery mode replay; frozen +request inputs; complete plan limits; and sanitized key-check success/errors, +redirect refusal, malformed/oversized responses and transport cleanup. +The actual local KeyCape + Approval Engine + compiled Flex Auth exercise passed +26 checks, including changed catalog target refusal before consume/backend and +exact child isolation. Its human approval and backend are explicit synthetic +fixtures, not native production evidence. + +Flex Auth's dedicated native pin was promoted to Helm revision 4 with the existing +published image `sha256:05a03a8790c2210c48ea92391441c77ddf640d0cd32f5ec09838f5393171fcbd`. +Eleven live evaluation checks passed, including current replay/approval binding; +other PDP deployment specs are unchanged. The separate local and live receipts +state their scope and cleanup. This removes the outdated PDP image dependency. + +## Remaining admitted execution sequence (SECRETS-WP-0010-T03) + +1. Complete APPROVAL-WP-0002-T01/T03/T05 and its identity/audit dependencies; + its declared namespace currently has no StatefulSet, pod or Service. Complete + RPF-WP-0035-T06 / CCR-2026-0019 client-side reader admission for the secrets-engine + approval-client. Keep secrets outside Git and use the existing attended + procedure in `docs/approval-service-auth.md`; do not read the verifier's Secret. +2. Have the custody owner admit this exact read-only recipient. Install and pin + its Python runtime and script, exact argv, private working directory and fixed + environment via `docs/exec-owner-binding.md`. Obtain the scoped attended + platform authority; no standing bootstrap credential or sibling identity. +3. Render the finalized request, use the evaluator-origin digest to create a + declared-human-control approval, obtain the real human entry, observe claim, + validate Check and CAS-consume before apply. `ActionAuthorization` is deferred, + not a missing API to implement. CCR-2026-0003 is provenance, not this approval. +4. Apply only the reviewed policy/AppRole. Verify correct-reader access, unrelated + identity and unrelated path denials, all fields, role limits, native session + revocation and llm-connect ESO/application health. No raw provider value in + evidence. Obtain a separate exact exec approval for the pinned key check. +5. Only after verified native readiness should SECRETS-WP-0006-T06 retire the + interim route. Rollback removes only the new policy/AppRole; preserve KV/ESO. +6. Trial delivery requires its own reviewed, pinned radar recipient/config and + approval. Reconcile the existing USD 0.023712 reservation in campaign + `ir-openrouter-pilot-20260913`; preserve the authorized USD 10 ceiling. This + task did not execute the key check or spend campaign funds. diff --git a/docs/proposals/openrouter-key-check.yaml b/docs/proposals/openrouter-key-check.yaml new file mode 100644 index 0000000..4a2156a --- /dev/null +++ b/docs/proposals/openrouter-key-check.yaml @@ -0,0 +1,87 @@ +# PROPOSED reviewed-input template; not active catalog or credential admission. +# SECRETS-WP-0010; retain the existing KV and AppRole names. +id: openrouter-llm-connect +kind: kv +org: coulomb +repo: llm-connect +stage: prod +description: >- + OpenRouter provider credential used by llm-connect for activity-core. The + proposed native recipient checks key authentication only; trial delivery + requires a separate reviewed recipient and exact-action approval. + +mount: platform +path: workloads/activity-core/llm-connect/llm-connect-provider-secrets +mount_management: existing +fields: + - OPENROUTER_API_KEY + +consumers: + - name: llm-connect-activity-core + auth: kubernetes + claim: serviceaccount:external-secrets/external-secrets + purpose: sync llm-connect-provider-secrets for the activity-core deployment + - name: intelligence-radar-key-check + auth: approle + claim: catalog:openrouter-llm-connect + purpose: IR-WP-0004 read-only OpenRouter key authentication check; no inference + +workload_delivery: + - mode: external-secrets + owner: railiance-platform + llm-connect + +delivery_modes: [exec-env, read-check] +delivery_auth: + method: approle + management: engine + policy_name: se-prod-openrouter-llm-connect + role_name: se-prod-openrouter-llm-connect + token_ttl: 15m + token_max_ttl: 30m + secret_id_ttl: 15m + secret_id_num_uses: 1 + token_num_uses: 8 + +approval: + model: ccr + human_control: true + purpose: IR-WP-0004 read-only OpenRouter key authentication check; no inference + decision_ref: CCR-2026-0003 + notes: >- + CCR-2026-0003 approves the existing workload lane. A live native AppRole + apply remains gated by SECRETS-WP-0006-T05 review and operator approval. + First native-cutover candidate (docs/native-lane-cutover.md); not an + authorization to apply. + +verification: + positive: approved delivery AppRole can read OPENROUTER_API_KEY without printing it + negative: unrelated token is denied on platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets + +risk: + classification: high + notes: provider key can incur spend and remains valid outside OpenBao until revoked at OpenRouter + +rotation: + owner: railiance-platform + llm-connect provider operator + expectation: >- + Mint a replacement at OpenRouter, update every live cluster copy, wait for + ESO refresh, restart llm-connect, verify, and revoke the old provider key. + ttl: provider-defined + +deactivation: + owner: railiance-platform + llm-connect + expectation: >- + Disable the front door and workload read path, remove materialized Secrets, + stop consumers, and revoke the provider key at OpenRouter. + +audit: + evidence: CCR id, actor, exact path, field name, provider key identifier if non-secret, timestamps, and pass/fail only + +delivery_config: + exec_owner: + status: pending + owner: intelligence-radar-key-check + reason: >- + Pin tools/openrouter_key_check.py and its protected Python runtime, exact + argv, private cwd and fixed environment after custody-owner admission. + The pending binding refuses exec before consume or secret retrieval. diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index d9deef7..664f942 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -6,6 +6,7 @@ policy, lifetime and approval correspondence before consumption. """ from __future__ import annotations +import copy import hashlib import json import re @@ -102,7 +103,22 @@ def build_action_request( "auth_targets": sorted(set(auth_targets)), }, }, - "context": {"purpose": purpose}, + "context": { + "purpose": purpose, + # Bind the actual non-secret custody/delivery specification, not + # just names which can be reused for a different backend target. + # Approval identifiers are excluded: adding the newly issued + # approval must not change its own approval-free binding. + "catalog_target": copy.deepcopy({ + name: getattr(entry, name) + for name in ( + "kind", "org", "repo", "mount", "path", "fields", + "mount_management", "consumers", "delivery_modes", + "delivery_auth", "delivery_config", "auth_capability", + "workload_delivery", + ) + }), + }, } ) if human_control_required(entry): diff --git a/src/secrets_engine/plan.py b/src/secrets_engine/plan.py index 622bcac..c325639 100644 --- a/src/secrets_engine/plan.py +++ b/src/secrets_engine/plan.py @@ -79,6 +79,9 @@ def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Pla "token_policies": policy_name, "auth": "approle", "token_ttl": entry.token_ttl, + "token_max_ttl": entry.token_max_ttl, + "secret_id_ttl": entry.secret_id_ttl, + "token_num_uses": entry.token_num_uses, "secret_id_num_uses": entry.secret_id_num_uses, }, ), @@ -114,6 +117,8 @@ def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Pla "token_ttl": entry.delivery_token_ttl, "token_max_ttl": entry.delivery_token_max_ttl, "token_num_uses": entry.delivery_token_num_uses, + "secret_id_ttl": entry.delivery_secret_id_ttl, + "secret_id_num_uses": entry.delivery_secret_id_num_uses, }, ), ] diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py index 47e9b74..8e93af7 100644 --- a/tests/test_decision_replay.py +++ b/tests/test_decision_replay.py @@ -3,7 +3,8 @@ import copy import json from datetime import datetime, timezone from pathlib import Path -from types import SimpleNamespace +from secrets_engine.catalog import validate_entry +from tests.test_catalog import VALID import pytest @@ -122,7 +123,7 @@ def test_real_expired_allow_refused(): def test_request_builder_tenant_boundary(): - entry = SimpleNamespace(id="glas-primary", stage="prod") + entry = validate_entry({**VALID, "id": "glas-primary", "stage": "prod"}) args = dict(subject_id="secrets-engine", subject_type="service", purpose="rotation") request = build_action_request(entry, "rotate", **args) assert request["tenant"] == REQUEST_TENANT diff --git a/tests/test_openrouter_binding.py b/tests/test_openrouter_binding.py new file mode 100644 index 0000000..9ec69d6 --- /dev/null +++ b/tests/test_openrouter_binding.py @@ -0,0 +1,61 @@ +"""A named native lane must not hide a changed custody or delivery target.""" +import copy +from pathlib import Path + +import pytest + +from secrets_engine.authorization import build_action_request, approval_binding_digest, validate_decision_envelope +from secrets_engine.catalog import load_entry, validate_entry +from secrets_engine.errors import DecisionError +from secrets_engine.plan import build_plan +from tests.test_action_authorization import _envelope + +CATALOG = Path(__file__).resolve().parents[1] / "catalog/openrouter-llm-connect.yaml" + + +def request(entry): + return build_action_request(entry, "apply", subject_id="secrets-engine", + subject_type="service", request_id="check:openrouter-apply", purpose="IR-WP-0004 native OpenRouter access", + policy_targets=[entry.policy_name], auth_targets=[entry.role_name]) + + +@pytest.mark.parametrize("change", ["path", "mount", "repo", "consumer", "ttl", "uses", "field", "delivery"]) +def test_old_approval_cannot_follow_changed_native_target(change): + entry = load_entry(CATALOG) + original = request(entry) + data = copy.deepcopy(entry.raw) + if change in {"path", "mount", "repo"}: data[change] += "-other" + elif change == "consumer": data["consumers"][1]["name"] = "unrelated-recipient" + elif change == "ttl": data["delivery_auth"]["token_max_ttl"] = "24h" + elif change == "uses": data["delivery_auth"]["secret_id_num_uses"] = 0 + elif change == "field": data["fields"].append("OTHER_KEY") + elif change == "delivery": data["delivery_modes"].append("exec-file") + changed = request(validate_entry(data)) + assert approval_binding_digest(original) != approval_binding_digest(changed) + with pytest.raises(DecisionError, match="submitted request digest"): + validate_decision_envelope(_envelope(original), changed, + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, + accepted_policy_versions={"v2"}) + + +def test_issuing_approval_does_not_change_its_own_target(): + entry = load_entry(CATALOG) + original = request(entry) + data = copy.deepcopy(entry.raw) + data["approval"]["authorization_id"] = "new-approval-object" + assert approval_binding_digest(original) == approval_binding_digest(request(validate_entry(data))) + entry.delivery_auth["token_max_ttl"] = "24h" + assert original["context"]["catalog_target"]["delivery_auth"]["token_max_ttl"] == "30m" + + +def test_openrouter_plan_exposes_all_limits_and_preserves_existing_custody(): + entry = load_entry(CATALOG) + plan = build_plan(entry, "prod") + assert [a.kind for a in plan.actions] == ["kv-mount-check", "policy", "approle"] + assert plan.policy_name == plan.role_name == "se-prod-openrouter-llm-connect" + assert entry.kv_data_path in plan.policy_hcl + assert plan.actions[-1].detail == { + "token_policies": plan.policy_name, "auth": "approle", + "token_ttl": "15m", "token_max_ttl": "30m", "token_num_uses": 8, + "secret_id_ttl": "15m", "secret_id_num_uses": 1, + } diff --git a/tests/test_openrouter_key_check.py b/tests/test_openrouter_key_check.py new file mode 100644 index 0000000..4463d5e --- /dev/null +++ b/tests/test_openrouter_key_check.py @@ -0,0 +1,54 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +SPEC = importlib.util.spec_from_file_location("openrouter_key_check", Path(__file__).resolve().parents[1] / "tools/openrouter_key_check.py") +probe = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(probe) + + +@pytest.mark.parametrize("status,body,result", [ + (200, b'{"data":{"label":"SYNTHETIC-KEY","usage":999}}', "authenticated"), + (401, b'SYNTHETIC-KEY', "refused"), + (302, b'SYNTHETIC-KEY', "refused"), + (500, b'SYNTHETIC-KEY', "refused"), + (200, b'bad SYNTHETIC-KEY', "check_failed"), + (200, b'{"data":null}', "invalid_response"), + (200, b'x' * (probe.MAX_BODY + 1), "invalid_response"), +]) +def test_probe_is_one_fixed_read_and_never_returns_provider_content(status, body, result): + calls = [] + class Connection: + def __init__(self, host, timeout): + assert (host, timeout) == ("openrouter.ai", 10) + def request(self, method, path, headers): + calls.append((method, path)) + assert headers["Authorization"] == "Bearer SYNTHETIC-KEY" + def getresponse(self): return self + def read(self, size): + assert size == probe.MAX_BODY + 1 + return body[:size] + def close(self): calls.append("closed") + Connection.status = status + output = probe.check("SYNTHETIC-KEY", connection_factory=Connection) + assert output["result"] == result + assert "SYNTHETIC-KEY" not in json.dumps(output) + assert calls == [("GET", "/api/v1/key"), "closed"] + + +def test_transport_error_is_sanitized_and_connection_closed(): + closed = [] + class Broken: + def __init__(self, *a, **kw): pass + def request(self, *a, **kw): raise OSError("SYNTHETIC-KEY") + def close(self): closed.append(True) + assert probe.check("SYNTHETIC-KEY", connection_factory=Broken) == {"result": "check_failed"} + assert closed == [True] + + +@pytest.mark.parametrize("key", ["", "key\r\nInjected: value", "key with spaces", "non-ascii-ä"]) +def test_bad_input_never_opens_connection(key): + def forbidden(*a, **kw): pytest.fail("must not connect") + assert probe.check(key, connection_factory=forbidden) == {"result": "invalid_input"} diff --git a/tools/exercise_approval_identity.py b/tools/exercise_approval_identity.py index af4f15d..9fd73fb 100644 --- a/tools/exercise_approval_identity.py +++ b/tools/exercise_approval_identity.py @@ -430,6 +430,21 @@ def exercise(args): assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls else: raise AssertionError("substitute recipient accepted") + from dataclasses import replace + for name, changed_entry in { + "path": replace(owner_entry, path=owner_entry.path + "-unrelated"), + "mount": replace(owner_entry, mount=owner_entry.mount + "-unrelated"), + "owner": replace(owner_entry, repo=owner_entry.repo + "-unrelated"), + "limits": replace(owner_entry, delivery_auth={**owner_entry.delivery_auth, "token_max_ttl": "24h"}), + }.items(): + with patch.object(cli, "get_entry", return_value=changed_entry): + try: + cli.cmd_exec(cfg, cli_args) + except DecisionError: + assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls + else: + raise AssertionError("changed catalog target replay accepted: " + name) + receipt["checks"]["catalog_" + name + "_change_refused_before_consume_backend"] = True original_env = raw["delivery_config"]["exec_owner"]["environment"]["LANG"] raw["delivery_config"]["exec_owner"]["environment"]["LANG"] = "C" try: diff --git a/tools/openrouter_key_check.py b/tools/openrouter_key_check.py new file mode 100644 index 0000000..6601d66 --- /dev/null +++ b/tools/openrouter_key_check.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Pinned read-only recipient for native delivery; no model inference or retries. + +Review and pin this script AND its Python runtime with delivery_config.exec_owner. +The custody owner must admit this recipient before a real key is delivered. +""" +import http.client +import json +import os +import sys + +HOST = "openrouter.ai" +PATH = "/api/v1/key" +MAX_BODY = 65536 + + +def check(key, *, connection_factory=http.client.HTTPSConnection): + if not key or not key.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in key): + return {"result": "invalid_input"} + connection = None + try: + # HTTPSConnection verifies TLS, ignores proxy env, and follows no redirects. + connection = connection_factory(HOST, timeout=10) + connection.request("GET", PATH, headers={"Authorization": "Bearer " + key, "Accept": "application/json"}) + response = connection.getresponse() + if response.status != 200: + return {"result": "refused", "http_status": response.status} + body = response.read(MAX_BODY + 1) + if len(body) > MAX_BODY: + return {"result": "invalid_response"} + payload = json.loads(body) + if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict): + return {"result": "invalid_response"} + # Do not serialize any provider field: labels can contain key prefixes, + # error bodies can reflect credentials, and aggregate usage is not a + # reconciliation receipt for radar's existing campaign reservation. + return {"result": "authenticated", "http_status": 200} + except (OSError, http.client.HTTPException, ValueError): + return {"result": "check_failed"} + finally: + if connection is not None: + connection.close() + + +def main(): + key = os.environ.pop("OPENROUTER_API_KEY", "") + result = {"result": "invalid_arguments"} if len(sys.argv) != 1 else check(key) + print(json.dumps(result, sort_keys=True)) + return 0 if result["result"] == "authenticated" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index 3294bad..131164a 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -710,9 +710,15 @@ priority: high state_hub_task_id: "a0a1dd92-d703-5a95-b488-d895f34d5cf7" ``` -Dependency: T01–T03, T05, T06 are done. T04 still waits on the durable -ActionAuthorization serving path. Also requires explicit per-lane approvals -and scoped production authority from railiance-platform. +Dependency: T01–T03, T05, T06 are done. T04 implements claim/PDP Check/CAS consume; +ActionAuthorization is deferred, not its serving prerequisite. Production still +waits on the admitted Approval Engine identity/audit/service and client-reader +path, explicit per-lane approval and scoped attended platform authority. + +2026-09-14: SECRETS-WP-0010-T01/T02 implemented exact catalog-target binding and +the proposed value-safe OpenRouter recipient. FLEX-WP-0026 promoted the dedicated +PDP to revision 4 and verified the live replay join. Native admission/verification +remains SECRETS-WP-0010-T03; no real key was retrieved and this task remains wait. Resume `SECRETS-WP-0006-T05` lane by lane. Apply only the exact native policy and bounded AppRole, verify every field plus unrelated-consumer denial, confirm the diff --git a/workplans/SECRETS-WP-0010-openrouter-native-access.md b/workplans/SECRETS-WP-0010-openrouter-native-access.md new file mode 100644 index 0000000..1c74f7e --- /dev/null +++ b/workplans/SECRETS-WP-0010-openrouter-native-access.md @@ -0,0 +1,68 @@ +--- +id: SECRETS-WP-0010 +type: workplan +title: "Native OpenRouter access for intelligence-radar" +domain: infotech +repo: secrets-engine +status: blocked +owner: codex +topic_slug: netkingdom +created: "2026-09-14" +updated: "2026-09-14" +related_workplans: + - IR-WP-0004 + - FLEX-WP-0026 + - SECRETS-WP-0007 + - SECRETS-WP-0006 +--- + +Source request: intelligence-radar message cfab5355-b0f9-4868-b4e6-61ea42c54b0f. +Implementation and execution procedure: `docs/openrouter-native-access.md`. + +## Bind approval to actual native custody and delivery inputs + +```task +id: SECRETS-WP-0010-T01 +status: done +priority: high +``` + +Implemented context.catalog_target and complete plan limits. Changed paths, +mounts, owners and token limits refuse replay before consume/backend against +real local components. Existing exec-owner and human-control contracts retained. +410 repository tests and 26 component checks passed. Receipts in docs/evidence. + +## Prepare value-safe first recipient and exact native plan + +```task +id: SECRETS-WP-0010-T02 +status: done +priority: high +``` + +Implemented the fixed read-only OpenRouter key-check script and synthetic tests. +Inactive proposed overlay declares human control and a pending exact recipient; +active llm-connect catalog admission is not broadened. Non-secret apply request +and bounded plan are review artifacts, not runtime grants. + +## Admit and verify real native delivery + +```task +id: SECRETS-WP-0010-T03 +status: wait +priority: high +``` + +Live residual from FLEX-WP-0026: the dedicated PDP is now current (revision 4, +11 live checks). Actual delivery still requires APPROVAL-WP-0002-T01/T03/T05 +(identity/audit/service deployment), RPF-WP-0035-T06 / CCR-2026-0019 client-reader +admission, exact installed recipient admission, real human approval/consume and +scoped attended platform authority. No Approval Engine StatefulSet/pod/Service +was present in its declared namespace at the 2026-09-14 inspection. + +Then execute `docs/openrouter-native-access.md` steps: bounded native apply, +positive/negative checks, ESO/app health, value-safe key check and session revoke. +SECRETS-WP-0007-T04/T07 and SECRETS-WP-0006-T05/T06 remain wait; this workplan +must not close them from synthetic evidence. Keep WARDEN-WP-0039-T03 and +IR-WP-0004-T02 waiting until the native route passes. Trials require a separately +bound recipient and the existing campaign/budget reconciliation.