Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a6b6d99
docs: plan itd-103 and write spc-16 shell-hazard registry spec
REPPL Jul 27, 2026
56de515
chore: commit iss-146 capture (guard-bash gh-write false positive)
REPPL Jul 27, 2026
5e2c813
feat: add shell tokenizer for the guard hazard registry
REPPL Jul 27, 2026
4a8ac2e
feat: add the bundled shell-hazard registry and its decision function
REPPL Jul 27, 2026
7f4259c
feat: load per-repo guard overrides from .abcd/guard.json
REPPL Jul 27, 2026
2640b50
feat: reject a guard flag group that could never match
REPPL Jul 27, 2026
3adde8c
fix: close four guard matching and validation holes found in review
REPPL Jul 27, 2026
0fe71ab
docs: map the guard package in internal/README
REPPL Jul 27, 2026
9bae2c3
fix: hold a heredoc body until its command line is complete
REPPL Jul 27, 2026
ad63e8d
fix: stop the git-clean successor recommending a warned command
REPPL Jul 27, 2026
83f20a1
fix: keep an arithmetic shift and a blank line from unguarding a session
REPPL Jul 27, 2026
35ef101
feat: wire the shell-hazard guard into the CLI, the host hook, and ahoy
REPPL Jul 27, 2026
e8ba053
docs: document the shell-hazard guard surface
REPPL Jul 27, 2026
d4dd46c
fix: answer an empty --command instead of waiting on stdin
REPPL Jul 27, 2026
6698f69
fix: stop the guard report asserting what it never checked
REPPL Jul 27, 2026
c6333d4
fix: close the two silent ways past the guard, and stop overclaiming …
REPPL Jul 27, 2026
4288532
fix: refuse to answer from a switched-off registry, and correct four …
REPPL Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .abcd/development/brief/04-surfaces/17-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# `/abcd:guard` — Shell-Hazard Guard

`/abcd:guard` decides whether a shell command is safe to run, against abcd's
hazard registry. It is **strictly read-only** — it performs zero writes, it only
answers. It is the execution-time half of itd-103; the teaching half is the rules
loader injecting the same registry's entries before shell-heavy work.

## Sub-verbs

- **`/abcd:guard check`** — evaluate one candidate command line and report the
decision. The candidate comes from `--command "<line>"` or from stdin. The
plugin command (`commands/abcd/guard.md`) passes it on **stdin**, inside a
quoted-delimiter heredoc: a candidate interpolated into a double-quoted
`--command` argument is expanded by the shell before the guard starts, so a
command substitution inside it would run at check time — the one moment the
check exists to prevent. `--command` stays for literals a human typed.
- **`abcd guard hook`** — the host adapter. It reads a pre-tool-use hook payload
on stdin, applies the same decision, and maps it onto the host's block/allow
protocol. It is invoked by `hooks/hooks.json`, not by hand.

Bare `abcd guard` prints command usage — it does **not** render a status board;
guard health is reported by `abcd ahoy`, which is where install/health state
lives for every other part of abcd too.

## The decision

Core (`internal/core/guard`) returns one of three verdicts, and the front doors
only format it:

| Verdict | `guard check` | `guard hook` |
|---|---|---|
| `allow` | exit 0 | exit 0, silent |
| `warn` | exit 0, warning rendered | exit 0, warning on stderr |
| `block` | exit 1, why + successor rendered | the host's blocking status, why + successor as the message |

A guard that cannot be **evaluated** — an unparsable command line, a registry
that will not load, a candidate too long to read, a registry switched off — is a
fourth case, and the two front doors part company on it deliberately:

- `guard check` exits **2**. A script that asked the guard a question must never
read silence as clearance.
- `guard hook` exits **1** and warns loudly. Exit 1 rather than 0 is the whole
point: a pre-tool-use hook that exits 0 has its stderr discarded, so the
warning would exist and nobody would ever see it. Exit 1 is non-blocking, so
the command still runs — a guard that cannot answer never stops a session.

## Fail-open-loud

