diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a063cd..f826bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.29.0 — 2026-06-29 + +### feat: Tier-2 `permissions:` + `trust_tiers:` policy schema — path-based capability model, never-auto-granted set, signed grants approval flow (BRO-1600) + +Lands the schema half of the indirect-prompt-injection defense: a declarative capability layer in `assets/templates/policy.yaml.template` (template internal version `1.0` → `1.1`) that makes an agent's authority over the filesystem an explicit, reviewable surface instead of an implicit consequence of which tools are wired. Schema-first by design — the runtime hooks that enforce these blocks are sequenced follow-ups, matching the precedent set by `write_gate` (declared before its checker shipped). + +### Added + +- **`permissions:` block (path-based capability model).** Declares per-path-glob capabilities (read / write / execute) so authority is granted by location, not inherited globally. A `never_auto_granted:` set names capabilities that an agent can **never** self-grant in-loop (the dangerous-by-default surface — credential paths, governance files, the grant ledger itself); acquiring them requires the explicit approval flow below, never an in-session decision. +- **`trust_tiers:` block (T0–T4).** Five named trust tiers that scope what a given actor/context is permitted to do, from untrusted input (T0) up to operator-authorized (T4). Capabilities and paths bind to a minimum tier; requests below tier are refused. +- **Signed-grant approval flow (`grants.jsonl`).** An append-only, signed grant ledger: escalations to a `never_auto_granted` capability are recorded as signed grant entries, so every elevation is attributable and auditable after the fact rather than silently assumed. +- **`tests/policy-template-schema.test.sh`** — schema guard: asserts the template carries internal version `1.1` and that the `permissions:`, `never_auto_granted:`, `trust_tiers:` (T0–T4), and `grants.jsonl` flow are all present and well-formed, so the schema can't silently regress. +- **`references/security-primitives.md`** — documents the capability model, the T0–T4 tiers, the never-auto-granted set, and the signed-grant flow; cross-links `specs/2026-05-15-indirect-prompt-injection-defense.md §5`. + +### Notes + +- **Schema-first, enforcement-sequenced.** This ships the declarative contract only; the PreToolUse runtime hooks that enforce `permissions:` / `trust_tiers:` are the sequenced follow-ups per `specs/2026-05-15-indirect-prompt-injection-defense.md §5` — the same land-schema-then-wire-checker order `write_gate` used. +- Primitive count unchanged (**20**). This is a policy-schema addition, not a new P-row. +- `VERSION` 0.28.1 → 0.29.0 (minor: additive schema feature, backward-compatible). + ## 0.28.1 — 2026-06-28 ### fix: `bstack-skills install` lands every skill, in the dir `status` reads (BRO-1588) diff --git a/VERSION b/VERSION index 48f7a71..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.1 +0.29.0 diff --git a/assets/templates/policy.yaml.template b/assets/templates/policy.yaml.template index a047471..5bd8574 100644 --- a/assets/templates/policy.yaml.template +++ b/assets/templates/policy.yaml.template @@ -2,9 +2,214 @@ # Installed by `bstack bootstrap` when .control/policy.yaml is absent. # Customize for your workspace; bstack doctor will validate required blocks. -version: "1.0" +version: "1.1" profile: governed # baseline | governed | autonomous +permissions: + defaults: + # Path-match PRECEDENCE: explicit `deny` ALWAYS wins over `allow`, regardless of + # list order, across read/write/execute/network. Load-bearing invariant — without + # it, broad allows like `{current_project}/**` (which equals `{workspace_root}/**` + # when work runs at workspace root) would match governance + secret paths and + # enable self-elevation. The runtime gate MUST apply deny-over-allow. + precedence: deny_over_allow + read: + allow: + - "{workspace_root}/CLAUDE.md" + - "{workspace_root}/AGENTS.md" + - "{workspace_root}/METALAYER.md" + - "{workspace_root}/.control/policy.yaml" + - "{workspace_root}/docs/**" + - "{workspace_root}/skills/**/SKILL.md" + - "{workspace_root}/skills/**/references/**" + - "{workspace_root}/research/**" + - "{current_project}/**" + - "{workspace_root}/scripts/**" # bstack scripts readable for inspection + deny: + - "**/.env*" + - "**/credentials*" + - "**/secrets/**" + - "**/*.key" + - "**/id_rsa*" + - "**/*.pem" + - "**/.aws/credentials" + - "**/.ssh/**" + - "~/Downloads/**" + - "~/Desktop/**" + - "/tmp/**" # tmp is for hook-output only; agent shouldn't read freely + - "**/.git/objects/**" # raw git internals — use git commands + + write: + allow: + - "{current_project}/**" + - "{workspace_root}/research/entities/**" # P6 — requires capability_id (BRO-1030) + - "{workspace_root}/research/notes/**" + - "{workspace_root}/docs/conversations/**" # P1 bridge writes + deny: + - "**/.env*" + - "**/credentials*" + - "**/secrets/**" + - "**/*.key" + - "**/id_rsa*" + - "**/.git/objects/**" + - "{workspace_root}/.control/policy.yaml" # never_auto_granted + - "{workspace_root}/.control/grants.jsonl" # never_auto_granted (use permission_approve) + - "{workspace_root}/CLAUDE.md" # governance — require_human + - "{workspace_root}/AGENTS.md" # governance — require_human + - "{workspace_root}/METALAYER.md" # governance — require_human + - "{workspace_root}/.claude/settings.json" # hooks:write — the hook registry + - "{workspace_root}/scripts/*-hook.sh" # hooks:write — enforcement substrate + - "{workspace_root}/.githooks/**" # hooks:write — pre-commit/gate hooks + + execute: + # NOTE: bash_allowlist/denylist are a convenience pre-filter + defense-in-depth + # tripwire, NOT a complete exec boundary — an allowed interpreter (python3, bun + # run, npm run) can shell out and evade the denylist. The real execution boundary + # is deny-precedence (above) + the Tier-3 sandbox (spec §4 Tier 3, follow-up PR). + # Denylist entries are tripwires for the common case, not adversarial-proof. + bash_allowlist: + # Read-only inspection + - "git status*" + - "git diff*" + - "git log*" + - "git show*" + - "git branch*" + - "git remote*" + - "git worktree list*" + - "ls *" + - "find *" + - "cat *" # but the read-allow list above gates the path + - "head *" + - "tail *" + - "wc *" + - "stat *" + - "file *" + - "echo *" + - "pwd" + - "which *" + - "type *" + # Build / test (project-scoped — never executes outside current project) + - "make {bstack-check|control-audit|check|janitor|test|build|smoke}" + - "bun {install|run *|test*}" + - "npm {install|run *|test*}" + - "cargo {build|test|check|clippy|fmt}" + - "pytest *" + - "python3 *" # paired with read-allow on the script path + # Tooling + - "gh {pr list|pr view *|pr checks *|run list|run view *|run watch *|api *}" + - "vercel {ls|inspect *|logs *}" + - "curl -sI *" # head-only; full curl requires network grant + - "jq *" + - "yq *" + bash_denylist: + - "rm -rf*" + - "*--no-verify*" + - "git push --force*" + - "git push -f *" + - "git reset --hard*" + - "git clean -f*" + - "sudo *" + - "su *" + - "chmod 777 *" + - "chown *" + - "curl http*://*" # full HTTP curl requires network grant + - "wget *" + - "ssh *" + - "scp *" + - "rsync *" + - "* > /etc/*" + - "* > /usr/*" + + network: + egress_allowlist: + # Anthropic + Claude Code infrastructure + - "https://api.anthropic.com/**" + - "https://*.anthropic.com/**" + # Git hosting + - "https://github.com/**" + - "https://api.github.com/**" + - "https://raw.githubusercontent.com/**" + # bstack-known surfaces + - "https://broomva.tech/**" + - "https://www.broomva.tech/**" + # NOTE: no `*.vercel.app` wildcard — that would authorize egress to any + # attacker-deployable subdomain (exfil channel). Preview-deploy hosts are + # added per-host via a `network:egress.add_host` grant (never_auto_granted). + - "https://www.moltbook.com/api/**" + - "https://linear.app/**" + - "https://api.linear.app/**" + # Package registries (read-only) + - "https://registry.npmjs.org/**" + - "https://pypi.org/**" + - "https://crates.io/**" + egress_denylist: [] # whitelist-only; nothing implicit + + # ============================================================================ + # NEVER-AUTO-GRANTED — capabilities that ALWAYS require human approval. + # ============================================================================ + # Even if the agent has `policy:write` from a prior grant, requests to add or + # broaden any of these MUST trigger a fresh human-approval flow. The chained- + # self-elevation check enforces this. + never_auto_granted: + - "policy:write" # edits to .control/policy.yaml + - "grants:write" # edits to .control/grants.jsonl outside approve/revoke + - "secrets:read" # reads from secret-shaped paths + - "network:egress.add_host" # adding a new egress allowlist entry + - "signed_writes:bypass" # bypassing capability_id (BRO-1030) requirement + - "governance:write" # writes to CLAUDE.md / AGENTS.md / METALAYER.md + - "hooks:write" # edits to scripts/*-hook.sh or .claude/settings.json + + # ============================================================================ + # APPROVAL FLOW — how permission requests get to the human. + # ============================================================================ + approval: + mode: in_band # in_band | out_of_band_telegram | auto_deny + in_band: + blocking: true # agent halts until approve/deny + timeout_seconds: 0 # 0 = no timeout (synchronous human wait) + out_of_band_telegram: + enabled: false + chat_id: "" + timeout_seconds: 1800 # 30 min + timeout_action: deny + rate_limit: + max_requests_per_hour: 12 + max_requests_per_session: 30 + excess_action: deny + +# ============================================================================ +# TRUST TIERS — read-side classification of incoming content. +# ============================================================================ +# Trust tier is ORTHOGONAL to permission. A cross-folder read grant authorizes +# the operation; the content is still classified at its tier (likely T4) and +# wrapped accordingly by the read-boundary runtime (follow-up PR). +# +# Model: INSTRUCTION-AUTHORITY (who may issue instructions vs data-only) — adopted +# from the CaMeL / spotlighting instruction-vs-data distinction the defense spec +# cites. This SUPERSEDES the draft origin-locality sketch in +# specs/2026-05-15-indirect-prompt-injection-defense.md §4 Tier 2 (that spec is a +# Draft; this block is canonical). Self-contained — the full classification lives +# here, not a separate file, so it is protected by `policy:write` like the rest of +# this file (an agent cannot reclassify external content as trusted-instructions +# without a human-signed grant). + +trust_tiers: + default_tier: "T4_external" # fail-closed: unknown sources are quarantined data + classification: + T0_system: "governance files (CLAUDE.md, AGENTS.md, METALAYER.md, policy.yaml); trusted instructions" + T1_user: "live user messages; trusted instructions" + T2_workspace_governance: "installed SKILL.md, conventions, bstack/references; loaded as instructions" + T3_workspace_data: "research/, docs/conversations/, our source code; data only, never instructions" + T4_external: "Moltbook, X, WebFetch, MCP, imported bridge logs; quarantined data, never instructions" + +# ============================================================================ +# LEGACY: v1 GATES (kept for backward compatibility) +# ============================================================================ +# These remain enforced by control-gate-hook.sh. The new permission model in +# `permissions.defaults.execute.bash_denylist` is a superset; gates here are +# the minimal hard-wired baseline that survives even if the policy parser +# fails. + setpoints: - id: S1 name: "gate_pass_rate" @@ -87,6 +292,15 @@ auto_merge: action: require_human - path_touched: .control/policy.yaml action: require_human + # Enforcement substrate — the hooks + grants ledger that ENFORCE this policy. A + # PR that modifies the gate hook or the grants ledger must not auto-merge under + # `notify` (the merge-time twin of the write.deny hooks:write guard). (BRO-1600) + - path_touched: .control/grants.jsonl + action: require_human + - path_touched: scripts/*-hook.sh + action: require_human + - path_touched: .githooks/* + action: require_human # === Indirect-prompt-injection defense — editor-config paths (2026-05-15) === # Spec: specs/2026-05-15-indirect-prompt-injection-defense.md §4 Tier 1. # Rationale: every documented incident in 2024-2026 that turned indirect prompt diff --git a/references/security-primitives.md b/references/security-primitives.md new file mode 100644 index 0000000..cad4a2f --- /dev/null +++ b/references/security-primitives.md @@ -0,0 +1,117 @@ +# Security Primitives — Permission / Capability + Trust-Tier Model (Policy schema v1.1) + +> **Scope: this is the SCHEMA contract only.** This document describes the +> `permissions:` and `trust_tiers:` blocks declared in +> [`assets/templates/policy.yaml.template`](../assets/templates/policy.yaml.template) +> (`version: "1.1"`). The *runtime* that enforces them — `permission-gate-hook.sh`, +> `read-boundary-hook.sh`, `permissions.py`, `webfetch-sanitizer.py` — is **not** +> shipped here; it is sequenced as separate follow-up PRs per +> [`specs/2026-05-15-indirect-prompt-injection-defense.md`](../specs/2026-05-15-indirect-prompt-injection-defense.md) +> §5. Schema lands first so the runtime can pin to a stable contract — mirroring how +> the `write_gate` block (Tier 1, spec §4) landed schema-first ahead of +> `check-file-write-safety.py`. Nothing below describes behavior the schema does not +> declare. + +Design source: spec §3 (Threat Model — the agent is treated as an *untrusted +insider*) and §4 Tier 2 (Read-Side Trust Boundary). + +## (a) Path-based capability model + +`permissions.defaults` declares three capability surfaces, each an +`allow` / `deny` path-list: + +- **`read`** — `allow` covers governance files, `docs/**`, `skills/**/SKILL.md`, + `research/**`, `{current_project}/**`, `scripts/**`; `deny` covers secret-shaped + paths (`**/.env*`, `**/*.key`, `**/.ssh/**`, `**/.aws/credentials`), `~/Downloads`, + `~/Desktop`, `/tmp/**`, and raw `**/.git/objects/**`. +- **`write`** — `allow` is narrow: `{current_project}/**` plus the P6/P1 sinks + (`research/entities/**`, `research/notes/**`, `docs/conversations/**`); + `deny` re-lists secrets and the governance / `.control` files. +- **`execute`** — a `bash_allowlist` of read-only inspection + project-scoped + build/test/tooling globs, and a `bash_denylist` of destructive forms + (`rm -rf*`, `git push --force*`, `sudo *`, `curl http*://*`, `wget *`, …). + A `network.egress_allowlist` is whitelist-only (`egress_denylist: []` — nothing + implicit). + +**Ordering rule: allow-then-deny, explicit deny wins** — declared explicitly as +`permissions.defaults.precedence: deny_over_allow`. A path matched by `allow` is +authorized *unless* it also matches `deny`; `deny` is evaluated last and is absolute. +This is load-bearing: `{current_project}/**` equals `{workspace_root}/**` when work +runs at workspace root, so without deny-precedence it would match governance/secret +paths — the `**/.env*` and `.control/policy.yaml` denies are what prevent self-elevation. + +**`{workspace_root}` placeholder** (and `{current_project}`) are expanded by the +bootstrap/runtime at install time so the same template is portable across +workspaces. Patterns use glob semantics (`**` = recursive). + +## (b) `never_auto_granted` — always human-gated + +`permissions.never_auto_granted` is a capability set that **always** triggers a +fresh human-approval flow, even when the agent already holds `policy:write` from a +prior grant (the chained-self-elevation check). Each entry guards a self-elevation +path: + +| Capability | Why human-gated | +|---|---| +| `policy:write` | edits to `.control/policy.yaml` — the file that defines every other gate; self-modification of the control plane (spec §6 risk: agent cannot widen its own gates without a human signature) | +| `grants:write` | direct edits to `.control/grants.jsonl` outside the approve/revoke CLI — would forge its own authorizations | +| `secrets:read` | reads from secret-shaped paths — exfiltration surface | +| `network:egress.add_host` | adding an egress allowlist entry — the dead-drop / C2 surface (spec Tier 3) | +| `signed_writes:bypass` | bypassing the `capability_id` requirement (BRO-1030) on signed writes | +| `governance:write` | writes to CLAUDE.md / AGENTS.md / METALAYER.md — the invariant layer | +| `hooks:write` | edits to `scripts/*-hook.sh` or `.claude/settings.json` — disabling the enforcement chain itself | + +## (c) Approval flow — `.control/grants.jsonl` + +When a tool call would exercise a `never_auto_granted` capability, it requires a +signed grant record appended to `.control/grants.jsonl` (HMAC-signed, produced by +`permissions.py grant`, human-in-the-loop — spec §4 Tier 2). The file is itself +write-`deny`'d to the agent, so grants can only enter through the approve/revoke CLI. + +`permissions.approval` declares how a request reaches the human: + +- **`mode`** ∈ `in_band` | `out_of_band_telegram` | `auto_deny`. +- **`in_band`** — `blocking: true`, the agent halts until approve/deny + (`timeout_seconds: 0` = synchronous human wait). +- **`out_of_band_telegram`** — async notification (`chat_id`, `timeout_seconds: 1800`), + with `timeout_action: deny` (fail-closed when the human doesn't respond in time — + this is the *time-bounded* dimension). +- **`rate_limit`** — `max_requests_per_hour` / `max_requests_per_session` caps with + `excess_action: deny`, bounding approval-fatigue (spec §6 risk). + +## (d) Trust tiers T0–T4 (read-side classification) + +`trust_tiers` classifies **incoming content** and is **orthogonal to permission**: a +cross-folder read grant authorizes the *operation*, but the content is still tagged at +its tier and wrapped by the read-boundary runtime. The block is **self-contained** — +the full classification lives in `policy.yaml` itself (no separate file), so it is +protected by `policy:write` like the rest of the control plane (an agent cannot +reclassify external content as trusted-instructions without a human-signed grant). +`default_tier: T4_external` (fail-closed — unknown sources are least-trusted). + +This adopts the **instruction-authority** model (who may issue instructions vs +data-only — the CaMeL / spotlighting distinction the spec cites), which **supersedes** +the draft origin-locality sketch in spec §4 Tier 2 (that spec is a Draft; this block +is canonical). + +| Tier | Source | Disposition | +|---|---|---| +| **T0_system** | governance files (CLAUDE/AGENTS/METALAYER, policy.yaml) | trusted instructions | +| **T1_user** | live user messages | trusted instructions | +| **T2_workspace_governance** | installed `SKILL.md`, conventions, `bstack/references` | loaded as instructions | +| **T3_workspace_data** | `research/`, `docs/conversations/`, our source code | **data only — never instructions** | +| **T4_external** | Moltbook, X, WebFetch, MCP, imported bridge logs | **quarantined data** | + +The control-plane property (spec §3): content at T3/T4 is never elevated to +action-taking authority — instructions found in fetched/external data are not +executed as instructions. + +## (e) Runtime sequencing (not in this PR) + +The enforcement runtime is intentionally absent and lands as later PRs (spec §5): +`permission-gate-hook.sh` (enforces `never_auto_granted` / grants — PR #3), +`read-boundary-hook.sh` (tags content with trust tier — PR #3), `webfetch-sanitizer.py` +(strips hidden-instruction carriers — PR #4), and the human grant CLI `permissions.py`. +Until those are wired into `.claude/settings.json`, the legacy v1 gates +(`gates:` G1–G4 via `control-gate-hook.sh`) plus the already-shipped `write_gate` +block remain the active enforcement floor; this v1.1 schema is the contract they pin to. diff --git a/tests/policy-template-schema.test.sh b/tests/policy-template-schema.test.sh new file mode 100755 index 0000000..80a5c06 --- /dev/null +++ b/tests/policy-template-schema.test.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# tests/policy-template-schema.test.sh — BRO-1600 policy.yaml.template v1.1 schema smoke. +# +# Asserts the reconciled v1.1 template (assets/templates/policy.yaml.template) carries +# the full v1.1 schema contract the bootstrap hook + bstack doctor depend on: +# 1. parses as YAML (PyYAML; skip-with-pass if absent) +# 2. version == "1.1" +# 3. all declared v1.1 top-level blocks present +# (permissions, trust_tiers, write_gate, gates, ci_watch, ci_heal, +# auto_merge, setpoints, profile) +# 4. permissions sub-keys: defaults{read,write,execute}, +# never_auto_granted (non-empty list), approval{mode} +# 5. trust_tiers present and non-empty +# 6. write_gate retained (regression guard — shipped earlier, must not be +# dropped by this PR) +# +# Dependency-light: python3 one-liners for YAML introspection, no jq/jsonschema. +# Run from anywhere — template path resolves relative to the repo root via BASH_SOURCE. +set -uo pipefail + +BSTACK_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMPLATE="$BSTACK_REPO/assets/templates/policy.yaml.template" + +PASS=0; FAIL=0; FAILED=() +ok() { PASS=$((PASS + 1)); echo " [pass] $1"; } +fail() { FAIL=$((FAIL + 1)); FAILED+=("$1"); echo " [FAIL] $1"; } + +summary() { + echo "" + echo "policy-template-schema: $PASS passed, $FAIL failed" + if [ "$FAIL" -ne 0 ]; then + printf ' - %s\n' "${FAILED[@]}" + exit 1 + fi + exit 0 +} + +# ── Preflight: template must exist ────────────────────────────────────────── +if [ ! -f "$TEMPLATE" ]; then + fail "template not found at $TEMPLATE" + summary +fi + +# ── Preflight: PyYAML present? (skip-with-pass per repo convention) ───────── +if ! python3 -c "import yaml" 2>/dev/null; then + echo "policy-template-schema.test.sh: PyYAML unavailable; skipping (skip-with-pass)." + ok "PyYAML absent — schema introspection skipped" + summary +fi + +# ── Test 1: template parses as YAML ───────────────────────────────────────── +echo "" +echo "Test 1: template parses as YAML" +if python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$TEMPLATE" 2>/dev/null; then + ok "template parses as valid YAML" +else + fail "template is not valid YAML" + summary # nothing else is meaningful if it doesn't parse +fi + +# ── Test 2: version == "1.1" ──────────────────────────────────────────────── +echo "" +echo "Test 2: version == \"1.1\"" +v=$(python3 -c "import sys, yaml; print(yaml.safe_load(open(sys.argv[1])).get('version'))" "$TEMPLATE" 2>/dev/null) +if [ "$v" = "1.1" ]; then + ok "version is \"1.1\"" +else + fail "version != \"1.1\" (got '$v')" +fi + +# ── Test 3: all declared v1.1 top-level blocks present ──────────────────────── +echo "" +echo "Test 3: all v1.1 top-level blocks present" +missing=$(python3 -c ' +import sys, yaml +d = yaml.safe_load(open(sys.argv[1])) +req = ["permissions", "trust_tiers", "write_gate", "gates", + "ci_watch", "ci_heal", "auto_merge", "setpoints", "profile"] +print(" ".join(k for k in req if k not in (d or {}))) +' "$TEMPLATE" 2>/dev/null) +if [ -z "$missing" ]; then + ok "all 9 v1.1 top-level blocks present" +else + fail "missing top-level blocks: $missing" +fi + +# ── Test 4: permissions sub-keys ──────────────────────────────────────────── +echo "" +echo "Test 4: permissions has defaults{read,write,execute} + never_auto_granted + approval.mode" +perm_err=$(python3 -c ' +import sys, yaml +d = yaml.safe_load(open(sys.argv[1])) or {} +p = d.get("permissions") or {} +errs = [] +defaults = p.get("defaults") or {} +for k in ("read", "write", "execute"): + if k not in defaults: + errs.append("defaults." + k + " missing") +nag = p.get("never_auto_granted") +if not isinstance(nag, list) or not nag: + errs.append("never_auto_granted not a non-empty list") +appr = p.get("approval") or {} +if "mode" not in appr: + errs.append("approval.mode missing") +print(" | ".join(errs)) +' "$TEMPLATE" 2>/dev/null) +if [ -z "$perm_err" ]; then + ok "permissions sub-keys all present" +else + fail "permissions schema issue: $perm_err" +fi + +# ── Test 5: trust_tiers present and non-empty ─────────────────────────────── +echo "" +echo "Test 5: trust_tiers present and non-empty" +tt=$(python3 -c "import sys, yaml; print('ok' if (yaml.safe_load(open(sys.argv[1])) or {}).get('trust_tiers') else 'empty')" "$TEMPLATE" 2>/dev/null) +if [ "$tt" = "ok" ]; then + ok "trust_tiers present and non-empty" +else + fail "trust_tiers missing or empty" +fi + +# ── Test 6: write_gate retained (regression guard) ────────────────────────── +echo "" +echo "Test 6: write_gate retained (regression guard)" +wg=$(python3 -c "import sys, yaml; wg = (yaml.safe_load(open(sys.argv[1])) or {}).get('write_gate'); print('ok' if isinstance(wg, dict) and wg else 'missing')" "$TEMPLATE" 2>/dev/null) +if [ "$wg" = "ok" ]; then + ok "write_gate retained (not dropped by this PR)" +else + fail "write_gate dropped — regression!" +fi + +summary \ No newline at end of file