From 429cc912ed0dd2cbcffc5ef30dccdbf596277b66 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 22 Aug 2026 10:03:54 +0200 Subject: [PATCH] Finish coding-agent high-risk boundary coverage Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa --- Makefile | 5 +- WORK-RECORDS.md | 5 +- docs/coding-agent-openbao-identity.md | 117 ++++++++++++++ .../agent-high-risk-boundary-2026-08-21.md | 56 +++++-- openbao/auth/coding-agent-approle.json | 13 ++ openbao/auth/coding-agent-jwt-role.json | 23 +++ openbao/policies/agent-high-risk-boundary.hcl | 15 ++ .../ops-warden-high-risk-data-paths.yaml | 90 +++++++++++ scripts/agent_high_risk_boundary.py | 146 ++++++++++++++++++ scripts/verify_coding_agent_approle.py | 140 +++++++++++++++++ tests/test_credential_change.py | 93 ++++++++--- ...-0022-agent-high-risk-boundary-coverage.md | 71 ++++++--- 12 files changed, 720 insertions(+), 54 deletions(-) create mode 100644 docs/coding-agent-openbao-identity.md create mode 100644 openbao/auth/coding-agent-approle.json create mode 100644 openbao/auth/coding-agent-jwt-role.json create mode 100644 openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml create mode 100644 scripts/agent_high_risk_boundary.py create mode 100644 scripts/verify_coding_agent_approle.py diff --git a/Makefile b/Makefile index cef39c6..b0b8dd2 100644 --- a/Makefile +++ b/Makefile @@ -340,6 +340,9 @@ credential-change-lifecycle-plan: ## Render deactivation/rotation/compromise lif credential-change-lifecycle-event: ## Record lifecycle event; pass CREDENTIAL_CHANGE_LIFECYCLE_ARGS scripts/credential-change.py lifecycle-event $(CREDENTIAL_CHANGE) --action $(CREDENTIAL_CHANGE_LIFECYCLE_ACTION) $(CREDENTIAL_CHANGE_LIFECYCLE_ARGS) +agent-high-risk-boundary-check: ## Check policy against the versioned ops-warden high-risk path input + python3 scripts/agent_high_risk_boundary.py --json + credential-change-import-inventory: ## Import existing lane as non-secret CCR; pass CREDENTIAL_CHANGE_IMPORT_ARGS scripts/credential-change.py import-inventory $(CREDENTIAL_CHANGE_IMPORT_ARGS) @@ -464,4 +467,4 @@ help: ## Show this help /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) .PHONY: apps-pg-isolation-probe -.PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help +.PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory agent-high-risk-boundary-check openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index beb80e2..a5ed4db 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -20,7 +20,7 @@ | workplan | RAILIANCE-WP-0016 | finished | — | workplans/RAILIANCE-WP-0016-apps-pg-resource-evidence.md | | workplan | RAILIANCE-WP-0016 | finished | — | workplans/RAILIANCE-WP-0016-architecture-cleanup-backlog.md | | workplan | RAILIANCE-WP-0017 | finished | — | workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md | -| workplan | RAILIANCE-WP-0022 | blocked | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| workplan | RAILIANCE-WP-0022 | finished | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | workplan | RAILIANCE-WP-0023 | finished | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md | | workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | @@ -91,7 +91,8 @@ | task | RAILIANCE-WP-0022-T02 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | task | RAILIANCE-WP-0022-T03 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | task | RAILIANCE-WP-0022-T04 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | -| task | RAILIANCE-WP-0022-T05 | wait | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T05 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T06 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | task | RAILIANCE-WP-0023-T01 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md | | task | RAILIANCE-WP-0023-T02 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md | | task | RAILIANCE-WP-0023-T03 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md | diff --git a/docs/coding-agent-openbao-identity.md b/docs/coding-agent-openbao-identity.md new file mode 100644 index 0000000..6762bf7 --- /dev/null +++ b/docs/coding-agent-openbao-identity.md @@ -0,0 +1,117 @@ +# Coding-agent OpenBao identity + +`RAILIANCE-WP-0022` requires an OpenBao identity that is distinct from the +attended human `platform-admin` role. The identity is split across its proper +owners: + +- KeyCape owns a confidential `client_credentials` service principal and its + signed short-lived JWT. +- railiance-platform owns the exact-bound OpenBao JWT role and the policies on + the resulting OpenBao token. +- ops-warden remains an interested consumer and SSH-certificate issuer; it does + not issue this identity or its credentials. + +The operational identity is the platform-owned AppRole +`coding-agent-railiance-platform`. The exact-bound KeyCape JWT role is the +issuer-backed migration target; it does not block the deny-wins control while +the KeyCape client is being registered. + +## Operational AppRole + +The reviewed role body is `openbao/auth/coding-agent-approle.json`. Apply it +with attended platform authority: + +```bash +bao write auth/approle/role/coding-agent-railiance-platform \ + @openbao/auth/coding-agent-approle.json +``` + +This is a standing machine role, not a manually minted token. Every SecretID +expires after five minutes and is single-use; resulting tokens last at most 15 +minutes, have eight uses, omit the default policy, and always carry +`agent-high-risk-boundary`. The role also carries the otherwise-readable +issue-core policy solely to make the deny-wins invariant directly testable. +The boundary permits token metadata lookup, capabilities inspection, and +self-revocation; it does not grant token creation or management of any other +token. + +Run the non-disclosing verifier while holding attended authority to create one +ephemeral SecretID: + +```bash +python3 scripts/verify_coding_agent_approle.py +``` + +The verifier holds the SecretID and token only in memory, calls +`sys/capabilities-self` rather than a KV read, and revokes the test token before +exit. It prints only policy names, TTL, capabilities, and pass/fail state. + +## KeyCape contract + +The accepted service token must contain exactly the following identity +coordinates: + +| Claim | Required value | +| --- | --- | +| `aud` / client id | `codex-railiance-platform` | +| `sub` | `service:codex:railiance-platform` | +| `principal_type` | `service` | +| `tenant` | `tenant:coulomb` | +| `roles` | contains `coding-agent` | +| requested scope | `openbao:login` | + +The KeyCape access token lifetime is 15 minutes. Its confidential client secret +must be generated and delivered through an approved out-of-repository custody +path. It must never appear in Git, State Hub, command arguments, logs, or chat. + +## KeyCape-backed OpenBao role + +The migration-target role body is +`openbao/auth/coding-agent-jwt-role.json`. Apply it only with attended platform +authority: + +```bash +bao write auth/netkingdom/role/coding-agent \ + @openbao/auth/coding-agent-jwt-role.json +``` + +The role binds the exact service audience, subject, principal type, tenant, and +role. It produces a 15-minute, eight-use token without the default policy. The +token deliberately combines `agent-high-risk-boundary` with +`workload-kv-read-issue-core-runtime`: the latter would otherwise read the +issue-core bundle, while the former must win with `deny`. + +## Value-safe verification + +Do not verify by attempting `bao kv get`; an unexpected policy error could +print a protected value. After KeyCape exchanges the client credential and the +JWT login produces an OpenBao token through a mode-0600 temporary file, use only +capabilities and token metadata: + +```bash +BAO_TOKEN_FILE=/run/user/$(id -u)/coding-agent.openbao-token +BAO_TOKEN=$(head -n 1 "$BAO_TOKEN_FILE") \ + bao token capabilities \ + platform/data/workloads/issue-core/issue-core/issue-core-runtime +BAO_TOKEN=$(head -n 1 "$BAO_TOKEN_FILE") \ + bao token capabilities \ + platform/metadata/workloads/issue-core/issue-core/issue-core-runtime +BAO_TOKEN=$(head -n 1 "$BAO_TOKEN_FILE") bao token lookup -format=json \ + | jq '{display_name:.data.display_name,policies:.data.policies,ttl:.data.ttl}' +``` + +Expected results are `deny` for the data path and `read` for the metadata path. +The lookup must list both policies, omit `default`, and report a TTL no greater +than 15 minutes. Shred the temporary JWT and OpenBao token files after the +check. Revoke the issued OpenBao token if it remains live. + +## Abort and rollback + +- Abort if any bound claim differs, the JWT issuer/audience is not the live + KeyCape contract, or a token would be printed. +- Delete `auth/netkingdom/role/coding-agent` if deny-wins does not appear in + capabilities or the token includes an unexpected policy. +- Delete `auth/approle/role/coding-agent-railiance-platform` if its metadata + differs from the reviewed body or its verifier fails. +- Revoke the test OpenBao token, disable the KeyCape client, and rotate its + confidential secret after suspected disclosure. diff --git a/docs/evidence/agent-high-risk-boundary-2026-08-21.md b/docs/evidence/agent-high-risk-boundary-2026-08-21.md index ab0742c..2417bb0 100644 --- a/docs/evidence/agent-high-risk-boundary-2026-08-21.md +++ b/docs/evidence/agent-high-risk-boundary-2026-08-21.md @@ -22,6 +22,23 @@ modified. catalog entries share the Binky IMAP path and Core Hub is an additional reviewed deny without a catalog lane. +### 2026-08-22 generated-input reconciliation + +- Ops-warden delivered generated artifact revision + `55f0f47a021375b8b25c924953d1b49a24e002c5`: 19 high-risk lanes, 14 + concrete entries (13 unique paths), and five pattern/non-KV lanes. +- The corrected grades add issue-core runtime (whose bundle also contains + `GITEA_BACKEND_TOKEN`) and reuse-surface runtime (whose bundle also contains + `REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET`). Both data paths now have explicit + deny blocks and read-only metadata blocks. +- `scripts/agent_high_risk_boundary.py` verified that the vendored input equals + the clean upstream artifact aside from generation time and that every entry + is covered. Forty-nine focused tests passed. +- Under attended `platform-admin` OIDC, the policy was uploaded and read back. + OpenBao added only its normal trailing newline. The server-backed ops-warden + audit reported 19 high-risk lanes, 14 covered, zero uncovered, and five with + no concrete address. + ## Attachment audit and residual blocker A metadata-only scan listed and read role configuration under netkingdom OIDC, @@ -30,14 +47,33 @@ Kubernetes auth, AppRole, and token roles. It found: - roles attaching `agent-high-risk-boundary`: **0**; - roles combining it with any `workload-kv-read-*` policy: **0**. -The live policy is therefore complete but is not automatically attached to a -coding-agent identity. The documented manual short-lived token example is not -a standing identity and carries no workload-read policy. Attaching the boundary -to `platform-admin` would incorrectly constrain the attended operator role and -erase the human/agent distinction, so that change was not made. +The 2026-08-21 scan accurately found no attachment at that time. On 2026-08-22, +State Hub decision `f0955252-7b20-4c80-86e9-f8080ec60793` selected the +platform-owned `coding-agent-railiance-platform` AppRole as the operational +machine identity while retaining the KeyCape JWT role as an issuer-backed +migration target. -The remaining work is an identity-owner decision: define a distinct coding- -agent issuance path, attach the boundary, and prove that deny wins when a -workload read policy is also present. A versioned generated list of concrete -high-risk deny paths is also requested from ops-warden so policy coverage does -not depend on manual catalog transcription. +Live AppRole metadata matched the reviewed source: + +- `bind_secret_id=true`, `secret_id_ttl=300`, and + `secret_id_num_uses=1`; +- `token_ttl=900`, `token_max_ttl=900`, `token_num_uses=8`, and no default + policy; +- policies exactly `agent-high-risk-boundary` and + `workload-kv-read-issue-core-runtime`. + +The non-disclosing verifier minted one ephemeral SecretID, logged in, and used +only token metadata plus `sys/capabilities-self`. The data path resolved to +`deny`; its metadata path resolved to `read`; the token TTL was 900 seconds. +The first verification exposed that a no-default-policy token could not revoke +itself. That bounded test token was located and revoked by accessor under +attended authority, `auth/token/revoke-self:update` was added to the boundary, +and the repeat verification passed and self-revoked without warning. No KV read +was attempted and no SecretID, token, or protected value was printed or logged. + +The exact-bound JWT role `auth/netkingdom/role/coding-agent` was also applied +with audience `codex-railiance-platform`, subject +`service:codex:railiance-platform`, `principal_type=service`, +`tenant=tenant:coulomb`, and role `coding-agent`. KeyCape client registration +is a future identity-hardening handoff; it is not required for the live AppRole +attachment proven here. diff --git a/openbao/auth/coding-agent-approle.json b/openbao/auth/coding-agent-approle.json new file mode 100644 index 0000000..36be4bc --- /dev/null +++ b/openbao/auth/coding-agent-approle.json @@ -0,0 +1,13 @@ +{ + "bind_secret_id": true, + "secret_id_ttl": "5m", + "secret_id_num_uses": 1, + "token_policies": [ + "agent-high-risk-boundary", + "workload-kv-read-issue-core-runtime" + ], + "token_ttl": "15m", + "token_max_ttl": "15m", + "token_num_uses": 8, + "token_no_default_policy": true +} diff --git a/openbao/auth/coding-agent-jwt-role.json b/openbao/auth/coding-agent-jwt-role.json new file mode 100644 index 0000000..2bd5e86 --- /dev/null +++ b/openbao/auth/coding-agent-jwt-role.json @@ -0,0 +1,23 @@ +{ + "role_type": "jwt", + "bound_audiences": [ + "codex-railiance-platform" + ], + "user_claim": "sub", + "bound_claims_type": "string", + "bound_claims": { + "sub": "service:codex:railiance-platform", + "principal_type": "service", + "tenant": "tenant:coulomb", + "roles": "coding-agent" + }, + "token_policies": [ + "agent-high-risk-boundary", + "workload-kv-read-issue-core-runtime" + ], + "token_ttl": "15m", + "token_max_ttl": "15m", + "token_num_uses": 8, + "token_no_default_policy": true, + "verbose_oidc_logging": false +} diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index e751933..1d45373 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -22,6 +22,18 @@ path "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-sec path "platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { capabilities = ["read"] } +path "platform/data/workloads/issue-core/issue-core/issue-core-runtime" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/issue-core/issue-core/issue-core-runtime" { + capabilities = ["read"] +} +path "platform/data/workloads/reuse/reuse-surface/runtime-secrets" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/reuse/reuse-surface/runtime-secrets" { + capabilities = ["read"] +} path "platform/data/workloads/core-hub/runtime" { capabilities = ["deny"] } @@ -85,3 +97,6 @@ path "sys/capabilities-self" { path "auth/token/lookup-self" { capabilities = ["read"] } +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml b/openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml new file mode 100644 index 0000000..b17869c --- /dev/null +++ b/openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml @@ -0,0 +1,90 @@ +# GENERATED by ops-warden/scripts/emit_high_risk_paths.py -- do not edit by hand. +# Vendored policy input. ops-warden owns the risk grade; railiance-platform owns +# whether and how agent-high-risk-boundary denies each path. + +generated_at: "2026-08-21T11:28:33Z" +source: ops-warden/registry/routing/catalog.yaml +catalog_revision: "55f0f47a021375b8b25c924953d1b49a24e002c5" +catalog_revision_date: "2026-08-21T13:26:10+02:00" +catalog_dirty: false +high_risk_lane_count: 19 +concrete_path_count: 14 + +no_concrete_path: + - database-dynamic-credentials + - inter-hub-bootstrap-ssh + - object-storage-sts + - openbao-api-key + - ops-warden-warden-sign-token + +paths: + - id: agent-harness-binky-mail-approle + data_path: tenants/data/binky/company-email/imap + metadata_path: tenants/metadata/binky/company-email/imap + owner_repo: railiance-platform + fields: null + - id: agent-harness-forgejo-deploy + data_path: platform/data/workloads/agent-harness/forgejo-deploy-key + metadata_path: platform/metadata/workloads/agent-harness/forgejo-deploy-key + owner_repo: railiance-platform + fields: null + - id: audit-core-senders + data_path: platform/data/workloads/audit-core/senders + metadata_path: platform/metadata/workloads/audit-core/senders + owner_repo: ops-mason + fields: null + - id: binky-company-email-imap + data_path: tenants/data/binky/company-email/imap + metadata_path: tenants/metadata/binky/company-email/imap + owner_repo: railiance-platform + fields: null + - id: binky-qonto-api + data_path: tenants/data/binky/qonto-api + metadata_path: tenants/metadata/binky/qonto-api + owner_repo: railiance-platform + fields: null + - id: email-connect-transactional + data_path: platform/data/workloads/email-connect/transactional + metadata_path: platform/metadata/workloads/email-connect/transactional + owner_repo: railiance-platform + fields: null + - id: forgejo-admin-api-token + data_path: platform/data/workloads/forgejo/forgejo-admin + metadata_path: platform/metadata/workloads/forgejo/forgejo-admin + owner_repo: railiance-platform + fields: null + - id: issue-core-ingestion-api-key + data_path: platform/data/workloads/issue-core/issue-core/issue-core-runtime + metadata_path: platform/metadata/workloads/issue-core/issue-core/issue-core-runtime + owner_repo: railiance-platform + fields: [ISSUE_CORE_API_KEY, GITEA_BACKEND_TOKEN] + - id: openrouter-llm-connect + data_path: platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets + metadata_path: platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider-secrets + owner_repo: railiance-platform + fields: null + - id: railiance-backup-offsite-lane + data_path: platform/data/workloads/railiance/backup/offsite-lane + metadata_path: platform/metadata/workloads/railiance/backup/offsite-lane + owner_repo: railiance-platform + fields: null + - id: rapp-qonto-keycape-client + data_path: platform/data/workloads/rapp-qonto/keycape-client + metadata_path: platform/metadata/workloads/rapp-qonto/keycape-client + owner_repo: key-cape + fields: null + - id: reuse-surface-hub-write-token + data_path: platform/data/workloads/reuse/reuse-surface/runtime-secrets + metadata_path: platform/metadata/workloads/reuse/reuse-surface/runtime-secrets + owner_repo: railiance-platform + fields: [REUSE_SURFACE_TOKEN, REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET] + - id: scaleway-bootstrap + data_path: platform/data/workloads/railiance/scaleway/bootstrap + metadata_path: platform/metadata/workloads/railiance/scaleway/bootstrap + owner_repo: railiance-platform + fields: null + - id: whynot-design-npm-publish + data_path: platform/data/workloads/coulomb/whynot-design/npm-publish + metadata_path: platform/metadata/workloads/coulomb/whynot-design/npm-publish + owner_repo: railiance-platform + fields: null diff --git a/scripts/agent_high_risk_boundary.py b/scripts/agent_high_risk_boundary.py new file mode 100644 index 0000000..0ea44b9 --- /dev/null +++ b/scripts/agent_high_risk_boundary.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Check the platform-owned agent deny policy against ops-warden's risk input.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + + +REPO_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ARTIFACT = ( + REPO_DIR / "openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml" +) +DEFAULT_POLICY = REPO_DIR / "openbao/policies/agent-high-risk-boundary.hcl" + + +def load_artifact(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"artifact must be a mapping: {path}") + return data + + +def parse_policy(text: str) -> dict[str, set[str]]: + paths: dict[str, set[str]] = {} + for match in re.finditer( + r'path\s+"([^"]+)"\s*\{[^}]*?capabilities\s*=\s*\[([^\]]*)\]', + text, + re.DOTALL, + ): + paths[match.group(1)] = { + value.strip().strip('"\'') + for value in match.group(2).split(",") + if value.strip() + } + return paths + + +def check_boundary( + artifact: dict[str, Any], policy: dict[str, set[str]] +) -> dict[str, Any]: + errors: list[str] = [] + rows = artifact.get("paths") + no_concrete = artifact.get("no_concrete_path") + if not isinstance(rows, list): + rows = [] + errors.append("artifact paths must be a list") + if not isinstance(no_concrete, list): + no_concrete = [] + errors.append("artifact no_concrete_path must be a list") + if artifact.get("catalog_dirty") is not False: + errors.append("artifact catalog_dirty must be false") + if artifact.get("concrete_path_count") != len(rows): + errors.append("artifact concrete_path_count does not match paths") + if artifact.get("high_risk_lane_count") != len(rows) + len(no_concrete): + errors.append("artifact high_risk_lane_count does not match its entries") + + seen_ids: set[str] = set() + unique_paths: set[str] = set() + for row in rows: + if not isinstance(row, dict): + errors.append("artifact path entry must be a mapping") + continue + lane_id = row.get("id") + data_path = row.get("data_path") + metadata_path = row.get("metadata_path") + if not isinstance(lane_id, str) or not lane_id: + errors.append("artifact path entry has no id") + continue + if lane_id in seen_ids: + errors.append(f"duplicate lane id: {lane_id}") + seen_ids.add(lane_id) + if not isinstance(data_path, str) or "/data/" not in data_path: + errors.append(f"{lane_id}: invalid data_path") + continue + expected_metadata = data_path.replace("/data/", "/metadata/", 1) + if metadata_path != expected_metadata: + errors.append(f"{lane_id}: metadata_path does not match data_path") + continue + unique_paths.add(data_path) + if "deny" not in policy.get(data_path, set()): + errors.append(f"{lane_id}: data path is not denied: {data_path}") + if "read" not in policy.get(metadata_path, set()): + errors.append(f"{lane_id}: metadata path is not readable: {metadata_path}") + + return { + "catalog_revision": artifact.get("catalog_revision"), + "high_risk_lanes": artifact.get("high_risk_lane_count"), + "concrete_entries": len(rows), + "unique_concrete_paths": len(unique_paths), + "no_concrete_paths": len(no_concrete), + "errors": errors, + "ok": not errors, + } + + +def comparable_artifact(data: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in data.items() if key != "generated_at"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT) + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + parser.add_argument( + "--upstream", + type=Path, + help="optionally require the vendored artifact to match this upstream copy", + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + try: + artifact = load_artifact(args.artifact) + policy = parse_policy(args.policy.read_text(encoding="utf-8")) + report = check_boundary(artifact, policy) + if args.upstream: + upstream = load_artifact(args.upstream) + if comparable_artifact(artifact) != comparable_artifact(upstream): + report["errors"].append("vendored artifact differs from upstream") + report["ok"] = False + except (OSError, ValueError, yaml.YAMLError) as exc: + report = {"ok": False, "errors": [str(exc)]} + + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + elif report["ok"]: + print( + "PASS: agent boundary covers " + f"{report['concrete_entries']} high-risk catalog entries at " + f"{report['catalog_revision']}" + ) + else: + for error in report["errors"]: + print(f"FAIL: {error}", file=sys.stderr) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_coding_agent_approle.py b/scripts/verify_coding_agent_approle.py new file mode 100644 index 0000000..c19fba4 --- /dev/null +++ b/scripts/verify_coding_agent_approle.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Mint, verify, and revoke a coding-agent AppRole token without printing it.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from typing import Any + + +DEFAULT_ADDR = "https://bao.coulomb.social" +DATA_PATH = "platform/data/workloads/issue-core/issue-core/issue-core-runtime" +METADATA_PATH = ( + "platform/metadata/workloads/issue-core/issue-core/issue-core-runtime" +) +EXPECTED_POLICIES = { + "agent-high-risk-boundary", + "workload-kv-read-issue-core-runtime", +} + + +def bao_json(args: list[str]) -> dict[str, Any]: + completed = subprocess.run( + ["bao", *args], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(completed.stdout) + + +def api_request( + addr: str, + method: str, + path: str, + *, + token: str | None = None, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if token: + headers["X-Vault-Token"] = token + body = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request( + f"{addr.rstrip('/')}/v1/{path}", data=body, headers=headers, method=method + ) + with urllib.request.urlopen(request, timeout=30) as response: + content = response.read() + return json.loads(content) if content else {} + + +def verify(role: str, addr: str) -> dict[str, Any]: + role_payload = bao_json( + ["read", "-format=json", f"auth/approle/role/{role}/role-id"] + ) + role_id = role_payload["data"]["role_id"] + secret_payload = bao_json( + ["write", "-format=json", "-f", f"auth/approle/role/{role}/secret-id"] + ) + secret_id = secret_payload["data"]["secret_id"] + + agent_token: str | None = None + report: dict[str, Any] = {} + try: + login = api_request( + addr, + "POST", + "auth/approle/login", + payload={"role_id": role_id, "secret_id": secret_id}, + ) + agent_token = login["auth"]["client_token"] + lookup = api_request(addr, "GET", "auth/token/lookup-self", token=agent_token) + capabilities = api_request( + addr, + "POST", + "sys/capabilities-self", + token=agent_token, + payload={"paths": [DATA_PATH, METADATA_PATH]}, + ) + policies = set(lookup["data"].get("policies", [])) + ttl = int(lookup["data"].get("ttl", 0)) + data_caps = capabilities.get(DATA_PATH, []) + metadata_caps = capabilities.get(METADATA_PATH, []) + errors: list[str] = [] + if policies != EXPECTED_POLICIES: + errors.append(f"unexpected policies: {sorted(policies)}") + if ttl <= 0 or ttl > 900: + errors.append(f"TTL outside 1..900 seconds: {ttl}") + if data_caps != ["deny"]: + errors.append(f"data capabilities are not deny: {data_caps}") + if "read" not in metadata_caps or "deny" in metadata_caps: + errors.append(f"metadata capabilities are not read-only: {metadata_caps}") + report = { + "role": role, + "policies": sorted(policies), + "ttl": ttl, + "data_capabilities": data_caps, + "metadata_capabilities": metadata_caps, + "deny_wins": data_caps == ["deny"], + "errors": errors, + "ok": not errors, + } + finally: + if agent_token: + try: + api_request(addr, "POST", "auth/token/revoke-self", token=agent_token) + except (OSError, urllib.error.HTTPError): + report.setdefault("errors", []).append("test token self-revocation failed") + report["ok"] = False + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--role", default="coding-agent-railiance-platform") + parser.add_argument("--addr", default=os.environ.get("BAO_ADDR", DEFAULT_ADDR)) + args = parser.parse_args() + try: + report = verify(args.role, args.addr) + except ( + KeyError, + json.JSONDecodeError, + OSError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + urllib.error.HTTPError, + ) as exc: + report = {"role": args.role, "ok": False, "errors": [str(exc)]} + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index 96fd6aa..35b54a0 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import os import shutil import sys @@ -17,6 +18,14 @@ assert SPEC.loader is not None sys.modules[SPEC.name] = credential_change SPEC.loader.exec_module(credential_change) +BOUNDARY_SPEC = importlib.util.spec_from_file_location( + "agent_high_risk_boundary", REPO_DIR / "scripts/agent_high_risk_boundary.py" +) +agent_high_risk_boundary = importlib.util.module_from_spec(BOUNDARY_SPEC) +assert BOUNDARY_SPEC.loader is not None +sys.modules[BOUNDARY_SPEC.name] = agent_high_risk_boundary +BOUNDARY_SPEC.loader.exec_module(agent_high_risk_boundary) + class CredentialChangeTests(unittest.TestCase): def setUp(self) -> None: @@ -155,29 +164,69 @@ class CredentialChangeTests(unittest.TestCase): self.assertEqual(store["spec"]["conditions"][0]["namespaces"], ["core-hub"]) def test_agent_boundary_denies_every_current_concrete_high_risk_catalog_path(self) -> None: - boundary = ( - REPO_DIR / "openbao/policies/agent-high-risk-boundary.hcl" - ).read_text() - data_paths = { - "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets", - "platform/data/workloads/railiance/backup/offsite-lane", - "platform/data/workloads/forgejo/forgejo-admin", - "tenants/data/binky/company-email/imap", - "tenants/data/binky/qonto-api", - "platform/data/workloads/coulomb/whynot-design/npm-publish", - "platform/data/workloads/rapp-qonto/keycape-client", - "platform/data/workloads/agent-harness/forgejo-deploy-key", - "platform/data/workloads/audit-core/senders", - "platform/data/workloads/email-connect/transactional", - "platform/data/workloads/railiance/scaleway/bootstrap", - } - for path in data_paths: - with self.subTest(path=path): - self.assertRegex( - boundary, - rf'path "{path}" \{{\s*capabilities = \["deny"\]', - ) + artifact = agent_high_risk_boundary.load_artifact( + REPO_DIR + / "openbao/policies/inputs/ops-warden-high-risk-data-paths.yaml" + ) + boundary = agent_high_risk_boundary.parse_policy( + (REPO_DIR / "openbao/policies/agent-high-risk-boundary.hcl").read_text() + ) + report = agent_high_risk_boundary.check_boundary(artifact, boundary) + self.assertEqual(report["errors"], []) + self.assertEqual(report["high_risk_lanes"], 19) + self.assertEqual(report["concrete_entries"], 14) + self.assertEqual(report["unique_concrete_paths"], 13) + self.assertEqual(report["no_concrete_paths"], 5) + self.assertEqual(boundary["auth/token/revoke-self"], {"update"}) + def test_coding_agent_jwt_role_is_exact_bound_and_deny_wins(self) -> None: + role = json.loads( + (REPO_DIR / "openbao/auth/coding-agent-jwt-role.json").read_text() + ) + self.assertEqual(role["role_type"], "jwt") + self.assertEqual(role["bound_audiences"], ["codex-railiance-platform"]) + self.assertEqual(role["user_claim"], "sub") + self.assertEqual( + role["bound_claims"], + { + "sub": "service:codex:railiance-platform", + "principal_type": "service", + "tenant": "tenant:coulomb", + "roles": "coding-agent", + }, + ) + self.assertEqual( + set(role["token_policies"]), + { + "agent-high-risk-boundary", + "workload-kv-read-issue-core-runtime", + }, + ) + self.assertTrue(role["token_no_default_policy"]) + self.assertEqual(role["token_ttl"], "15m") + self.assertEqual(role["token_max_ttl"], "15m") + self.assertEqual(role["token_num_uses"], 8) + + def test_coding_agent_approle_is_bounded_and_deny_wins(self) -> None: + role = json.loads( + (REPO_DIR / "openbao/auth/coding-agent-approle.json").read_text() + ) + self.assertTrue(role["bind_secret_id"]) + self.assertEqual(role["secret_id_ttl"], "5m") + self.assertEqual(role["secret_id_num_uses"], 1) + self.assertEqual( + set(role["token_policies"]), + { + "agent-high-risk-boundary", + "workload-kv-read-issue-core-runtime", + }, + ) + self.assertTrue(role["token_no_default_policy"]) + self.assertEqual(role["token_ttl"], "15m") + self.assertEqual(role["token_max_ttl"], "15m") + self.assertEqual(role["token_num_uses"], 8) + + def test_core_hub_database_lanes_are_split_and_exact_scope(self) -> None: database_policy = ( REPO_DIR / "openbao/policies/external-secrets-core-hub-database.hcl" ).read_text() diff --git a/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md b/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md index f35b266..f5be3db 100644 --- a/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md +++ b/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md @@ -4,11 +4,11 @@ type: workplan title: "Close agent high-risk OpenBao boundary coverage" domain: financials repo: railiance-platform -status: blocked +status: finished owner: codex topic_slug: railiance created: "2026-08-21" -updated: "2026-08-21" +updated: "2026-08-22" related: - WARDEN-WP-0032 - RISK-F-0009 @@ -43,9 +43,11 @@ priority: high state_hub_task_id: "44d1a4bf-70bb-4d50-a40a-2158acc96b36" ``` -Run the capabilities-only ops-warden audit against the policy. The 2026-08-21 -reconciliation found 17 high-risk lanes: six covered, six concrete uncovered, -and five without a concrete KV address. No credential value was read. +Run the capabilities-only ops-warden audit against the policy. The initial +2026-08-21 reconciliation found 17 high-risk lanes: six covered, six concrete +uncovered, and five without a concrete KV address. The final generated input +contains 19 high-risk lanes, 14 concrete entries, and five without an address. +No credential value was read. ## T02 — Close the concrete deny gaps @@ -61,9 +63,10 @@ rapp-qonto Keycape client, agent-harness Forgejo deploy key, audit-core senders, email-connect transactional, and Scaleway bootstrap. Add regression coverage for every concrete path currently emitted by the catalog audit. -Completed 2026-08-21. The source policy covers all 12 unique concrete paths -(including the Core Hub path, which has no catalog lane), and the local -catalog audit reports all 12 catalog entries covered with none uncovered. +Completed 2026-08-22. After ops-warden corrected two multi-field grades, the +source policy covers all 13 unique concrete catalog paths plus the additional +reviewed Core Hub path. The generated-input check reports all 14 concrete +catalog entries covered with none uncovered. ## T03 — Apply and verify live @@ -79,13 +82,11 @@ and rerun the catalog audit with `policy_source: server`. Inspect policy/token role metadata to determine whether an agent identity carries the boundary and whether any role combines it with a workload-read policy. Do not mint a token. -Completed 2026-08-21 under attended `platform-admin` OIDC. OpenBao accepted the -policy; normalized readback matched source, and the server-backed catalog audit -reported 17 high-risk lanes, 12 covered entries, zero uncovered, and five -pattern/non-KV lanes without a concrete address. Metadata-only inspection of -all discoverable netkingdom, Kubernetes, AppRole, and token roles found zero -attachments of `agent-high-risk-boundary` and therefore zero roles combining -it with a workload-read policy. No token was minted and no Secret was read. +Completed initially on 2026-08-21 and reconciled again on 2026-08-22 under +attended `platform-admin` OIDC. OpenBao accepted the policy; normalized +readback matched source, and the final server-backed audit reported 19 +high-risk lanes, 14 covered entries, zero uncovered, and five pattern/non-KV +lanes without a concrete address. No Secret value was read. ## T04 — Route the result @@ -105,11 +106,15 @@ Completed 2026-08-21 via State Hub message attachment finding, the distinct-agent-identity blocker, and the requested versioned generated artifact shape. +Ops-warden delivered revision `55f0f47a021375b8b25c924953d1b49a24e002c5` +on 2026-08-21. The platform now vendors it as a checked policy input and has a +repeatable upstream-equality plus policy-coverage command. + ## T05 — Establish a distinct coding-agent identity ```task id: RAILIANCE-WP-0022-T05 -status: wait +status: done priority: high state_hub_task_id: "47aa5ed9-95c5-4a23-a460-e4bbd3ed6f65" ``` @@ -119,12 +124,40 @@ The policy is live but no role attaches it. Do not add the boundary to recovery work may require the protected values. The identity owner must define a distinct coding-agent issuance path, attach this boundary there, and prove deny-wins behavior when combined with an otherwise readable workload policy. -This is blocked on an identity-owner decision and is not invented here. +Completed 2026-08-22. State Hub decision +`f0955252-7b20-4c80-86e9-f8080ec60793` establishes the platform-owned AppRole +`coding-agent-railiance-platform` as the operational machine identity. Its +single-use SecretIDs expire after five minutes; its tokens last at most 15 +minutes, have eight uses, omit the default policy, and attach both +`agent-high-risk-boundary` and the otherwise-readable issue-core workload +policy. A live ephemeral login proved data capabilities resolve to `deny` and +metadata to `read`; the token was revoked without reading the KV value. + +The exact-bound KeyCape JWT role is also live as the issuer-backed migration +target. KeyCape principal registration remains an identity hardening follow-up, +not a blocker to the active AppRole boundary. + +## T06 — Consume the generated catalog artifact + +```task +id: RAILIANCE-WP-0022-T06 +status: done +priority: high +``` + +Consume ops-warden's versioned `high-risk-data-paths.yaml` as a policy-check +input, add the two newly regraded issue-core and reuse-surface paths, and rerun +source plus live coverage. The input remains ops-warden's risk statement, not +the policy authority; railiance-platform continues to own every deny decision. + +Completed 2026-08-22. The vendored input matches the clean upstream artifact, +the repository check covers all 14 entries, and the server-backed audit reports +zero uncovered. Evidence: `docs/evidence/agent-high-risk-boundary-2026-08-21.md`. ## Acceptance - [x] Every concrete high-risk catalog path is denied in the source policy. - [x] The live policy matches source and the server-backed audit passes. -- [x] Agent boundary attachment is established from metadata (currently zero). +- [x] A distinct AppRole attachment is established from live metadata. - [x] Result and generated-artifact follow-up are routed to ops-warden. -- [ ] A distinct coding-agent identity actually attaches the boundary. +- [x] A distinct coding-agent identity actually attaches the boundary.