The installed hook entry wraps the binary call in a shim. The binary's own three
statuses pass through untouched — 0 (allow), 1 (ran, could not decide, allowed
loudly), 2 (block). Anything else means the binary did not run at all: missing,
not executable, crashed, killed. The shim then allows the command and emits an
unmissable `UNGUARDED` warning of its own. A session is therefore never bricked
by a broken guard, and never silently unprotected either — and a binary that ran
and reported is never described as having failed to run.

The outside-the-session half is `abcd ahoy`'s `guard:` line, which reports the
three things that can independently be false: whether the hook is installed,
whether the binary it calls is reachable, and whether the registry loads. Each
also surfaces as a gap (`guard.hook_missing`, `guard.binary_unreachable`,
`guard.registry_unloadable`).

## Registry and overrides

The hazard entries are bundled in the binary and merged with a repo's
`.abcd/guard.json` — a dedicated file rather than a rules-loader domain, so a
rules kill switch can never silently disable a safety guard. An entry key
overrides one field or declares a new hazard; `{"disabled": true}` switches the
guard off.

There is **no flag, environment variable, or prompt** that disarms the guard for
a session: the file is the only route, so the change lands in a diff. What the
file is *not*, today, is verified as committed — `guard.Load` reads the working
tree, so an edit takes effect on the next command, before review. The mitigation
is loudness rather than enforcement: a disabled registry makes every command it
lets through carry an UNGUARDED warning naming the file, and `abcd ahoy` reports
`OFF`. Closing the gap properly (refusing a `disabled: true` that is not in
`HEAD`) is a core-side change to `guard.Load`, tracked as an issue.

## What an allow means

An allow means **no registry entry matched** — never that a command is safe. The
guard reads command names it can see in command position, so a hazard reached any
other way is not seen:

- a command string handed to an interpreter (`eval`, `sh -c`);
- one launched through a wrapper outside the small known set (`sudo`, `doas`,
`command`, `env`, `nohup`, `time`) — `xargs`, `timeout`, `exec` are not in it;
- one launched through a wrapper that *is* in that set but carries its own flags:
only the wrapper NAME is stepped over, so `sudo <hazard>` is seen and
`sudo -u bob <hazard>` is not (`-u` is read as the command);
- one inside a backtick substitution (`$(…)` is followed, backticks are not);
- a dangerous form no entry describes.

Coverage is what the registry names. The registry grows from reality: a
facilitator who sees something scary captures it, and recurring captures are
promoted into the bundled defaults through the admission gate.

## References

- Plugin command: [`commands/abcd/guard.md`](../../../../commands/abcd/guard.md)
- Spec: [`spc-16`](../../specs/open/spc-16-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md)
- Intent: [`itd-103`](../../intents/planned/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md)
- Install/health surface: [`01-ahoy.md`](01-ahoy.md)
1 change: 1 addition & 0 deletions .abcd/development/brief/04-surfaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The brief's user-facing command surface is the set enumerated below (not all are
| 14 | `/abcd:ingest` | shipped | Register a URL or document into the local sources corpus — host-delegated command, no Go verb | [`14-ingest.md`](14-ingest.md) |
| 15 | `/abcd:prepare-this-repo` | shipped | Bring an owned repo up to abcd's conventions (interim bridge) — host-delegated command, no Go verb | [`15-prepare-this-repo.md`](15-prepare-this-repo.md) |
| 16 | `/abcd:audit` | shipped | Check whether a repo conforms to the working conventions (three-tier layout, AGENTS.md router, durable decisions, docs currency, privacy hygiene) — read-only, tri-state exit; backs `prepare-this-repo` and gates CI (itd-85) | [`16-audit.md`](16-audit.md) |
| 17 | `/abcd:guard` | shipped | Shell-hazard guard (`check`) — decide one candidate command line against the bundled hazard registry merged with the repo's committed `.abcd/guard.json`, and answer allow / warn / block with the plain-language why and the safe successor. Read-only. The `hook` sub-verb is the host pre-tool-use adapter, live-wired from `hooks/hooks.json` behind a fail-open-loud shim; guard health (hook installed, binary reachable, registry loadable) is reported by `abcd ahoy` (itd-103, spc-16) | [`17-guard.md`](17-guard.md) |

