Documentation
¶
Overview ¶
Package guard is abcd's transport-agnostic shell-hazard registry (itd-103, spc-16). It owns one registry of dangerous command patterns and the deterministic decision that a harness hook calls at execution time: given a candidate command line it returns allow, warn, or block — never writing to stdout, never exiting, never knowing a transport. Front doors under internal/surface format the decision, and the fail-open-loud behaviour on a broken guard lives in the hook shim, not here.
The model is a set of binary-bundled default entries (embedded from defaults/guard.json) merged with an optional per-repo <repoRoot>/.abcd/guard.json override. Each entry declares an id, a command-position pattern over shell tokens, a tier (blocker/warn), the safe successor, a plain-language why, and the known-bad/known-good fixtures that prove it. Bundled entries are held to an admission gate (fixtures present, known-good at least 40% of the corpus, 100% true-negative rate) enforced by TestBundledEntriesPassAdmissionGate, so an entry cannot ship without proof.
Matching is shell-token-aware and applies in COMMAND POSITION only: the candidate is tokenised with shell quoting honoured, so a hazard named inside a quoted string argument (an incident capture whose text mentions "cd scratch && rm -rf *") never fires. Quoting affects tokenisation, not argument semantics — `git push '--force'` still fires, because the shell hands the same argv either way.
Index ¶
Constants ¶
const ( TierBlocker = "blocker" TierWarn = "warn" )
Tier values, mirroring the record-lint severity family: a blocker refuses the command, a warn lets it through with the warning attached.
const RepoRelPath = ".abcd/guard.json"
RepoRelPath is the per-repo override file, relative to the repo worktree. It is a dedicated committed file rather than a rules.json domain: the entry schema is structured, and the rules loader's kill switch must never silently disable a safety guard (spc-16, "Config home"). Disabling the guard is itself a committed, reviewable act.
const SchemaVersion = 1
SchemaVersion is the guard config schema this build implements. It is independent of the rules loader's schema: the two features keep separate switches so a rules kill switch can never silently disable a safety guard.
Variables ¶
var ( // ErrUnparsableCommand is a candidate command line the shell tokenizer // cannot split (an unterminated quote, a trailing backslash). ErrUnparsableCommand = errors.New("guard: unparsable command line") // ErrMalformedConfig is a per-repo .abcd/guard.json that is unreadable or // is not valid JSON. ErrMalformedConfig = errors.New("guard: malformed config") // ErrSchemaVersion is a config declaring a schema_version this build does // not implement. ErrSchemaVersion = errors.New("guard: unsupported schema_version") // ErrUnknownTier is an entry whose tier is neither blocker nor warn. ErrUnknownTier = errors.New("guard: unknown tier") // ErrInvalidEntry is an entry that fails the registry schema (bad id, no // pattern command, missing successor or why). ErrInvalidEntry = errors.New("guard: invalid entry") )
The guard sentinels. Every error the package returns wraps one of these, so a front door can render the failure loudly (and, for the hook shim, fail OPEN) without string-matching. Core never decides what a failure means for a session — it only names it.
Functions ¶
Types ¶
type Decision ¶
type Decision struct {
Verdict Verdict `json:"verdict"`
// EntryID, Tier, Successor, and Why describe the winning entry; they are
// empty on an allow.
EntryID string `json:"entry_id,omitempty"`
Tier string `json:"tier,omitempty"`
Successor string `json:"successor,omitempty"`
Why string `json:"why,omitempty"`
// Message is the ready-to-surface sentence: the why plus the successor, so
// the refusal itself teaches the safe form.
Message string `json:"message,omitempty"`
// Matches lists every entry the command tripped (blocker ids first, then
// warn, each in id order), so a warn plane can surface all of them. It
// includes the reserved synthetic id when an execute-a-string payload raised
// an entry-less verdict.
Matches []string `json:"matches,omitempty"`
// Family and Reason describe an entry-less (synthetic) verdict — an
// execute-a-string wrapper, or a brace group the tokenizer cannot expand:
// the family that raised it and the plain-language reason. They are empty on
// an ordinary registry match. The raw payload is deliberately not carried —
// nothing reads it yet.
Family string `json:"family,omitempty"`
Reason string `json:"reason,omitempty"`
}
Decision is what core returns for one candidate command. It carries no formatting and no exit status — the front door decides how to render it, and the hook shim decides what to do with a broken guard.
type Entry ¶
type Entry struct {
ID string `json:"id,omitempty"`
Pattern Pattern `json:"pattern"`
Tier string `json:"tier"`
Successor string `json:"successor"`
Why string `json:"why"`
Fixtures Fixtures `json:"fixtures,omitempty"`
}
Entry is one hazard. The map key in Registry.Entries is its id; ID is filled from that key on load, so the key is always authoritative — a declared id is accepted (the documented entry shape spells one out) and then overwritten, never used to rename the entry.
type Fixtures ¶
type Fixtures struct {
KnownBad []string `json:"known_bad,omitempty"`
KnownGood []string `json:"known_good,omitempty"`
}
Fixtures is an entry's proof corpus: command lines it must fire on, and command lines it must not. The admission gate in fixtures_test.go enforces both sides plus the 40% known-good floor.
type FlagValue ¶
type FlagValue struct {
// Flag is an alternation group written with "|" ("-X|--method"), matching
// the spelling Pattern.Flags uses.
Flag string `json:"flag"`
// Values are the accepted settings. They are compared case-insensitively:
// an HTTP method's case is not what makes it destructive, and a constraint a
// change of case walks past is not a constraint.
Values []string `json:"values"`
}
FlagValue is one flag-with-value constraint: some argument must set one of Flag's "|"-separated alternatives to one of Values.
type PathArg ¶
type PathArg struct {
// Root is the first path segment the operand must have ("repos").
Root string `json:"root"`
// Segments is the EXACT number of segments the operand must hold, counting
// Root: three for `repos/{owner}/{repo}`. Exact rather than a maximum,
// because it is the deeper paths — not the shallower ones — that stay
// allowed.
Segments int `json:"segments"`
}
PathArg is one operand constraint for a slash-separated resource path.
type Pattern ¶
type Pattern struct {
// Command is the executable that must stand in command position. It is
// compared against the segment's argv[0] by basename, so /bin/rm matches rm.
Command string `json:"command"`
// Subcommand, when set, must be the first non-flag argument (git push,
// git reset). ValueFlags lets a global option that consumes the next token
// be skipped while looking for it.
Subcommand string `json:"subcommand,omitempty"`
// Subcommand2, when set, must be the SECOND non-flag argument — the
// two-level grammar `gh repo delete`, which one Subcommand cannot express
// (`gh repo list` and `gh repo delete` share their first level, and only one
// of them is a hazard).
Subcommand2 string `json:"subcommand2,omitempty"`
// ValueFlags are flags of Command that consume the FOLLOWING token, so the
// scan for Subcommand steps over the value rather than reading it as the
// subcommand (`git -C /repo push`).
ValueFlags []string `json:"value_flags,omitempty"`
// Flags are argument constraints. Each element is one alternation group
// written with "|" ("--force|-f"); every group must be satisfied by some
// argument token. A long alternative also matches its --flag=value form; a
// single-letter short alternative also matches inside a bundled cluster
// (-rf satisfies -r and -f).
Flags []string `json:"flags,omitempty"`
// FlagValues constrain what a flag is SET TO, not merely that it is present.
// `gh api -X DELETE repos/owner/repo` deletes the repository and
// `gh api -X GET repos/owner/repo` reads it, so an entry that could only ask
// whether -X appears would have to choose between missing the first and
// refusing the second. Every listed constraint must be satisfied.
FlagValues []FlagValue `json:"flag_values,omitempty"`
// ArgPaths constrain an operand that names a slash-separated resource path,
// by its first segment AND its exact depth. Depth is the point: DELETE on
// `repos/{owner}/{repo}` destroys the repository, while DELETE on a deeper
// path under it (a branch ref, a release) is ordinary work that stays
// allowed. Every listed constraint must be satisfied.
ArgPaths []PathArg `json:"arg_paths,omitempty"`
// ArgPrefixes constrain an OPERAND rather than a flag: every listed prefix
// must be carried by some non-flag argument. It is what describes a hazard
// spelled without any flag at all — `git push origin +main:main`, where the
// leading `+` on the refspec is a force push by another name and Flags has
// nothing to look at.
ArgPrefixes []string `json:"arg_prefixes,omitempty"`
// AfterCD, when true, additionally requires that some EARLIER command in the
// same chain is a `cd` — the cd-chain structure (`cd scratch && rm -rf *`)
// whose hazard is that a failed cd silently redirects the command. A nil
// pointer means false; it is a pointer so a per-repo override can turn the
// requirement off as well as on.
AfterCD *bool `json:"after_cd,omitempty"`
}
Pattern is a command-position match expressed over shell tokens, never raw text. All declared constraints must hold for the pattern to fire.
type Registry ¶
type Registry struct {
SchemaVersion int `json:"schema_version"`
Disabled bool `json:"disabled"`
Entries map[string]Entry `json:"entries"`
}
Registry is the merged, validated hazard model: the bundled defaults overlaid with the per-repo override.
func Defaults ¶
func Defaults() Registry
Defaults returns a deep copy of the binary-bundled registry, safe for the caller to mutate.
func Load ¶
Load returns the bundled defaults merged with <repoRoot>/.abcd/guard.json when that file exists. An absent file yields the defaults unchanged.
A repo override that cannot be read, parsed, or validated is a fail-SAFE fallback, not a fail-open one: Load returns the bundled defaults ALONGSIDE the error (never an empty registry), so a broken repo layer drops only the repo's own overrides while the built-in hazards stay armed. Returning empty here would disable the whole guard on one broken committed file — the fail-open the bundled hazards never depended on (iss-2608261551087492). The error is still returned so the caller (the hook shim) can announce the dropped repo layer loudly while continuing to check against the bundled registry.
func Merge ¶
Merge overlays over onto base. Entry fields are per-field: a field set on the override wins, an absent field inherits the bundled entry (so {"tier":"warn"} retiers an entry while keeping its pattern, successor, and why). New entry keys are added. The kill switch is sticky — either side can disable the guard, and neither can silently re-enable the other's refusal to run it.
func (Registry) Check ¶
Check evaluates one candidate command line against the registry. A disabled registry allows everything (the escape is committed and reviewable, never an in-session override). A command line the tokenizer cannot split is an error, not an allow — core names the failure and the caller decides what it means.
type Verdict ¶
type Verdict string
Verdict is the decision a front door acts on.
const ( // VerdictAllow means no entry matched: run the command. VerdictAllow Verdict = "allow" // VerdictWarn means a warn-tier entry matched: run the command with the // warning surfaced. VerdictWarn Verdict = "warn" // VerdictBlock means a blocker-tier entry matched: refuse, and reply with // the successor as the block message. VerdictBlock Verdict = "block" )