diff --git a/AGENTS.md b/AGENTS.md index 6d35f6a..709928e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,21 +261,47 @@ it as `BAO_TOKEN=$(cat ~/.claude-bao-token) bao `. ### What the grant authorises -Policy `ops-mason-build` (`policies/ops-mason-build.hcl`), 45 minutes, max one -hour, named `claude-`: +Policy `ops-mason-survey` (`policies/ops-mason-survey.hcl`), 45 minutes, max +one hour, named `claude-`. Read-only, by founder decision 2026-10-07 +(MASON-IN-0004, option (c)+(a)); it replaces `ops-mason-build`, now in +`policies/retired/` and never to be installed. -| Allowed | Denied | +| Allowed | Denied / not granted | |---|---| -| read `sys/mounts`, `sys/auth`, policy list — the phase-2 survey | **every read of `*/data/*`** on `platform`, `operators`, `secret` | -| create/update policies under `sys/policies/acl/*` | enabling or tuning mounts (`sys/mounts/*`) — a railiance-platform act | -| create/update `auth/kubernetes/role/*`, `auth/approle/role/*` | anything not listed | -| read KV **metadata** — versions and timestamps, enough to confirm a delivery landed | KV **values** | -| mint and revoke short-lived test tokens, check capabilities | | +| read/list `sys/mounts`, `sys/auth`; list `sys/policies/acl`, read `sys/policies/acl/*` — the phase-2 survey | **any policy write** (no create/update/delete on `sys/policies/acl/*`; explicit deny on its own policy) | +| read/list role definitions on `auth/kubernetes/role/*`, `auth/netkingdom/role/*`, `auth/jwt/role/*`; list `auth/approle/role`, read `auth/approle/role/` | **any auth-role write**; AppRole `role-id`, `secret-id`, `secret-id/*`, `secret-id-accessor/*`, `custom-secret-id` (explicit deny) | +| read/list KV **metadata** on `platform/`, `operators/` — enough to confirm a delivery landed | **every `*/data/*`** on `platform`, `operators`, `secret` (explicit deny); KV metadata writes | +| `sys/capabilities`, `sys/capabilities-self` (query only), `auth/token/lookup-self`, `auth/token/revoke-self` | **token creation of any kind**; revoking other tokens; `sys/mounts/*` | -The denial is the point. `SCOPE.md` says ops-mason never touches secret values; -this makes OpenBao enforce it rather than leaving it to whoever is driving. An -explicit `deny` outranks any grant, including one added to this policy later by -mistake. +The grant can show what exists and prove what a token can reach; it cannot +change anything. That removes the escalation MASON-IN-0004 described — write a +policy without the denies, bind it to an AppRole, read the role-id, mint a +secret-id, log in — because none of those steps is granted. + +### Installing the survey policy (founder act) + +ops-mason cannot install its own grant. The founder runs, silently and with an +absolute path (the warden wrapper refuses output and runs with a temporary +`HOME`): + +```bash +warden access openbao-platform-admin-login --exec -- sh -c \ + 'bao policy write ops-mason-survey /home/worsch/ops-mason/policies/ops-mason-survey.hcl >/dev/null 2>&1' +``` + +Silent check — the installed policy's hash into a 0600 receipt, compared with +the repo file's hash outside the wrapper: + +```bash +warden access openbao-platform-admin-login --exec -- sh -c \ + 'umask 077; mkdir -p /home/worsch/.local/state/railiance/receipts; bao policy read ops-mason-survey 2>/dev/null | sha256sum > /home/worsch/.local/state/railiance/receipts/ops-mason-survey.sha256' +cat /home/worsch/.local/state/railiance/receipts/ops-mason-survey.sha256 +sha256sum < /home/worsch/ops-mason/policies/ops-mason-survey.hcl +``` + +The two hashes match only if OpenBao returns the file byte-for-byte; if they +differ, compare the stored text against the file before concluding the install +is wrong (OpenBao may normalise a trailing newline). ### Choosing the task name @@ -286,8 +312,13 @@ recognise later. ### When a broader grant is genuinely needed -Enabling a mount, reading a value, or touching another subsystem's paths is out -of `ops-mason-build` on purpose. Do not widen the policy to get past a refusal. -Either the act belongs to another repo, or it needs its own named policy and its -own decision — the way `operators/` did. +Writing a policy or auth role, minting a secret-id or a token, enabling a +mount, reading a value, or touching another subsystem's paths is out of +`ops-mason-survey` on purpose. Do not widen the policy to get past a refusal. +Builds that need writes are applied by the founder through the +railiance-platform delegated applier (`scripts/credential-change.py +applier-apply` under `warden access openbao-platform-admin-login --exec`), as +for CCR-2026-0033/0034; ops-mason supplies the plan and verifies afterwards. +Anything else belongs to another repo or needs its own named policy and its +own founder decision. diff --git a/INTENT.md b/INTENT.md index 294cf0a..edbd6e7 100644 --- a/INTENT.md +++ b/INTENT.md @@ -78,8 +78,11 @@ tooling_contacts: capability: "Create policies and auth roles for an approved construction plan (structure only, never secret values)" intended_owner: secrets-engine blocked_on: >- - No engine exposes a policy / auth-role construction surface; ops-mason - runs the bao CLI under a scoped, time-boxed ops-mason-build token. + No engine exposes a policy / auth-role construction surface. Since + MASON-IN-0004 (founder, 2026-10-07) ops-mason holds no build grant: + these writes are applied by the founder through the railiance-platform + delegated applier; ops-mason surveys and verifies under the read-only + ops-mason-survey token. review: "2026-12-21" gap_state: accepted accepted_exception: *plan-approval-gate @@ -164,7 +167,7 @@ tooling_contacts: tooling: OpenBao operation: "Operator OIDC login; mint a scoped, bounded token under the operator's own session" write: true - capability: "Hand an agent session a task-scoped ops-mason-build token instead of the operator's session" + capability: "Hand an agent session a task-scoped, read-only ops-mason-survey token instead of the operator's session" supplied_authority: none note: >- Runs under the operator's own authority and narrows it; ops-mason holds diff --git a/docs/construction-plan-format.md b/docs/construction-plan-format.md index 1fe1dc5..4a254aa 100644 --- a/docs/construction-plan-format.md +++ b/docs/construction-plan-format.md @@ -64,7 +64,7 @@ Generate the section rather than gathering it by hand (MASON-WP-0008). The founder mints the scoped grant; the agent then runs the survey with it: ```bash -./scripts/bao-session.sh grant # founder: ops-mason-build, 45m, claude- +./scripts/bao-session.sh grant # founder: ops-mason-survey (read-only), 45m, claude- ./scripts/plan-survey.py \ --kv-path platform/workloads// \ --policy workload-kv-read-- \ @@ -83,10 +83,10 @@ matching ops-warden catalog entries, a UTC timestamp, `BAO_ADDR`, the token draft answer into a decision; it is generated evidence, not a review. The survey refuses to run without a grant, refuses a token whose policies -are anything but `ops-mason-build` (plus `default`), and never prints the +are anything but `ops-mason-survey` (plus `default`), and never prints the token. Every bao call passes an allowlist that rejects `kv get` and any `*/data/*` path before a process starts. A query the grant does not permit -is reported as `not permitted by ops-mason-build` and listed under +is reported as `not permitted by ops-mason-survey` and listed under "incomplete" rather than read as an empty answer. Policies with names that do not match the demand are not read, so a broadly named policy covering the path is not detected; the output says so. diff --git a/intakes/intakes.md b/intakes/intakes.md index 30b3410..b826334 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -103,7 +103,8 @@ state_hub_intake_id: "01a06e68-8c29-7230-b0dc-88f1e374afa4" id: MASON-IN-0004 kind: intake title: Close the policy-plus-AppRole escalation path in ops-mason-build -status: open +status: closed +outcome: resolved origin: review origin_ref: inbound-library ILIB-WP-0003 (finding raised 2026-10-05 while preparing the first grant) priority: high @@ -152,5 +153,23 @@ notes: >- A survey needs only (c)'s read-only grant. Today MASON-WP-0008 plan-survey runs under the full build grant, so separating them also reduces what a survey session can do. +resolution: >- + Founder decision (Bernd Worsch, 2026-10-07, given in the key-cape session): + (c)+(a), a read-only grant only. ops-mason gets the read-only + ops-mason-survey policy (policies/ops-mason-survey.hcl: read/list on mounts, + auth methods, policies, auth role definitions and KV metadata; capability + queries; lookup-self and revoke-self; explicit denies on */data/*, its own + policy, and AppRole role-id / secret-id / secret-id-accessor / + custom-secret-id). No policy writes, no auth-role writes, no secret-id + minting, no token creation. Those acts go through the founder-attended + railiance-platform delegated applier (scripts/credential-change.py + applier-apply under warden access openbao-platform-admin-login --exec), as + for CCR-2026-0033/0034 on 2026-10-07. ops-mason-build moved to + policies/retired/ with a do-not-install header; it was not present in + OpenBao at the time. bao-session.sh, plan-survey.py, custody-inventory.py, + tests, AGENTS.md, INTENT.md and docs/construction-plan-format.md switched to + ops-mason-survey. Installing the policy is a founder act (AGENTS.md, + "Installing the survey policy"). Commit: COMMIT_PENDING. +updated: '2026-10-07T00:00:00Z' state_hub_intake_id: "01a11118-1f35-7ad5-ad51-65dd785c932f" ``` diff --git a/policies/ops-mason-survey.hcl b/policies/ops-mason-survey.hcl new file mode 100644 index 0000000..fcf1769 --- /dev/null +++ b/policies/ops-mason-survey.hcl @@ -0,0 +1,150 @@ +# ops-mason-survey — read-only survey and verification. Nothing else. +# +# Founder decision 2026-10-07 under MASON-IN-0004, option (c)+(a): ops-mason +# designs, surveys and verifies; it does not build. Policy writes, auth-role +# writes, AppRole secret-id minting and token creation are applied by the +# founder through the railiance-platform delegated applier +# (scripts/credential-change.py applier-apply under +# `warden access openbao-platform-admin-login --exec`). This replaces +# ops-mason-build (policies/retired/), whose policy-write + AppRole + token +# authority could be chained into a token carrying none of its denies. +# +# There is no write capability anywhere in this file except the two +# query-shaped endpoints (sys/capabilities*, revoke-self) that grant nothing. +# Granted through scripts/bao-session.sh as a 45-minute, named token. +# +# Path priority note: OpenBao uses the single most specific matching path +# within a policy, and a path with a `+` segment loses to one without. A deny +# on `auth/approle/role/+/secret-id/*` would therefore LOSE to a broad +# `auth/approle/role/*` grant. So AppRole is granted narrowly (list the roles, +# read `role/` only) and the sub-endpoints are never granted at all; the +# explicit denies below are a second line, not the only one. + +# --- survey: mounts, auth methods, policies --------------------------------- +# Phase 2 of the construction process. `bao secrets list` / `bao auth list`. +path "sys/mounts" { + capabilities = ["read", "list"] +} + +path "sys/auth" { + capabilities = ["read", "list"] +} + +# `bao policy list`. +path "sys/policies/acl" { + capabilities = ["list"] +} + +# `bao policy read ` — policy text, never a secret value. read only: +# no create/update/delete, so this grant cannot write or remove any policy. +path "sys/policies/acl/*" { + capabilities = ["read"] +} + +# --- survey: auth role definitions (read only) ------------------------------ +# Kubernetes roles hold bindings and policy names, no credential material. +path "auth/kubernetes/role/*" { + capabilities = ["read", "list"] +} + +# AppRole: list role names, read a role's definition. Deliberately not +# `auth/approle/role/*` — see the priority note above. +path "auth/approle/role" { + capabilities = ["list"] +} + +path "auth/approle/role/+" { + capabilities = ["read"] +} + +# OIDC/JWT roles used in this estate (netkingdom OIDC mount, jwt mount): +# bound claims, redirect URIs and policy names, no credential material. +path "auth/netkingdom/role/*" { + capabilities = ["read", "list"] +} + +path "auth/jwt/role/*" { + capabilities = ["read", "list"] +} + +# --- survey: KV structure, never KV values ---------------------------------- +# Metadata carries versions, timestamps and custom_metadata — enough to confirm +# a path exists and that a paste-once delivery landed. Read/list only: this +# grant cannot describe, create or delete a path. +path "platform/metadata/*" { + capabilities = ["read", "list"] +} + +path "operators/metadata/*" { + capabilities = ["read", "list"] +} + +# --- verification ----------------------------------------------------------- +# `bao token capabilities ` asks what a token can reach. The endpoints +# take a POST, hence update; they answer a question and grant nothing. +path "sys/capabilities" { + capabilities = ["update"] +} + +path "sys/capabilities-self" { + capabilities = ["update"] +} + +# The survey checks its own token's policies before running. +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +# End the grant early when the task is done. +path "auth/token/revoke-self" { + capabilities = ["update"] +} + +# Not granted, on purpose: auth/token/create (no token minting of any kind), +# auth/token/revoke (other tokens), any auth/*/role write, sys/mounts/*. + +# --- the grant cannot read or rewrite its own scope ------------------------- +# It has no policy write at all, so this is belt and braces: if a write on +# sys/policies/acl/* is ever added here by mistake, this exact path still +# outranks the glob and the grant cannot edit its own denials. +path "sys/policies/acl/ops-mason-survey" { + capabilities = ["deny"] +} + +# --- the AppRole escalation, stated as denials ------------------------------ +# Reading a role-id or minting a secret-id is exactly how a role becomes a +# login. None of these is granted above; deny makes that explicit and also +# overrides any other policy attached to the same token. +path "auth/approle/role/+/role-id" { + capabilities = ["deny"] +} + +path "auth/approle/role/+/secret-id" { + capabilities = ["deny"] +} + +path "auth/approle/role/+/secret-id/*" { + capabilities = ["deny"] +} + +path "auth/approle/role/+/secret-id-accessor/*" { + capabilities = ["deny"] +} + +path "auth/approle/role/+/custom-secret-id" { + capabilities = ["deny"] +} + +# --- the line on secret values, stated as a denial -------------------------- +# Explicit deny outranks any grant, including one added here later by mistake. +path "platform/data/*" { + capabilities = ["deny"] +} + +path "operators/data/*" { + capabilities = ["deny"] +} + +path "secret/data/*" { + capabilities = ["deny"] +} diff --git a/policies/ops-mason-build.hcl b/policies/retired/ops-mason-build.hcl similarity index 82% rename from policies/ops-mason-build.hcl rename to policies/retired/ops-mason-build.hcl index 9c1a55a..a8cdfc4 100644 --- a/policies/ops-mason-build.hcl +++ b/policies/retired/ops-mason-build.hcl @@ -1,3 +1,19 @@ +# RETIRED — DO NOT INSTALL. +# +# Retired 2026-10-07 by founder decision (Bernd Worsch) under MASON-IN-0004, +# option (c)+(a): ops-mason holds only the read-only ops-mason-survey grant +# (policies/ops-mason-survey.hcl). This policy could write ACL policies and +# AppRole roles and mint tokens; together those let a holder create a policy +# without its denies, bind it to an AppRole, read the role-id, mint a secret-id +# and log in as a token that carries none of the denies below. Policy writes, +# auth-role writes, secret-id minting and token creation now go through the +# founder-attended railiance-platform delegated applier +# (scripts/credential-change.py applier-apply under +# `warden access openbao-platform-admin-login --exec`). +# +# Kept for the record only. It was never present in OpenBao at retirement +# (bao-session.sh grant reported "does not exist yet" on 2026-10-07). +# # ops-mason-build — the authority ops-mason actually needs, and no more. # # SCOPE.md says ops-mason "never touches secret values, even transiently". Until diff --git a/scripts/bao-session.sh b/scripts/bao-session.sh index 53fab34..8a79a90 100755 --- a/scripts/bao-session.sh +++ b/scripts/bao-session.sh @@ -12,10 +12,12 @@ # for as long as you have it, indistinguishable from you in the audit log. The # grant path instead mints a separate token that is: # -# * scoped — carries the ops-mason-build policy, which can create policies, -# auth roles and KV *structure* but cannot read secret values. -# SCOPE.md's "never touch secret values" stops being a promise in -# a document and becomes something OpenBao enforces. +# * scoped — carries the read-only ops-mason-survey policy: it can list +# and read mounts, auth methods, policies, auth role definitions +# and KV *metadata*, and query capabilities. It cannot write a +# policy or role, mint a secret-id or a token, or read a secret +# value (MASON-IN-0004, founder decision 2026-10-07). Builds go +# through the founder-attended railiance-platform applier. # * bounded — 45 minutes, no renewal past one hour. # * named — display_name claude-, so the audit log says which task an # action belonged to, not just that platform-root did it. @@ -30,7 +32,7 @@ set -euo pipefail export BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:18200}" GRANT_FILE="${GRANT_FILE:-$HOME/.claude-bao-token}" -GRANT_POLICY="${GRANT_POLICY:-ops-mason-build}" +GRANT_POLICY="${GRANT_POLICY:-ops-mason-survey}" GRANT_TTL="${GRANT_TTL:-45m}" GRANT_MAX_TTL="${GRANT_MAX_TTL:-1h}" OIDC_PATH="${OIDC_PATH:-netkingdom}" diff --git a/scripts/custody-inventory.py b/scripts/custody-inventory.py index 961b719..a1fdeb0 100755 --- a/scripts/custody-inventory.py +++ b/scripts/custody-inventory.py @@ -5,7 +5,7 @@ ./scripts/custody-inventory.py operators one mount or prefix ./scripts/custody-inventory.py --undescribed only paths missing metadata -Reads metadata only — never a value — so it runs under ops-mason-build and can +Reads metadata only — never a value — so it runs under ops-mason-survey and can be handed to anyone orienting themselves without granting them a single secret. A path with no description is a finding, not a formatting problem: it is a diff --git a/scripts/plan-survey.py b/scripts/plan-survey.py index 5b38843..5d68a8a 100755 --- a/scripts/plan-survey.py +++ b/scripts/plan-survey.py @@ -12,7 +12,7 @@ auth-role listings, KV metadata existence for the target path, matching ops-warden routing catalog entries, and a draft answer to "does an existing lane already satisfy this demand?". -Metadata only. It runs under the ops-mason-build grant (~/.claude-bao-token), +Metadata only. It runs under the ops-mason-survey grant (~/.claude-bao-token), refuses any other token, and every bao invocation passes `allowed_bao_call`, an allowlist that rejects `kv get` and every `*/data/*` path before a process starts. Policy bodies are read with `bao policy read` (policy text, never a secret value) @@ -33,11 +33,11 @@ from pathlib import Path from typing import Callable GRANT_FILE = "~/.claude-bao-token" -GRANT_POLICY = "ops-mason-build" +GRANT_POLICY = "ops-mason-survey" DEFAULT_ADDR = "http://127.0.0.1:18200" DEFAULT_CATALOG = "~/ops-warden/registry/routing/catalog.yaml" MAX_POLICY_READS = 15 -NOT_PERMITTED = "not permitted by ops-mason-build" +NOT_PERMITTED = "not permitted by ops-mason-survey" # Path segments too generic to identify a consumer. GENERIC = {"platform", "workloads", "operators", "secret", "data", "metadata", "kv", "read", "workload", "role", "token", "auth", "policy"} @@ -133,7 +133,7 @@ def grant_env(environ: dict | None = None) -> dict: with open(grant) as f: env["BAO_TOKEN"] = f.read().strip() if not env.get("BAO_TOKEN"): - raise SurveyError("no ops-mason-build grant (~/.claude-bao-token absent and BAO_TOKEN unset); " + raise SurveyError("no ops-mason-survey grant (~/.claude-bao-token absent and BAO_TOKEN unset); " "ask the founder to run ./scripts/bao-session.sh grant ") return env diff --git a/tests/test_plan_survey.py b/tests/test_plan_survey.py index f1d168f..3dbcb05 100644 --- a/tests/test_plan_survey.py +++ b/tests/test_plan_survey.py @@ -43,7 +43,7 @@ path "platform/metadata/workloads/inbound-library/imap" { ''' -def lookup(policies=("default", "ops-mason-build")): +def lookup(policies=("default", "ops-mason-survey")): return {"data": {"id": TOKEN, "accessor": "acc-SECRET", "display_name": "token-claude-mason-wp-0008", "policies": list(policies), "ttl": 2000}} @@ -59,7 +59,7 @@ class FakeBao: "sys/": {"type": "system"}}, ("auth", "list"): {"approle/": {"type": "approle"}, "kubernetes/": {"type": "kubernetes"}, "token/": {"type": "token"}}, - ("policy", "list"): ["default", "root", "ops-mason-build", "workload-kv-read-inbound-library-imap", + ("policy", "list"): ["default", "root", "ops-mason-survey", "workload-kv-read-inbound-library-imap", "workload-kv-read-other"], ("list", "auth/approle/role"): ["inbound-library", "rein-openweights"], ("list", "auth/kubernetes/role"): ["external-secrets-activity-core"], @@ -149,20 +149,20 @@ def test_missing_capability_degrades_and_is_reported(catalog, capsys): ("policy", "read", "workload-kv-read-inbound-library-imap")}) rc, out = run(fake, catalog, capsys) assert rc == 0 - assert "not permitted by ops-mason-build" in out + assert "not permitted by ops-mason-survey" in out assert "incomplete:" in out def test_no_grant_fails_loudly(monkeypatch, tmp_path): monkeypatch.setattr(survey.os.path, "expanduser", lambda p: str(tmp_path / "absent")) - with pytest.raises(survey.SurveyError, match="no ops-mason-build grant"): + with pytest.raises(survey.SurveyError, match="no ops-mason-survey grant"): survey.main(ARGS, runner=FakeBao(), environ={}) -@pytest.mark.parametrize("policies", [("root",), ("default", "ops-mason-build", "platform-admin"), ("default",)]) +@pytest.mark.parametrize("policies", [("root",), ("default", "ops-mason-survey", "platform-admin"), ("default",)]) def test_non_grant_token_is_refused(catalog, policies): fake = FakeBao(table={("token", "lookup"): lookup(policies)}) - with pytest.raises(survey.SurveyError, match="not an ops-mason-build grant") as exc: + with pytest.raises(survey.SurveyError, match="not an ops-mason-survey grant") as exc: survey.main([*ARGS, "--catalog", str(catalog)], runner=fake, environ={"BAO_TOKEN": TOKEN}) assert TOKEN not in str(exc.value) assert len(fake.calls) == 1 @@ -182,7 +182,7 @@ def test_script_exits_nonzero_without_grant(tmp_path): p = subprocess.run([sys.executable, str(Path(__file__).parents[1] / "scripts/plan-survey.py"), *ARGS], capture_output=True, text=True, env=env) assert p.returncode == 2 - assert "ERROR: no ops-mason-build grant" in p.stderr + assert "ERROR: no ops-mason-survey grant" in p.stderr @pytest.mark.parametrize("args", [