The **Status** column is machine-checked: the `surface_coverage` record-lint rule asserts every `shipped` row has a backing surface (`commands/abcd/<name>.md` or `skills/<name>/`) and every `staged` row (a design target) has none — and, in reverse, that every real surface has a row here. The bare `/abcd` top-level is binary-backed (its command file is `commands/abcd.md`, not a `commands/abcd/<name>.md` entry) and is exempt from the file check. Keeping this column honest is how the brief's surface set stays reconciled with the shipped binary; the semantic half — whether each row's *prose* matches binary behaviour — stays a release-gate agent check.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
id: itd-103
slug: abcd-teaches-repo-agents-the-shell-commands-they-must-never
spec_id: null
kind: null
spec_id: spc-16
kind: standalone
suggested_kind: null
reclassification_history: []
builds_on: []
Expand Down
23 changes: 23 additions & 0 deletions .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,29 @@
"hidden": false,
"flags": []
},
{
"path": "abcd guard",
"hidden": false,
"flags": []
},
{
"path": "abcd guard check",
"hidden": false,
"flags": [
{
"name": "command",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
}
]
},
{
"path": "abcd guard hook",
"hidden": false,
"flags": []
},
{
"path": "abcd history",
"hidden": false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
id: spc-16
slug: abcd-teaches-repo-agents-the-shell-commands-they-must-never
intent: itd-103
---
# abcd-teaches-repo-agents-the-shell-commands-they-must-never

## Summary

spc-16 delivers the shell-hazard registry for itd-103: one bundled registry of
dangerous command patterns, surfaced through two planes. The teaching plane
injects the matched safety rules through the existing rules loader before
shell-heavy work; the guard plane is a deterministic core verb
(`abcd guard check`) that a harness hook calls at execution time to refuse a
matching command and reply with its safe successor. The design decisions below
were settled by the 2026-07-27 grill (recorded in the intent's Grill
Settlements); this spec records them together with the mechanism — it does not
reopen them.

## Settled constraints (from the grill)

- **Fail-open-loud.** If the guard hook cannot execute the abcd binary, the
session is never blocked and never silently unguarded: the hook emits an
unmissable in-session warning, and `abcd ahoy` status reports guard health.
- **Two tiers, mirroring the docs-lint family.** `blocker` refuses the command
with the safe successor as the block message; `warn` lets the command pass
with the warning injected. Entry shape follows the lint `banned_tokens`
family: id, pattern, tier, successor, plain-language why.
- **No in-session override.** The only escape is a committed, reviewable
per-repo config override.
- **Shell-token-aware matching in command position only.** A raw-regex guard
would have blocked this repository's own incident-capture command (a ledger
capture whose quoted text mentions `rm -rf *`) — the known-good corpus is
not optional.

## Mechanism

### Registry

Bundled defaults are embedded in the binary, like the rules-loader default
domains. Each entry declares:

- `id` — stable slug (e.g. `rm-rf-after-cd-chain`).
- `pattern` — a command-position match expressed over shell tokens, not raw
text (see Matching).
- `tier` — `blocker` or `warn`.
- `successor` — the safe form the agent is told to run instead.
- `why` — one plain-language sentence a non-expert facilitator understands.
- `fixtures` — known-bad and known-good command lines proving the entry's
behaviour (see Admission gate).

### Config home (spec-time decision)

Per-repo overrides live in a dedicated committed file, `.abcd/guard.json`
(`schema_version`, `disabled`, `entries` keyed by id for per-field override or
addition) — not in a new `.abcd/rules.json` domain. Two reasons: the guard
entry schema is structured (tier/successor/fixtures) where rules-domain
entries are prose strings, and the rules loader's kill switch must not
silently disable a safety guard — the two features keep independent switches.
Disabling the guard is itself a committed, reviewable act: `"disabled": true`
in `.abcd/guard.json`.

