Documentation
¶
Overview ¶
Package dispatch is the shared CHECK-RUNNER at the heart of the new nature-based guardrails: given a matched declaration's `require` and `checks` and the context of a fired event, it runs them in order and returns one verdict — pass, or refuse-with-reason.
Why a package of its own ¶
Every nature that decides against an event does it the same way. A gate wakes on a trigger, evaluates `require` then `checks`, and blocks or admits. A file-guard (the next slice) wakes on a file's state and does the identical thing — same Prerequisite semantics, same Check ordering, same payload assembly, same judge substrate. The one part that differs is which PAYLOAD a check is handed (a file-guard's CheckPayload vs a gate's GateCheckPayload) and which JUDGE-INPUT a judge renders against (FileJudgeInput vs GateJudgeInput) — and the spec keeps even those at parity (dot-dir-file-store/main.tsp). So the running is factored here once, parameterised by the payload shape, and each nature's dispatch (in services/sr-session) supplies only the matched rule and the fired event.
This package REUSES rather than reinvents. The declaration types (Prerequisite, Check) and the payload/judge shapes are internal/declaration's; the runtime state maps (ContextState, GateState) are internal/natures'; the trajectory reader (for a `{skill}` prerequisite) is internal/transcript's; the judge substrate is the sr-agent binary, run BY NAME the same way the old-format judge hooks run it. What this package adds is the ordering, the native `require` evaluation, the payload assembly, and the script/judge/prepare execution.
The contract, in one place ¶
A caller builds a Request naming the require + checks, the fired event, the transcript path, the context[]/gates[] state maps the next slice populates, the guard's own folder (scripts resolve relative to it), and which nature this is (so the right payload shape is assembled). Run returns a Verdict: Refused plus a Reason when a prerequisite is missing or a check refused, or a clean pass.
Fail-closed ¶
The engine's default is that a check which cannot be RUN is a refusal, not a permission — a mechanism that failed must never read as approval (the same rule services/sr-session's old dispatch follows for a hook that cannot start). A script that will not execute, a prepare that fails, a judge whose substrate is missing: each refuses, with a reason naming what to fix. The ONE documented exception is a judge's own model-call flakiness, which an individual judge may choose to fail-open on inside its own verify script — but this runner's default for the judge substrate failing to run at all is closed, matching sr-agent's own --verify fail-closed default.
Index ¶
- Constants
- func CheckTemplate(src string) (err error)
- func ContextScriptFault(dir, script string) error
- func JudgeID(qualified, template string) string
- func ResolveExistingPrefix(path string) string
- func SkillFilePaths(workspace, name string) []string
- func SkillSubpagePaths(workspace, name, file string) []string
- type ContextEnterRequest
- type ContextExitRequest
- type FileHistory
- type HistoryPoint
- type HistoryState
- type Nature
- type Prepared
- type Request
- type Runner
- func (r Runner) CheckRequire(req Request) (Verdict, error)
- func (r Runner) EnterContext(req ContextEnterRequest) (payload map[string]any, active bool, v Verdict, err error)
- func (r Runner) ExitContext(req ContextExitRequest) (done bool, reason string, err error)
- func (r Runner) Judge(req Request, c declaration.Check, p Prepared) (Verdict, error)
- func (r Runner) PrepareJudge(req Request, c declaration.Check) (Prepared, Verdict, error)
- func (r Runner) PrerequisiteApplies(req Request, p declaration.Prerequisite) (bool, error)
- func (r Runner) RenderJudge(req Request, c declaration.Check, p Prepared) (rendered, refusal string, err error)
- func (r Runner) Run(req Request) (Verdict, error)
- func (r Runner) RunScript(req Request, c declaration.Check, p Prepared) (Verdict, error)
- func (r Runner) RunSubjects(req Request, script string) ([]byte, Verdict, error)
- type StructureGate
- type StructureSet
- type UncitedChange
- type Verdict
Constants ¶
const JudgeMocksEnv = "SR_CHECKS_JUDGE_MOCKS"
JudgeMocksEnv, when set, replaces the model behind every judge with a script: a JSON object mapping a judge id to the absolute path of a script. The id is `<plugin>/<nature>/<rule>/<template stem>` for a plugin's rule and `<nature>/<rule>/<template stem>` for the project's own (the stem of `tests-prove-it.md.j2` is `tests-prove-it`).
The script gets the judge's input on stdin (the rendered prompt, then the check's payload as JSON) and prints `{"pass": bool, "reasoning": "..."}`. A script that fails to run, exits non-zero or prints anything else makes the check an ERROR, never a pass; so does a judge with no entry, so a model is never reached by accident while the variable is set. The variable is read by the engine itself, so it holds under sr-test, `sr-checks run` and the hooks alike.
Variables ¶
This section is empty.
Functions ¶
func CheckTemplate ¶ added in v0.3.0
CheckTemplate reports whether a judge template can be rendered by this engine: it parses, and every filter and test it names — in an expression, a `{% filter %}` block, a `{% set %}`, an `is` test, or as the literal name map/select/reject/selectattr/rejectattr apply; in a branch that runs or one that does not — is one this engine has. The load check runs it on every judge template, and every render runs it first, so a template that cannot render is refused with its own positions, before anything is rewritten.
func ContextScriptFault ¶ added in v0.5.0
ContextScriptFault reports why a context's declared enter or exit script cannot run, or nil when it can (or may: a bare word is a program on PATH, left to the exec). Unlike scriptexec.VerifyDeclared, a script file that does not exist is a fault here: a context whose enter or exit is missing is never entered or never closed, and nothing else says so.
func ResolveExistingPrefix ¶ added in v0.2.1
ResolveExistingPrefix resolves the deepest existing ancestor of path through its symlinks and re-attaches the rest, so two spellings of the same directory (macOS's /tmp → /private/tmp, /var → /private/var, or a project reached through a symlinked path) compare equal after resolution even when path itself does not exist yet.
A path being CREATED does not exist yet, so filepath.EvalSymlinks on the whole of it fails outright — this walks up to the first ancestor that does exist, resolves THAT, and rejoins the part that was not there.
Shared by two callers that both compare a session-reported path against a resolved root and must not be fooled by symlink spelling:
- services/sr-session's structure gate (a write's path against the project root gitrepo.Root resolves, and against another project's root when the write lands outside this one);
- this package's `{skill}`/`{skill, files}` prerequisite (a Read tool_use or `cat`'d Bash command's path against the candidate skill/subpage paths SkillFilePaths/SkillSubpagePaths compute from Request.Workspace, which is itself a git root — same symlink exposure, same fix).
Measured on macOS specifically: os.MkdirTemp's own result is spelled under /var, while `git rev-parse --show-toplevel` (gitrepo.Root, which Request.Workspace is built from) resolves the /var → /private/var symlink and answers under /private/var — so a session-reported path built from the unresolved spelling never string-equals a candidate built from the resolved one, and a bare `{skill, files}` prerequisite refused a subpage that had genuinely just been read, in exactly the shape it was told to read it.
func SkillFilePaths ¶
SkillFilePaths returns every path a skill named `name` could be loaded from, in resolution order, given the project workspace it is running against.
workspace is the tree's own root — Request.Workspace, the git root a session is anchored to, which is exactly the projectDir harness.Resolve itself takes (see services/sr-session's natureDeclarationStore, which resolves plugins from the identical anchor). An empty workspace resolves nothing: without a project root there is no `.claude/` to look under and no settings file to read plugins from, so the honest answer is no candidates rather than a guess rooted at the process's own working directory.
A home directory that cannot be located, or a settings file that cannot be read, degrades to "no plugin candidates" rather than an error — the same fail-open natureDeclarationStore itself applies to the identical failure, because a plugin skill this cannot find is not a reason to also stop looking for the project's OWN skill file, which needs no plugin resolution at all.
func SkillSubpagePaths ¶ added in v0.2.1
SkillSubpagePaths returns every path a FILE INSIDE skill `name` — file, relative to the skill's own directory (the one holding its SKILL.md), e.g. "script-checks.md" — could live at, in the same resolution order SkillFilePaths uses for the skill itself.
This is the `require: [{skill, files}]` prerequisite's read-detection half (see require.go): a `files` entry names a page relative to the skill, and this connects that name to the concrete path(s) a Read tool_use or a file-reading Bash command would name — the identical join SkillFilePaths itself does with the one hardcoded name "SKILL.md".
Types ¶
type ContextEnterRequest ¶
type ContextEnterRequest struct {
// Enter is the enter script path, relative to Dir.
Enter string
// Event is the fired `on` trigger — a ContextEventKind (a Post variant is
// legal, unlike a gate). Carried into the payload flat, as `event`.
Event event.Event
// TranscriptPath names the session record enter reads to recognise "now it's
// the time" and extract the payload. May be "".
TranscriptPath string
// Gates is every declared gate's most recent verdict, by name — what enter
// reads as `gates[<name>].status` to gate its own activation.
Gates map[string]natures.GateState
// CurrentContext is this context's own last {active, payload}, so enter sees
// what was there before deciding whether to grow, replace, or leave it.
CurrentContext natures.ContextState
// Dir is the context's own folder — enter resolves relative to it, and its
// scripts find their siblings there.
Dir string
// Name is the context's name, for the script's SR_GUARDRAIL and for
// diagnostics.
Name string
// Workspace and SessionID are the session facts enter's environment carries —
// SR_WORKSPACE (to resolve a workspace-relative path and key its own state) and
// SR_SESSION_ID — the same as a check's. Empty leaves them unset.
Workspace string
SessionID string
// AgentID is the sub-agent the hook fired inside, emitted as SR_AGENT_ID —
// see Request.AgentID.
AgentID string
// LaunchedBy is the re-entry provenance emitted as SLOPRAIL_LAUNCHED_BY, so a
// context whose enter spawns sr-agent does not re-enter itself on the launched
// agent's writes. The caller computes it (appendLaunchedBy) and threads it
// through; the runner forwards it onto the scriptCall. See scriptCall.LaunchedBy.
LaunchedBy string
}
ContextEnterRequest is everything EnterContext needs to run one context's `enter` on one fired trigger.
It carries the same collaborators a check does — the fired event, the transcript path, the gates[] map (enter may gate its own activation on a gate's verdict), and the context's own folder — plus CurrentContext, this context's own last {active, payload}, which enter needs because it runs on EVERY trigger (active or not) and its stdout REPLACES the payload rather than merging: a script that wants to grow a list reads currentContext.payload and returns the grown version itself (spec ContextDeclaration.enter).
type ContextExitRequest ¶
type ContextExitRequest struct {
// Exit is the exit script path, relative to Dir.
Exit string
// Event is the Stop that triggered the exit check.
Event event.Event
// TranscriptPath names the session record exit reads. May be "".
TranscriptPath string
// Gates is every declared gate's most recent verdict, by name — how the
// paired context reads a gate's own verdict back to decide active/inactive.
Gates map[string]natures.GateState
// CurrentContext is this context's own entry from the context[] map.
CurrentContext natures.ContextState
// Dir is the context's own folder.
Dir string
// Name is the context's name.
Name string
// Workspace and SessionID are the session facts exit's environment carries —
// see ContextEnterRequest. Empty leaves them unset.
Workspace string
SessionID string
// AgentID is the sub-agent the hook fired inside, emitted as SR_AGENT_ID —
// see Request.AgentID.
AgentID string
// LaunchedBy is the re-entry provenance emitted as SLOPRAIL_LAUNCHED_BY — see
// ContextEnterRequest.LaunchedBy. The caller threads it through so an exit that
// spawns sr-agent does not re-enter this context on the launched agent's writes.
LaunchedBy string
}
ContextExitRequest is everything ExitContext needs to run one context's `exit` on a Stop.
The Stop counterpart of ContextEnterRequest. `exit` has no `on` of its own — it is always consulted on the Stop while the context is active — so Event is always the Stop the caller passes. It reads the same gates[] map (to read a paired gate's verdict back) and its own CurrentContext.
type FileHistory ¶ added in v0.3.0
type FileHistory struct {
Baseline, Current HistoryState
Points []HistoryPoint
// Content returns the content a hash names, and whether it is known.
Content func(hash string) (string, bool)
}
FileHistory is one path's history, from its session baseline to its current content.
type HistoryPoint ¶ added in v0.3.0
type HistoryPoint struct {
// Foreign marks a change the agent did not make: the file as it stood at
// the start of one of the agent's cycles, when that differs from how the
// agent left it (or, at the session's first hook, from the baseline). Such
// a change is never charged to the agent. From is where it started; nil
// when that is not known (the session's first hook), which charges nothing
// before it either.
Foreign bool
From *HistoryState
// FromAt is when the file stood at From (the agent's last Stop). A cited
// change of another session landing after it — a background sub-agent's,
// say — explains a difference from From that is not the agent's either.
FromAt int64
// BetweenTurns marks a change that landed between the agent's Stop and its
// next hook while work it had started may still have been running (By
// names that work): charged like the agent's own, from From to After, and
// the refusal says why.
BetweenTurns bool
By string
// Pools are the pools the citations of a cited change resolved in. A cited
// change grounds a requirement only when one of them is one it accepts:
// a change cited in the wrong pool is, to that requirement, uncited.
Pools []transcript.SourceType
// Whole marks a cited change that stated the file's entire content (an
// sr-file write): it grounds the file as it left it, so nothing before it
// is left ungrounded.
Whole bool
Before, After HistoryState
// At orders points across the stores a Stop merges (the session's own and
// its sub-agents' or dispatcher's).
At int64
}
HistoryPoint is one step of a file's history.
type HistoryState ¶ added in v0.3.0
HistoryState is a file's content at one moment, by hash, or its absence.
type Nature ¶
type Nature string
Nature selects which payload/judge-input shape a check is handed — the one axis on which the two natures wired so far actually differ.
A file-guard's checks receive declaration.CheckPayload (and a judge FileJudgeInput); a gate's receive declaration.GateCheckPayload (and a judge GateJudgeInput). The runner is otherwise identical, so this is a small enum rather than two parallel entry points — a caller says which nature it is and the runner assembles the matching shape.
type Prepared ¶ added in v0.4.0
type Prepared struct {
// Skip: prepare asked not to run the judge; the check abstains.
Skip bool
// Context is prepare's additionalContext, folded into the judge's input.
Context declaration.PreparedContext
}
Prepared is what a judge check's prepare step concluded, ready for the judge.
type Request ¶
type Request struct {
// Nature is which payload/judge-input shape the checks are handed.
Nature Nature
// Require are the preconditions that must hold before any check runs, in
// order. A failed prerequisite refuses before a check is paid for.
Require []declaration.Prerequisite
// Checks are the checks to run, in order, first-refusal-ends-it.
Checks []declaration.Check
// Event is the fired event the checks decide about — a pre-action GateEventKind
// for a gate, a file event for a file-guard. Carried into the payload as-is.
Event event.Event
// TranscriptPath names the session record a check (or the native `{skill}`
// prerequisite) reads for what the event does not carry. May be "" when the
// session's record could not be resolved; a `{skill}` prerequisite then
// refuses, because a precondition it cannot check has not passed.
TranscriptPath string
// Subagent is true when the agent being judged is a sub-agent — decided by
// the hook that fired (SubagentStop), never read from a record. A refusal for
// want of the user's words then tells it to hand the change back to its
// parent, which can ask the user, rather than to ask the user itself.
Subagent bool
// Context is every declared context by name, at parity with the nature's match
// scope — what a check reads as `context[<name>]`, and what a `{context}`
// prerequisite consults. Nil is an empty map.
Context map[string]natures.ContextState
// Gates is every declared gate's most recent verdict by name. Carried for the
// payloads that expose it (a context's enter/exit; not a gate/file-guard check,
// which the spec gives no `gates`) and threaded through for the next slice. Nil
// is an empty map.
Gates map[string]natures.GateState
// Dir is the rule's own folder, against which a check's `script`, `judge` and
// `prepare` paths resolve — the same relative-to-the-guard rule the spec states
// and the old format's guardrailDir provides.
Dir string
// GuardName is the rule's name, used only for diagnostics and for the judge's
// isolation. Not load-bearing for the verdict.
GuardName string
// Qualified is the rule's `<plugin>/<nature>/<name>` (`<nature>/<name>` in-repo): what a
// judge is identified by under SR_CHECKS_JUDGE_MOCKS. Empty falls back to the nature and GuardName.
Qualified string
// Workspace is the tree being guarded, passed into a check's environment as
// SR_WORKSPACE so a script can resolve a workspace-relative path (a goal's
// verify.sh under <workspace>/goal/…) and key its own `sr-session state`. The
// caller resolves it (the payload's cwd, git-rooted); the runner only forwards
// it. Empty leaves SR_WORKSPACE unset.
Workspace string
// ProjectRoot is the tree a JUDGE reads as the project, when it differs from
// Workspace: a changeset's read-only snapshot of the tip being judged (SR_TREE).
// A file-guard judges commits, so the judge must see the committed tree of that
// tip, never the folder's checked-out working tree (another branch, or
// uncommitted edits). Empty falls back to Workspace.
ProjectRoot string
// SessionID is the conversation's identity, passed as SR_SESSION_ID so a
// check's own `sr-session state` lands in this session's keyspace. Empty leaves
// it unset (diagnosable). Not load-bearing for the verdict.
SessionID string
// AgentID is the sub-agent the hook fired inside, as the harness reported it,
// passed as SR_AGENT_ID (empty, but set, in the main session) so a rule can
// tell a sub-agent's action from the main session's. Not load-bearing for the
// verdict.
AgentID string
// LaunchedBy is the re-entry provenance — the colon-separated list of guards
// whose checks are on the current call stack — passed into every check's
// environment as SLOPRAIL_LAUNCHED_BY. A check that spawns sr-agent (a judge,
// or a script that shells it) carries this across the exec into the launched
// agent, whose own hooks read it and decline to re-fire THOSE guards on the
// agent's writes. Without it a judging guard re-fires on itself and recurses.
//
// The caller computes it with services/sr-session's appendLaunchedBy (this
// guard appended to any inherited value, deduped) because this package sits
// below that one and cannot import its provenance logic. The runner only
// forwards it onto the scriptCall / judgeCall. Empty leaves the variable unset
// — correct for a check that cannot launch an agent, harmless for one whose own
// name is the only entry. Not load-bearing for THIS request's verdict; it
// governs the verdict of the dispatch one exec down.
LaunchedBy string
// History is, for a Post file event at Stop whose path had cited changes
// this session, the file's history from the session baseline (see
// FileHistory). A citation grounds only the change it rode on, so a
// `citation` prerequisite holds only when its `when` waives every part of
// the change the agent made that no cited change in its pools made (with no
// `when`, no such part may exist). Nil for every other event — a Pre event
// IS one change, and its citations ride on it.
History *FileHistory
// Changeset, when set, is what a file-guard's checks are handed instead of a
// file event: a script's stdin and a judge's template variables are this
// payload (`event: {kind: "Changeset"}`, `changeset`, `subject`, ...), and a
// judge additionally gets `change`, the combined diff of the selected files.
// Event still carries the changeset's citations (under `citations`), which is
// what a `require: citation` prerequisite reads.
Changeset *changeset.Payload
// Env is extra environment for every script and judge this request runs,
// appended after the inherited one so it wins — SR_TREE, SR_BASE and SR_HEAD for
// a changeset: the read-only snapshot of head the checks read, and the range.
Env []string
}
Request is everything the check-runner needs to reach a verdict on one fired event against one matched rule.
It carries the state maps (Context, Gates) as INPUTS rather than reading them itself, which is the coordination point with the next slice: for this slice a gate's `require: [{context}]` reads whatever context state exists (an engine ordering guarantee the full context lifecycle will provide), and the maps are threaded through so the slice that populates them changes only the caller, not this runner. Neither map is required — a nil map is an empty world, which is the correct reading before any context has entered or any gate has run.
type Runner ¶
type Runner struct {
// contains filtered or unexported fields
}
Runner runs a Request's require + checks and returns a verdict.
A struct rather than a bare function so its two collaborators — the trajectory reader a `{skill}` prerequisite uses, and the judge substrate a judge check invokes — can be substituted in a unit test without a real transcript file or a real model call. Both default to the production implementations when the zero-value Runner is used, so a caller that wants the real thing writes `dispatch.Runner{}.Run(req)`.
func (Runner) CheckRequire ¶
CheckRequire evaluates only req.Require, without running any check. A changeset evaluation records each prerequisite as a check of its own, so it asks one at a time.
func (Runner) EnterContext ¶
func (r Runner) EnterContext(req ContextEnterRequest) (payload map[string]any, active bool, v Verdict, err error)
EnterContext runs a context's `enter` script and returns the payload it produced, whether the context should be active, and a verdict when the script could not run.
What enter's exit code means ¶
enter is not a pass/fail gate the way a check is: it is the script that DECIDES activation and extracts the payload. So a clean exit means "this context is active this cycle" and its stdout (a flat JSON object) REPLACES the payload. Exiting WITHOUT output leaves the payload as it was (the spec: "Exiting without producing output leaves the context's state as it was") — so the previous payload is returned unchanged in that case, and active is still true, because enter ran and did not decline.
A NON-ZERO exit is how an enter declines to (re-)activate on this trigger — the context is not entered by this occurrence. This is distinct from a script that could not RUN AT ALL (a missing/again-non-executable script, a NUL in the command), which is a mechanism failure: fail-closed, returned as a refusing Verdict so the caller can report it, and the context's state is left untouched (neither activated nor its payload changed) because a mechanism that failed decided nothing.
That refusal is NOT a decline and must not be read as one: a context left inactive by a broken enter silently disarms every rule that reads it. The caller (services/sr-session) therefore refuses the event that triggered the enter (a Pre* kind is denied; a Post* kind is refused at the Stop that handles it), and the context's state is untouched.
The returns: (payload, active, verdict, err). active is meaningful only when verdict is a pass. When verdict.Refused, the script could not run (or printed output that is not a flat JSON object) and the caller must refuse the trigger with verdict.Reason.
func (Runner) ExitContext ¶
func (r Runner) ExitContext(req ContextExitRequest) (done bool, reason string, err error)
ExitContext runs a context's `exit` script on a Stop and reports whether the context now considers itself DONE.
exit does NOT block the Stop ¶
This is the reversal (spec slice 7): a context's exit only flips its own `active`. So `done` means "this context is DONE — mark it inactive", NOT "block the turn". The caller reads it to set active=false and NEVER turns it into a block. A gate bound to Stop is what blocks a turn, and that goes through Runner.Run.
Exit-code convention (the examples' convention, kept) ¶
A clean exit (zero) means the context is DONE — deactivate. A NON-ZERO exit means NOT done — stay active for another cycle. This is exactly what the shipped example (sloprail-community's examples/eval-loop-maxing goal-tracking/exit.sh) does:
if [ "$status" = "pass" ]; then exit 0 # target met — deactivate fi; exit 1 # not met — stay active
It is the natural shell reading of a "are we done?" question — exit 0 is "yes/success" — and matches the spec's "Some contexts never say done, staying active for the rest of the session; that falls out of the script": such a context's exit simply never exits zero.
A script that could not RUN AT ALL is folded into the NON-ZERO case — stay active — and that IS the fail-closed direction here. A context is read two ways and both read an ACTIVE context as MORE guarding: a gate's `require: [{context}]` runs (and can block) only while the context is active, and a file-guard's `context[x].active` match applies only while it is active. So keeping a context active when its exit could not run makes every reader keep guarding, which is the safe way to be wrong — the same fail-closed principle a check follows, arriving at "stay active" because of what reads this flag.
Returns (done, reason). done is whether to mark the context inactive. reason is the cause when the exit script could not run, including a kill on the timeout (empty on a clean exit or a plain non-zero "stay active"); the Stop refuses on it.
func (Runner) PrepareJudge ¶ added in v0.4.0
PrepareJudge runs a check's (judge or script) prepare step, when it has one. A refused verdict means prepare failed (the check fails closed, carrying prepare's own words); otherwise Prepared says whether to skip the judge and what context to give it. Split from Judge so a caller that caches verdicts can fingerprint what the judge is about to receive — including what prepare inlined — before paying for the model.
func (Runner) PrerequisiteApplies ¶ added in v0.4.0
func (r Runner) PrerequisiteApplies(req Request, p declaration.Prerequisite) (bool, error)
PrerequisiteApplies reports whether a prerequisite applies to the request: true when it has no `when`, else what its `when` script says (see prerequisiteApplies: only exit 1 waives).
func (Runner) RenderJudge ¶ added in v0.4.0
func (r Runner) RenderJudge(req Request, c declaration.Check, p Prepared) (rendered, refusal string, err error)
RenderJudge is the judge's fully rendered prompt (the template with the slice and prepare's additionalContext folded in), exactly as Judge will render it: what a cache keys a verdict on. A non-empty refusal is the reason the prompt cannot be rendered; Judge refuses with the same words.
func (Runner) Run ¶
Run evaluates the request and returns a verdict.
The order is the spec's: require first (a missing prerequisite refuses before a check is paid for), then checks in declaration order, first-refusal-ends-it. A pure-require rule (no checks) refuses on the missing precondition and otherwise passes; a pure-checks rule refuses on the first failing check. A rule with neither passes — the loaders guarantee a gate has at least one, so that case is a caller's, not this runner's, to forbid. sr:invariant checks/requirements-before-checks sr:invariant checks/first-refusal-ends-the-run
func (Runner) RunScript ¶ added in v0.4.0
RunScript runs one script check, after prepare: the payload on stdin (plus prepare's additionalContext), exit code the verdict.
func (Runner) RunSubjects ¶ added in v0.4.0
RunSubjects runs a rule's `subjects:` script: the changeset payload on stdin and nothing of the session. Its stdout is the subjects (changeset.ParseSubjects); a non-zero exit is a refusal carrying the script's words.
type StructureGate ¶
type StructureGate struct {
// contains filtered or unexported fields
}
StructureGate is a compiled structure gate: the allow and deny entries turned into path matchers once, so a malformed pattern is a construction error rather than a per-path surprise.
Built from a declaration.StructureGate (already loaded and validated), it is the runtime form the dispatch calls Allows on for each written path. The loader already refuses a bad glob/regex, so CompileStructureGate should not fail for a declaration that loaded — but it returns an error rather than panicking, because a compile that disagrees with the load check must surface loudly, not decide enforcement silently.
func CompileStructureGate ¶
func CompileStructureGate(sg declaration.StructureGate) (*StructureGate, error)
CompileStructureGate turns a loaded structure declaration into a runtime matcher.
Each entry is compiled by its kind: a glob through the file-guard glob machinery (so structure globs and file-guard globs are the same grammar and anchoring), a regex through Go's regexp. An entry setting neither — impossible for a loaded declaration — is a construction error rather than a silently skipped entry.
func (*StructureGate) Allows ¶
func (sg *StructureGate) Allows(path string) (allowed bool, reason string)
Allows reports whether a write to path is permitted by this structure gate, and on a refusal why.
Deny-by-default: a path allowed by no `allow` entry is refused. A path that IS allowed is then checked against `deny`, which carves exceptions back out — a match there refuses. So the order is: must match some allow, must match no deny.
A matcher that ERRORS (a glob handed a non-string path, say — not reachable here since path is a string, but the interface admits it) is treated as a refusal for that decision, fail-closed: an allow entry that could not be evaluated has not established that the path is allowed, and a deny entry that could not be evaluated is treated as matching, because the engine cannot show the path is outside the exception. Both directions refuse, which is the safe way to be wrong about a write. sr:invariant structure/deny-by-default
type StructureSet ¶
type StructureSet struct {
// contains filtered or unexported fields
}
StructureSet is every loaded structure gate compiled together: the project's own (whole tree), if any, and each plugin's with the folders its `scope` owns.
func CompileStructureSet ¶
func CompileStructureSet(gates []declaration.StructureGate) (*StructureSet, error)
CompileStructureSet compiles every loaded structure gate. A project gate is the one with no plugin origin; every other one must carry a scope (the loader already refused any that do not).
func (*StructureSet) Decide ¶
func (s *StructureSet) Decide(path string) (bool, string)
Decide reports whether a write to path is permitted by the combined structure gates, and on a refusal why — naming the source that decided.
The order, exactly:
- OWNERS are the plugins whose scope the path lies in. More than one → refuse, naming every owner (an ownership conflict: nobody can say which rule is meant, so neither is guessed).
- Exactly one owner: the project's `deny` still VETOES (the project always keeps the last word on its own tree); otherwise the owner decides — its `allow` must match and its `deny` must not. The project's `allow` does NOT widen an owned scope.
- No owner: the project's structure decides exactly as it always has (allow must match, deny must not); with no project structure the write is permitted — a plugin can lock down only its own scope.
func (*StructureSet) Deciders ¶ added in v0.5.0
func (s *StructureSet) Deciders(path string) []string
Deciders names the structure gates that decide a write to path, by their disable key (`<plugin>/structure`, `structure` for the project's): the owning plugin (every owner, in a conflict), else the project's own. Empty when no gate has a say.
func (*StructureSet) Empty ¶
func (s *StructureSet) Empty() bool
Empty reports whether the set holds no structure gate at all.
type UncitedChange ¶ added in v0.3.0
type UncitedChange struct {
FromExists bool
From string
ToExists bool
To string
// ToKnown is false when To could not be read (the settled file, unreadable).
ToKnown bool
// BetweenTurns: the change landed after the agent's last Stop, while work
// it had started (By) may still have been running.
BetweenTurns bool
By string
}
UncitedChange is one stretch of a file's history that no citation rode on: the file as it stood before (From, absent when !FromExists) and after (To, absent when !ToExists).
type Verdict ¶
type Verdict struct {
// Refused reports whether the action must not proceed. When true, Reason is
// non-empty.
Refused bool
// Reason is what to tell the agent on a refusal — the prerequisite's remedy,
// or the check's own words. Never empty when Refused.
Reason string
// Abstained reports that this ONE check reached no verdict and drops out of the
// chain — the other checks decide. Never true together with Refused. Set only by
// a judge check whose prepare asked to skip; the loop treats it as "continue,
// count nothing", and Run never surfaces it (an all-abstain chain permits, the
// same as reaching the end with no refusal).
Abstained bool
// NoVerdict reports that a check refused for want of an answer, not as a verdict on the
// content. For a judge: it produced nothing the verifier could read, timed out, could not
// start, or the model's transport failed. For a script or a prepare: it could not be run
// at all, or said {"error": true} on stdout.
// Only with Refused. Set by the check call itself (askJudge, runScriptCheck, runPrepare),
// so callers need not read the Reason text.
NoVerdict bool
// sr-agent named: "usage limit", "authentication", "version skew" or "other". Only with
// NoVerdict. It lets a run report one "judges unavailable" outage instead of a refusal per check.
Unavailable string
}
Verdict is what the check-runner concluded about one fired event.
Refusal is a field rather than an empty-reason convention, the same design the old dispatch's verdict uses and for the same reason: encoding "refused" as "said something" is what let a refusal with nothing to say read as consent. A clean pass is the zero value.
Abstained is a THIRD state, distinct from both — a check that reached no verdict of its own (a judge whose prepare emitted `skip`; see runJudgeCheck). It is meaningful only WITHIN the check loop: an abstaining check neither refuses nor counts as an affirmative pass, so the loop continues to the next check rather than ending. It is an internal signal — Run itself never returns an Abstained verdict to a caller (it collapses to the clean pass when the whole chain reached no refusal), so every consumer still reads only Refused/Reason exactly as before.