guard

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 13 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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

func Validate

func Validate(r Registry) error

Validate checks the registry schema: the schema version, every entry id, and every entry's tier, pattern command, successor, and why. A per-repo override is validated AFTER merging, so an override can never produce an entry the bundled defaults would not have been allowed to ship.

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

func Load(repoRoot string) (Registry, error)

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

func Merge(base, over Registry) Registry

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

func (r Registry) Check(command string) (Decision, error)

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"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL