rules

package
v0.13.2 Latest Latest
Warning

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

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package rules is abcd's transport-agnostic modular rules loader (itd-3). It owns the whole capability behind two front doors: the `abcd rules [domain]` CLI verb and the Claude Code prompt-router hook. Nothing here writes to stdout or knows about a harness event — the front doors under internal/surface and the hook entrypoint marshal these results for their transport.

The model is a small set of binary-bundled default domains (embedded below, plus SHELL, generated from the guard's hazard registry in shell.go, and GRILL, generated from the asking rules in grill.go) merged with two optional override layers, in order: the user scope's ~/.abcd.noindex/rules.json (one per machine, spc-23) and then the per-repo <repoRoot>/.abcd/rules.json, so the repo wins a field both set. Each domain carries recall keywords + aliases and a list of rules; a prompt is recall-matched against the active domains and only the matching rules are rendered for injection. A leading *<DOMAIN> star-command activates a domain unconditionally (overriding a dormant state, but never the top-level kill switch).

Validation is hand-rolled Go (zero new dependencies): a rules.json that fails to parse or validate is a fail-closed error the caller surfaces loudly — it never silently degrades to zero injection.

Index

Constants

View Source
const (
	StateActive  = "active"
	StateDormant = "dormant"
)

Domain state values. An empty string is treated as active.

View Source
const (
	SourceBundled = "bundled"
	SourceUser    = "user"
	SourceRepo    = "repo"
)

Source labels for ResolvedDomain.Source: where a domain's effective content came from. An override that names a domain — replacing its rules, changing its state, or declaring it outright — makes the domain that override's layer, conservatively: the effective behaviour is that layer's choice even when only the state moved. The LAST layer to name a domain labels it, so a domain the user scope and the repo both name is SourceRepo.

View Source
const DefaultRefreshBackstop = 15

DefaultRefreshBackstop is the fixed-N full-refresh backstop (D1): the primary refresh is event-driven (a SessionStart/PreCompact reset clears the ledger), and this large counter only catches always-relevant domains that never recall-match. It is deliberately larger than CARL's every-5.

View Source
const GrillDomain = "GRILL"

GrillDomain is the bundled domain that carries the asking rules every abcd interview follows (itd-201; spc-2610030944505997, "GRILL generated from one Go source"): one thing at a time, the thing being decided quoted in full, the question's layout and its limits, options that widen and never recommend, and deferral recorded as an answer.

The domain is GENERATED from internal/core/question, never written in defaults/rules.json: its rules are question.AskingRules(question.Default), the same Limits value the question check in the guard hook enforces, and its recall is question.AskingRecall. A limit changed there changes the rule text with no second edit (itd-2610030810350727 criterion A7), and TestGrillDomainIsGeneratedFromTheAskingRules fails if the two ever part. It reaches every repository abcd manages through the binary, as SHELL does, and is an ordinary bundled domain to every loader contract: per-field user and repository overrides, dormant (a repository silences it with {"GRILL": {"state": "dormant"}}), the kill switch, the *GRILL star command, dedup and provenance.

View Source
const RepoRelPath = ".abcd/rules.json"

RepoRelPath is the per-repo override file, relative to the repo worktree.

View Source
const ShellDomain = "SHELL"

ShellDomain is the bundled domain that carries itd-103's teaching plane (spc-16, "Two planes, one registry"; iss-151, ruling J10): the shell-hazard registry's lessons, injected before shell-heavy work so an agent is taught the safe form up front. A host without hook support gets this plane alone; a host with hooks gets it and the guard.

The domain is GENERATED from the bundled registry the guard reads, never written in defaults/rules.json: one rule per registry entry (the entry's Lesson, in id order) and one recall term per command head the registry matches. An entry added to or removed from the registry changes the domain with no second edit, and TestShellDomainIsGeneratedFromTheGuardRegistry fails if the two ever part. It is an ordinary bundled domain to every loader contract — per-field user and repo overrides, dormant, the kill switch, the *SHELL star command, dedup and provenance — and a guardrail domain to noteWithheld.

The bundled defaults carry the domain generated from the bundled registry. Load regenerates it, by the same generator, from the registry the guard enforces in the repository — the bundled entries merged with the repository's own .abcd/guard.json (ruling CK1) — so a repository's own hazards are taught as the bundled ones are, each such lesson marked guard.RepoMark. The switches stay independent (spc-16, "Config home"): the guard file decides what is refused, and rules.json overrides, silences or kills the teaching of it like any bundled domain's.

View Source
const StateTTL = 7 * 24 * time.Hour

StateTTL bounds how long a per-session ledger lives before the reset hook sweeps it (sessions are one-shot; a week is generous headroom).

Variables

View Source
var TrustedRootsDisplay = abcdhome.Display("trusted-roots")

TrustedRootsDisplay is how that file is NAMED in a diagnostic: the tilde form, never the expanded path, so a refusal a user pastes into a shell works and no message carries the developer-identity home path (iss-81, fsutil.RedactHome).

View Source
var TrustedRootsRelPath = abcdhome.Rel("trusted-roots")

TrustedRootsRelPath is the home-scoped opt-in that re-admits a marker root the caller does not own, relative to the user's home directory.

View Source
var UserDisplayPath = abcdhome.Display("rules.json")

UserDisplayPath is how the user-scope file is NAMED in a diagnostic: the tilde form, never the expanded path, so no message carries the developer-identity home path (iss-81, fsutil.RedactHome) and a refusal a user pastes still names the file.

View Source
var UserRelPath = abcdhome.Rel("rules.json")

UserRelPath is the user-scope override file, relative to the home directory: the machine's own conventions, layered between the bundled defaults and every repo's override (itd-117, spc-23).

Functions

func Label added in v0.11.0

func Label(name, source string) string

Label is a domain name as every provenance surface prints it: bare for a bundled domain, "NAME (user override)" or "NAME (repo override)" for one an override layer named. The rendered heading and the hook's diagnostic both use it, so the two agree byte for byte.

func LayerPath added in v0.11.0

func LayerPath(source string) string

LayerPath is the display path of the file a layer's source label names: UserDisplayPath for SourceUser, RepoRelPath for SourceRepo, and "" for the bundled defaults, which have no file.

func LoadBackstop

func LoadBackstop(repoRoot string) int

LoadBackstop reads rules.force_refresh_every_n from <repoRoot>/.abcd/config.json — the fixed-N full-refresh backstop (D1). Event-driven reset is the primary refresh, so this only bounds always-relevant domains; a missing file/key or a non-positive value falls back to DefaultRefreshBackstop.

func PruneState

func PruneState(maxAge time.Duration)

PruneState removes session-state files older than maxAge, bounding the growth of the temp state dir across many sessions. Best-effort housekeeping: a missing dir or a per-entry error is ignored.

func Render

func Render(domains []ResolvedDomain) string

Render is the single renderer both front doors use. It emits a header plus one section per domain. An empty domain list renders to zero bytes (D3: no model-facing tokens on a no-match).

func ResetState

func ResetState(session string) error

ResetState clears a session's ledger (the event-driven refresh: the next prompt re-injects every matching domain). A missing file is not an error.

func ResolveRoot added in v0.7.1

func ResolveRoot(cwd string) string

ResolveRoot resolves the repo root the per-repo configuration under .abcd/ is read from — rules.json and config.json here, guard.json for the shell guard, which shares this resolver so a session's rules and its guard can never come from two different places.

The resolution is bounded at the git working tree (GHSA-vvqc-3mv2-5p49). The toplevel git reports for cwd is resolved FIRST; the walk then runs from cwd upward but stops at that toplevel inclusive, so the nearest .abcd directory INSIDE the tree wins (a repo that disabled a domain, or the whole loader, stays disabled from any nested directory — iss-66/B12) and a tree with no .abcd resolves to its own toplevel. A .abcd above the working tree is never reached: an unbounded walk let a directory planted in the shared temp dir govern the injected rules and disarm the guard of every repository beneath it. The per-repo file is a repo-scope setting by design (the configuration chapter and itd-3 both reject a global rules.json), so the bound is the documented contract, not a new policy.

The exact scope of what that closes: any ancestor a session's own working tree sits inside; a plant in a shared directory that is not a repository — the one-command `: > /tmp/.git` or `mkdir /tmp/.git` beside a planted /tmp/.abcd, which the fallback below refuses because a marker must look like a repository; and a REAL repository planted in a shared ancestor by another uid (`git init /tmp`, or a repository laid in a root-owned mode-1777 directory), which the fallback refuses on OWNERSHIP — see foreignOwnerRefusal, and TrustedRootsRelPath for the explicit opt-in that re-admits a foreign-uid checkout the caller means to trust.

The home directory is never a repo root (iss-2609020219198779). Its .abcd is the USER layer (spc-23), and a home that is itself a git working tree (dotfiles-in-home) would otherwise hand every non-repo directory beneath it the home's .abcd a second time, as the repo layer — its guard.json with it, which has no user layer at all, and its config.json as the repo layer over itself as the machine layer. So the walk passes over the home, a toplevel that IS the home takes the non-repo route (cwd, no walk) when nothing nearer carries a .abcd, and a toplevel that CONTAINS the home — a hermetic harness pointing HOME inside its checkout — stays the root, because it is a repository git vouched for and not the home. What this does not reach is a session whose working directory IS the home: the root is then cwd, as it is for any non-repo directory, and a .abcd at the working directory is read. That is the working-directory read the refusal below also leaves standing; making it refuse is a posture change, not a bound on the walk (iss-2609251522588539, and its entry of 2026-09-25 in .abcd/work/DECISIONS.md).

"Not a repository" and "a repository git will not answer for" are DIFFERENT outcomes and only the first resolves to cwd with no walk. abcd runs git under an isolated environment (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1) which also discards the developer's safe.directory exceptions, so rev-parse fails inside a checkout owned by another uid; it fails outright when the host launches the hook with git off its PATH. Collapsing those onto cwd dropped the repository's own configuration layer with no error anywhere: from a subdirectory the root became the subdirectory, so guard.json's hazards became allows, the rules kill switch stopped applying, and the private banlist store went missing. A repo-shaped tree therefore falls back to the .git marker (gitutil.RepoShapedRoot), and only a directory with no repository above it keeps the no-walk cwd: outside a working tree there is no boundary to stop at, and a walk that stops nowhere is the defect.

The marker walk is a NAME check that runs to the filesystem root, so it is not by itself a bound: an unprivileged `: > /tmp/.git` would otherwise hand every session in a plain directory beneath /tmp to a planted /tmp/.abcd, git having refused to answer for a directory that is not a repository. The fallback root is accepted only when its .git is a PLAUSIBLE repository — a directory carrying HEAD, or a regular file beginning "gitdir: " (plausibleRepository below, which states the shapes exactly) — and anything else takes the non-repo route: cwd, no walk, nothing above it consulted. That discriminator is what git reads, not a trust boundary on its own: laying out a real repository still passes it. The trust boundary is the ownership gate that runs next — a marker root whose owner is not the caller is refused, loudly, unless the caller has declared it in the user-scope home (iss-2609020259564193). Both bounds apply only where git will not answer; where it does, the toplevel it named stands whoever owns it.

A nested repository or submodule resolves its own toplevel either way and does not inherit the superproject's .abcd — correct scoping, visible to submodule workflows.

func SaveState

func SaveState(session string, st SessionState) error

SaveState durably persists the ledger for a session through the canonical fsutil.WriteFileAtomic (temp-write, fsync, rename, parent-dir fsync), so the write is crash-safe and a reader sees either the old file or the complete new one. The state dir is pre-created 0700 (WriteFileAtomic's own MkdirAll is a no-op once it exists); the session file is 0600.

func Signature

func Signature(d ResolvedDomain) string

Signature is the per-domain dedup key: an FNV-1a hash of the rendered block, so identical rendered content (defaults or override) dedups and any content drift invalidates. FNV is sufficient — this is dedup, not security.

func SwapUserHomeForTest added in v0.11.0

func SwapUserHomeForTest(fn func() (string, error)) (restore func())

SwapUserHomeForTest substitutes the home lookup Load reads the user layer through and returns the restore. It is exported because the front-door tests that load rules live in another package. Tests only; never called in production code, and never safe to call from a parallel test.

func Validate

func Validate(rs RuleSet) error

Validate checks structural invariants: schema_version == 1, every domain name matches [A-Z][A-Z0-9_]*, every state is active/dormant (or empty), every domain carries at least one rule, and no rule body is empty or whitespace-only. An empty rule body otherwise passes here and renders as a bare contentless "- " bullet in the injected block (iss-2608261550497978), and a domain with no rules at all — an override of {"rules": []}, or a custom domain declared without any — renders as a heading-only "## NAME" block, suppression wearing the domain's name (GHSA-22f8-qf5r-gjgq sibling). Neither silent shape is acceptable; {"state": "dormant"} is the way to silence a domain.

The ruleless-domain rule is a REFUSAL here and a per-domain skip in Load. It refuses here because Validate is what guards the bundled defaults, where a heading-only domain is a build error. Load reaches Validate with the ruleless domains already dropped and a note naming each (see dropRulelessDomains), because failing a repo's whole file — every domain, on an upgrade — is not proportionate to one malformed entry.

Types

type Domain

type Domain struct {
	State   string   `json:"state,omitempty"`
	Recall  []string `json:"recall,omitempty"`
	Aliases []string `json:"aliases,omitempty"`
	Rules   []string `json:"rules,omitempty"`
}

Domain is one keyed rule domain. The map key in RuleSet.Domains is its name; the name is therefore not a serialized field on the value.

type InjectResult

type InjectResult struct {
	Text     string
	Injected []string
	Active   []string
	Sources  map[string]string
	State    SessionState
}

InjectResult is the outcome of one prompt-router evaluation. Text is empty when nothing new is injected (a healthy no-match renders zero model-facing tokens, per D3). Sources says, per injected name, which layer the domain came from (SourceBundled, SourceUser or SourceRepo): the names alone cannot, and the out-of-band diagnostic must say whose words went into the context.

Active is the FULL set of domain names in force this turn, name-sorted and never nil (ruling J15, iss-2608261550580260): every domain that is not dormant, plus a dormant one this prompt activated with *NAME, and nothing at all under the kill switch. Injected names only what changed; Active is the removal signal a client that snapshots injected rules prunes against — a name it holds that Active omits has stopped, whether it was deleted, renamed or made dormant. It is never rendered into Text, so it costs the model's context nothing; a front door hands it to a machine reader out of band.

func Inject

func Inject(rs RuleSet, prompt string, prev SessionState, backstop int) InjectResult

Inject is the pure heart of the prompt-router hook: it recall-matches prompt, drops any matched domain already injected this session with an unchanged signature, renders the remainder, and returns the updated session state. It performs no I/O and never reflects prompt bytes into the output — the rendered text is abcd's own rule content only.

backstop is the fixed-N full-refresh interval; <= 0 uses DefaultRefreshBackstop. When the (incremented) prompt count is a multiple of the backstop, the ledger is cleared first so always-relevant domains re-inject.

func (InjectResult) Labels added in v0.7.1

func (r InjectResult) Labels() []string

Labels returns the injected names in order, each labelled the way the rendered heading is — "PII (repo override)" for a repo-sourced domain, "PII (user override)" for a user-scope one, the bare name otherwise — so the diagnostic and the block agree byte for byte.

type Resolution added in v0.8.0

type Resolution struct {
	Root  string
	Notes []string
}

Resolution is ResolveRoot's answer together with the diagnostics the resolution produced. Notes is empty in the ordinary case; a non-empty entry is a refusal or a downgrade a front door MUST print out of band (stderr, never the injected context), because a resolution that silently declined to read a repository's own configuration is the exact shape the bound exists to prevent (.abcd/development/principles/loud-staging.md).

func Resolve added in v0.8.0

func Resolve(cwd string) Resolution

Resolve is ResolveRoot with its diagnostics kept. ResolveRoot is the shorthand for the callers that have nowhere to print them.

type ResolvedDomain

type ResolvedDomain struct {
	Name   string `json:"name"`
	Source string `json:"source"`
	Domain
}

ResolvedDomain pairs a domain with its name for ordered rendering and dedup, and with its Source so every consumer can say whose words these are (GHSA-22f8-qf5r-gjgq): the renderer marks a repo-sourced heading, the hook diagnostic labels the name, and `rules --json` carries the field.

type RuleSet

type RuleSet struct {
	SchemaVersion int               `json:"schema_version"`
	Disabled      bool              `json:"disabled"`
	Domains       map[string]Domain `json:"domains"`
	// contains filtered or unexported fields
}

RuleSet is the merged, validated rule model: the bundled defaults overlaid with the per-repo override.

func Defaults

func Defaults() RuleSet

Defaults returns a deep copy of the binary-bundled default rule set, safe for the caller to mutate.

func Load

func Load(repoRoot string) (RuleSet, error)

Load returns the bundled defaults merged with the user scope's ~/.abcd.noindex/rules.json and then with <repoRoot>/.abcd/rules.json — bundled, then user, then repo, each layer overriding per field (itd-117, spc-23). An absent file contributes nothing, so a machine with neither file gets the defaults unchanged; a present file that cannot be read, parsed or validated is a fail-closed error naming that file, and no partial set is returned with it.

func Merge

func Merge(base, over RuleSet) RuleSet

Merge overlays over onto base. Domain fields are per-field: a field set on the override wins; an absent field inherits the base (so {"state":"dormant"} on a default domain silences it while keeping its recall and rules). New domain keys are added. The kill switch is sticky (either side can enable it). Every domain the override names is recorded as SourceRepo — the one moment the origin is still known, so the renderer and the diagnostics can say so later.

func (RuleSet) Active

func (rs RuleSet) Active() []ResolvedDomain

Active returns every injectable domain (state != dormant) in name-sorted order — the full set the diagnostic `abcd rules` render shows. The top-level kill switch yields nothing.

func (RuleSet) KillSwitchSources added in v0.11.0

func (rs RuleSet) KillSwitchSources() []string

KillSwitchSources returns the layers whose file set the kill switch, in layer order (SourceUser before SourceRepo); empty when the set is not disabled. The switch is sticky, so every layer named here has to clear it before anything injects again.

func (RuleSet) Lookup

func (rs RuleSet) Lookup(name string) (ResolvedDomain, bool)

Lookup returns one domain by name regardless of its state (a dormant domain is still inspectable); ok is false when the name is absent.

func (RuleSet) Match

func (rs RuleSet) Match(prompt string) []ResolvedDomain

Match returns the domains to inject for prompt, in deterministic (name-sorted) order. The top-level kill switch suppresses everything. Otherwise a domain is injected if a star-command names it (overriding dormant) or — when active — its recall keywords or aliases hit the prompt.

func (RuleSet) Notes added in v0.7.1

func (rs RuleSet) Notes() []string

Notes returns the diagnostics this load produced, in a stable order: a front door prints them out-of-band (stderr, never the injected context) so a dropped domain is loud rather than silently missing. An empty result is the normal case.

type SessionState

type SessionState struct {
	Count  int               `json:"count"`
	Ledger map[string]string `json:"ledger"` // domain name -> last-injected signature
}

SessionState is the per-session dedup ledger plus the prompt counter that drives the fixed-N refresh backstop.

func LoadState

func LoadState(session string) SessionState

LoadState reads the ledger for a session. A missing, oversized, or malformed file yields the zero state (a fresh session), never an error — dedup is a best-effort optimisation, not a correctness gate.

Jump to

Keyboard shortcuts

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