### Two planes, one registry

- **Teaching plane (all hosts).** The rules loader gains a guard-backed safety
domain: prompts that recall-match shell-heavy work get the matched registry
entries rendered as rules (pattern, why, safe successor) injected before the
agent acts. Hosts without hook support get this plane at minimum.
- **Guard plane (hosts with hooks).** `abcd guard check` is a core verb behind
the transport-agnostic boundary: core takes a candidate command string and
returns a decision (allow / warn+message / block+successor+why); the CLI
front door formats it, and the host-hook adapter parses the host's hook
payload into that call and maps the decision onto the host's block/allow
protocol. Hook wiring is installed by ahoy for hosts that support it.
- Both planes are wired at delivery: the verb is reachable from the CLI and
the plugin markdown surface (`commands/abcd/guard.md`), and the hook
executes it in a live session — no dead scaffolding.

### Matching

Matching is shell-token-aware and applies in command position only:

- The candidate line is tokenised with shell quoting honoured; a hazard
pattern inside a quoted string argument never fires (AC 3 — the
incident-capture command is known-good fixture #1).
- Command position is tracked across compound separators (`&&`, `;`, `||`,
pipes), so `cd scratch && rm -rf *` matches the cd-chain structure the
registry entry describes.
- String payloads inside `eval` or `sh -c '...'` are a **documented v1 gap**,
stated in the verb's reference doc and the registry README — not a silent
one.

### Fail-open-loud and health

The installed hook is a thin shim: if the abcd binary is missing, fails to
execute, or exits abnormally, the shim exits with the host's allow status
while emitting an unmissable warning into the session transcript. `abcd ahoy`
status gains a guard-health line (hook installed, binary reachable, registry
loadable) so a broken guard is visible outside the session too — never a
silent no-op, never a bricked session.

### Admission gate (bundled defaults)

A registry entry is admitted to the bundled defaults only when:

- it ships known-bad and known-good fixtures, with known-good at least 40% of
the entry's corpus;
- it clears the declared true-negative-rate floor: **100% on its known-good
corpus** — a single known-good fixture that fires blocks admission. The
floor is enforced by a test over the embedded corpus, so an entry cannot
ship without its proof.

The registry grows from reality: `abcd capture` is the single move a
facilitator needs, and recurring captures are promoted into the bundled
defaults through this gate.

## Acceptance-criteria mapping

- AC 1 (fail open loud, health in ahoy) → Fail-open-loud and health.
- AC 2 (blocker refuses with successor, warn passes, committed override only)
→ Registry tiers + Config home.
- AC 3 (quoted-string non-firing, command position, cd-chain) → Matching.
- AC 4 (fixture corpus, ≥40% known-good, TNR floor, documented eval gap) →
Admission gate + Matching.

## Out of scope

- Non-shell tool calls (a later guard-family question, per the intent).
- Parsing string payloads inside `eval` / `shell -c` (documented v1 gap).
- Any scheduled or CI-side execution of the guard.
4 changes: 4 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,3 +862,7 @@ parallel-agent merge contention bites.
- 2026-07-27 — ADOPTED the canonical tagline: "A host-agnostic configuration layer for intent-driven development." Canonical identity home is the brief product chapter (01-product README, Identity section: title / tagline / pitch); README strapline, plugin manifest description, and AGENTS.md opening render from it. README's former strapline ("An opinionated, intent-driven development framework for product thinkers") is retired as a surface line; the product-thinker framing lives on in the README body. Rejected wordings: "for agent harnesses" (object shift, host/harness redundancy, plural over-promise), "over any agent harness" (drops the domain). Resolves iss-143; itd-102 generalises the drift check for managed repos.
- 2026-07-27 — itd-93 parity tension resolved: Branch A. The scaffold template is the single source for release.yml/auto-release.yml; abcd-cli's live workflows are regenerated from it; the only sanctioned live diff is the additive workflow_dispatch rehearsal (trigger + non-publishing dry-run job). Rejected: substitution-gating the rehearsal off for abcd (weakens the parity guarantee on the one novel component).
- 2026-07-27 — GRILLED itd-101/102/103/104 to settlement (six decisions, each now in its intent's Grill Settlements + acceptance criteria): itd-103 guard fails open loudly, blocker/warn tiers with committed-only overrides, shell-token-aware command-position matching with an itd-81-style TNR-floored corpus; itd-101 refresh is manual-with-nagging (scheduled CI later, own sign-off), staleness warns at 180d and blocks releases at 365d, human verifications age on the same clock; itd-102 identity lives in a parseable markdown block (config points at it), check is warn-tier and never rewrites; itd-104 ideate is an optional verb, never a pre-capture gate, with a fresh-context off-policy adversary. All four drafts are grill-settled and await intent plan.
- 2026-07-27: itd-103 guard overrides live in dedicated committed .abcd/guard.json, not a rules.json domain — structured entry schema, and the rules kill switch must not silently disable a safety guard (spc-16).
- 2026-07-27: itd-103 guard wiring — `abcd guard hook` is a sub-verb of the USER-facing `guard` verb, not a fifth entrypoint under the hidden `hook` subtree. Reason: guard health has to be legible (AC 1), and a hidden adapter documents nothing; the other `hook` entrypoints are injection transport with no user-facing counterpart, this one is the same decision the user can also ask for by hand. Consequence: it appears in the generated CLI reference and must stay host-agnostic in its prose.
- 2026-07-27: itd-103 guard wiring — the two front doors disagree on ONE case by design. A guard that cannot be evaluated (unparsable command, registry that will not load) is a FAULT for `guard check` (exit 2: a script must never read silence as clearance) and fail-OPEN for `guard hook` (exit 0 + loud stderr: a broken guard must never stop a session). Rejected: one shared behaviour, which would either brick sessions or teach scripts that silence means safe.
- 2026-07-27: itd-103 guard wiring — the fail-open-loud shim is a shell wrapper in the plugin's `hooks/hooks.json` PreToolUse entry (pass through only the binary's own 0/2; everything else warns UNGUARDED and allows), not Go code. The failure it guards against is the Go binary not running at all, so it cannot live in Go. Tested by executing the committed manifest string under /bin/sh against a fake plugin root.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-146"
slug: "guard-bash-gh-write-local-path-fp"
severity: "minor"
category: "observation"
source: "user-observation"
found_during: "2026-07-27 session"
found_at: "guard hook, agent shell usage"
---

guard-bash false positive class: a compound command chaining a gh write verb with a LOCAL command taking an absolute path argument (gh pr merge ... ; git worktree remove /abs/path) is blocked by the line-level privacy regex, which cannot see that the path never reaches GitHub. Second guard incident of 2026-07-27 (the first, blocking --no-verify, was a true positive). Both are fixture material for itd-103's calibration corpus: this one is a known-good case the TNR floor must protect; the workaround (split the compound) is recorded here so the friction is not silent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
schema_version: 1
id: "iss-147"
slug: "guard-load-reads-abcd-guard-json-from-the-working-tree-so-a"
severity: "minor"
category: "observation"
source: "user-observation"
found_during: "manual-capture"
---

guard.Load reads .abcd/guard.json from the WORKING TREE, so a disabled:true (or a retiered blocker) takes effect on the very next command, before anyone reviews it — spc-16 and the shipped docs both say the only escape is a committed, reviewable override, and nothing enforces the committed half. Reachable in one move: an agent writes .abcd/guard.json and the guard itself allows that write. Mitigated at the front door in itd-103 wiring (a disabled registry now warns UNGUARDED on every command, and abcd ahoy reports OFF) but not enforced. Proper fix is core-side: refuse a disabled:true that is not in HEAD, or drop the committed claim from spc-16. Found by the security reviewer on the itd-103 wiring branch.
Loading