commandmod

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package commandmod is the module for commands: what a shell command line is about to run.

One command line is rarely one program. A pipeline, an `&&` chain, a subshell, a `sudo` or an `xargs` each nest invocations inside a single string, and a rule about what an agent is about to run has to see all of them. This walks that structure once and emits every invocation it finds, so no rule has to recurse through shell syntax itself — and so nesting an invocation one level deeper does not defeat a rule written against it.

Resolution has a floor. A program named by a variable, a payload decoded and piped to a shell, splitting that depends on the runtime IFS: none of these can be known without running them, and running them is exactly what a guardrail must not do. What can be seen is emitted; what cannot is left alone rather than guessed at. This is a correctness aid, never a security boundary.

Index

Constants

View Source
const (
	FieldRaw         = "raw"
	FieldInvocations = "invocations"

	// Keys within one entry of FieldInvocations. Not fields of the kind: a
	// matcher reads them off an element of the list, and the declaration
	// describes the list itself.
	KeyBin         = "bin"
	KeyArgv        = "argv"
	KeyGaps        = "gaps"
	KeyGitGapEarly = "gitGapEarly"
	KeyFlags       = "flags"
	KeyCwd         = "cwd"
	KeyEnv         = "env"
	KeyStdin       = "stdin"
	KeyStdinKnown  = "stdinKnown"
)

Field names. They appear here, in Kinds below, and in the conversion in event.go — nowhere else, so a rename cannot leave a matcher checking against a name the events no longer carry.

View Source
const KindPreInvoke = "PreCommandInvoke"

KindPreInvoke is the one kind this module declares.

One kind, not one per program or one per shell construct. What a rule asks is whether the agent is about to run something, and the answer is the same question whether the something sits in a pipeline or behind a sudo. The nesting is flattened into the event rather than encoded in its name.

There is no Post counterpart. A file has a settled state a diff can establish afterwards; a command that already ran has no equivalent — what it changed shows up as the file module's Post events, which is where a rule about consequences belongs.

View Source
const Name = "command"

Name identifies this module. It is how the engine reports which module produced an event, and how a module is switched off — not a prefix the kinds carry.

Variables

View Source
var HarnessCommandTools = map[string]bool{
	"Bash": true,
}

HarnessCommandTools names every tool whose arguments are a shell command line — the shape both commandmod.Extract and filemod.extractCommand read out of a `command` key.

View Source
var HarnessWriteTools = map[string]bool{
	"Write":        true,
	"Edit":         true,
	"MultiEdit":    true,
	"NotebookEdit": true,
}

HarnessWriteTools names every tool whose arguments state a file write, under the shape pendingArgs already models: content, old_string/new_string, edits[], or a notebook edit's new_source.

Read is deliberately absent — see statesAWrite and extractPending's doc comment for the defect its absence fixes.

Functions

func AndChain added in v0.3.0

func AndChain(raw string) (calls [][]string, ok bool)

AndChain returns the calls of raw, in the order they run, when raw is ONE statement made only of plain calls joined by && — `a && b && c` — each as its exact argument vector. In such a line a call that fails stops every call after it, which is what lets a caller that knows a call will fail refuse the whole line and lose nothing that ran before it, if nothing before it had an effect.

The calls are returned only up to the first one whose words are not exact literals — a bare word or a single-quoted one, with nothing the shell would expand, glob or unescape — since from there on what runs is not what the line spells. ok is false for anything else: several statements (`a; b` runs b whatever a did), `||`, pipes, redirections other than a here-document, assignments, a background job, or a line that does not parse.

func Detaches added in v0.3.0

func Detaches(raw string) (bool, string)

Detaches reports whether running raw may leave work running after the line itself returns, and names that work: a program that detaches work (nohup, setsid, disown, at, crontab, tmux, screen, …), a coprocess, or a statement sent to the background (`&`) that nothing in the same line waits for. A `cmd & … wait` ends its work inside the call, and so does `&` that is only text — in a quoted argument, a URL, a here-document. A bare `wait` (no arguments) reaps every job backgrounded so far. `wait` given an argument — `wait $!`, `wait -n`, `wait 1234` — reaps at most one, so it is only weighed against how many jobs the line backgrounded, not assumed to cover them all: one job and one targeted wait clears it, but `job1 & job2 & wait $!` leaves job1 running (bash's `$!` names the last background PID) and is reported as detaching by job1.

`wait $!` specifically is stricter still: `$!` is set once, by the MOST RECENT `&`, and does not change until another job is backgrounded — so a second `wait $!` with no new `&` in between targets the exact same job the first one did, not a different one. `job1 & job2 & wait $!; wait $!` still leaves job1 running: both waits target job2. Only the FIRST `wait $!` (or `wait "$!"`) since the last `&` can cover a job at all; every further one before the next `&` is credited as covering nothing new. `wait -n` and `wait <pid>` do not have this problem — `-n` can reap a different job each call, and a literal PID is assumed to name a real, distinct job — so those keep being tallied one-per-call against the count of backgrounded jobs. A shell's `-c` command line (`bash -c 'x &'`) is parsed the same way, as a line of its own.

A line that does not parse is reported as detaching: whether it could is then unknown, and the answer decides whether a change that lands later can be set aside as not the agent's.

func EchoesRecord added in v0.4.0

func EchoesRecord(raw string) bool

EchoesRecord reports whether the output of the shell command raw is the agent's own text read back: one of sloprail's binaries (sr, sr-session, sr-file, sr-mark, sr-checks, sr-agent, ...), git with a message-printing subcommand, or anything that reads a path inside .git/ (COMMIT_EDITMSG, logs/HEAD, ...).

func OnlyCalls added in v0.3.0

func OnlyCalls(raw string, allowed func(literals []string) bool) bool

OnlyCalls reports whether EXECUTING raw can do nothing but run calls that allowed accepts — the precondition for running an agent's command line ahead of time to learn what it would do.

It is an allowlist over the parsed syntax tree, never a string test. A line passes only when every statement is a plain call (or an &&/|| chain of them) whose leading literal words allowed accepts, and nothing anywhere in it can run code, touch a file, or mean something else to the shell that later runs it for real:

  • no expansion but a leading `~/` — no command or process substitution (`$(...)`, backticks, `<(...)` run code during EXPANSION, before any allowed program starts), and no parameter or arithmetic expansion either: `${X:=a[$(id)]}` then `$((X))`, `${!X}` and `${X@P}` all run code in bash, and even a plain `$X` is read from an environment the ahead-of-time run does not share with the real one (`$ZSH_VERSION` is set in one shell and not the other), so what it predicts is not what runs. A word opening with `=` is refused for the same reason: zsh expands it to a command's path;
  • no unquoted glob or brace character anywhere in a word (`*`, `?`, `[`, `{`, and zsh's extended `^`, `#`, `~`): the ahead-of-time run is bash, the real one is the user's shell, and the two expand a pattern differently — zsh reads `memories/**/a.md` recursively where bash leaves it as text, and brace, extglob and EXTENDED_GLOB rules differ too — so the file the dry run judged would not be the file the real run changes. Quoted (`'notes-*.md'`) or escaped (`notes-\*.md`), the character is text to every shell and the word stays pure. `~` is admitted only as a bare leading `~` or `~/`, which is $HOME to both; `~name` is a user's home to bash and may be a named directory to zsh;
  • no redirection but here-documents/here-strings on stdin and descriptor duplication to a NUMBER (`2>&1`) — `>&out.txt` writes a file in bash;
  • no pipes, subshells, groups, conditionals, loops, functions, background jobs or coprocesses;
  • no assignments, not even as a call's prefix: `PATH=. prog` picks which program runs, and any variable the allowed program itself reads (a mode switch, a file to trust) would be the line's to set.

allowed receives each call's leading LITERAL words (the program name first, then any literal words up to the first expansion), so it can check a program and a subcommand without being handed text an expansion produced. No word it is handed can expand to anything but itself (or a leading `~/` to $HOME). An unparseable or empty line is not pure.

func ReadsFile

func ReadsFile(raw, path string) bool

ReadsFile reports whether a command line reads path's bytes in full, either through a known whole-file reader (readBins) or a `<`/`<>` redirection.

path is compared for exact string equality against what the command line names, after quoting is undone and expansion is applied — the same certainty standard every other path in this package is held to, so `cat $F` where F is unset in the empty environment answers false rather than a guess. A caller wanting to compare against a resolved absolute path should pass one; this does no normalisation of its own; commandmod does not resolve against a root for the same reason FileTargets does not — that is a question about the tree, which is the caller's to answer, not the command line's.

Types

type CommandEvent

type CommandEvent struct {
	// Raw is the command line exactly as written, before any resolution.
	Raw string

	// Invocations is every invocation the line performs, flattened out of the
	// pipelines, chains, subshells and wrappers that nest them. Order is the
	// order they appear in the line, not the order they would run — a rule
	// asks what is about to run, not when.
	Invocations []Invocation
}

CommandEvent is what this module's own code passes around.

Typed here, a map at the boundary — the same split the file module makes, and for the same reason. The engine has no use for a struct whose shape it cannot know, and this module has no use for a map when it is the one putting the values in.

func ExtractCommand

func ExtractCommand(raw string) CommandEvent

ExtractCommand finds every invocation a command line performs.

Exported because it is the whole of this module's logic and it is a pure function of a string — which is what lets it be tested by handing it a command line and reading what comes back, with no harness and no payload in the way.

It never returns an error. A line that will not parse, or one whose programs cannot be resolved, still produces an event: the raw text is what a rule about unparseable commands would match on, and swallowing the event would mean the one command line nobody could read is also the one nobody is watching.

func FromEvent

func FromEvent(e event.Event) (CommandEvent, error)

FromEvent converts back, for this module's own reads of events it produced.

An event carrying no raw command line is an error rather than a zero value: every event this module emits was produced from a command line, so one without it did not come from here, and returning an empty command would let a caller judge a line nobody ran. An empty invocation list is not an error — a line whose programs could not be resolved is exactly the case this module promises to report honestly rather than guess at.

func (CommandEvent) Event

func (c CommandEvent) Event() event.Event

Event converts to the wire form.

The invocation list is flattened to plain maps rather than left as structs, because what reads it is a matcher expression and a hook's JSON decoder, neither of which knows this package's types.

type Effect

type Effect int

Effect is what a command line is about to do to one path.

Two values, not three, and the omission is the design rather than an oversight. There is no Create: a Pre event announcing a creation must carry the content that would be written, and a command line does not say what bytes will result — `sed -i` states a transformation, not an outcome. So a command aimed at a path that does not exist yet reports Write and the file module resolves it against the tree, which is the only place the answer actually is. See events/main.tsp in the spec for the argument, and extractCommand below for what that resolution does with a path that is absent.

const (
	// Write means the file's bytes are about to change: a redirection into it,
	// a `sed -i`, a `cp` onto it. What they will change TO is not known and is
	// not claimed.
	Write Effect = iota

	// Remove means the file is about to stop existing — `rm`, or the source of
	// an `mv`.
	Remove
)

type FileTarget

type FileTarget struct {
	// Path is the path as the command line names it, with quoting undone and
	// nothing else done to it. It is not resolved against a root or cleaned
	// here: this package knows what a command line says, and where the
	// repository sits is the file module's question.
	Path string

	// Effect is what is about to happen to it.
	Effect Effect

	// Payload is what the LINE determines about the resulting bytes, which for
	// most commands is nothing.
	//
	// The zero value is PayloadNone, so a target built without thinking about
	// content claims none — which is why every construction site in this
	// package could stay as it was and only the ones that genuinely know
	// something had to change.
	//
	// It is a statement about the line, not the outcome: `cp a.md b.md` yields
	// PayloadCopyOf naming a.md, and turning that into bytes is filemod's,
	// because it means reading the filesystem and this package does not. See
	// payload.go for the whole argument.
	Payload Payload

	// MTimeOnly marks a write that changes no bytes when the file already
	// exists — `touch`, and nothing else today.
	//
	// It exists because `touch` is the one command whose PAYLOAD depends on
	// something this package refuses to look at. Creating a file, it determines
	// an empty one; against a file already there it changes only the timestamp
	// and every byte survives. One line, two outcomes, chosen by the tree.
	//
	// So the line states both halves — the create payload in Payload, and this
	// flag saying "only if it is a create" — and filemod, which has already
	// stat'd the path, picks. Without it a caller applying the payload
	// uniformly would report `touch existing.md` as emptying the file, and a
	// guardrail refusing empty results would fire on a command that changes
	// nothing.
	MTimeOnly bool

	// Into are the sources copied INTO this path when it is a directory —
	// `cp a.md b.md target-dir/`, whose real results are target-dir/a.md and
	// target-dir/b.md.
	//
	// The second case, after MTimeOnly, where one line means different things
	// depending on the tree, and it is the same shape of answer. Whether the
	// last operand is a directory decides whether this line writes ONE file
	// (a two-operand copy onto a path) or SEVERAL (a copy into a directory),
	// and this package refuses to look. So it states both readings — Path with
	// its Payload for the file case, and this list for the directory case —
	// and filemod, which has already stat'd the path, picks.
	//
	// Empty for every other command, and empty for a two-operand copy, where
	// there is no directory reading to state: `cp a.md b.md` writes b.md
	// whether or not b.md exists, and the only way `b.md` is a directory is
	// the one this field covers.
	//
	// The resulting NAME is base(source), which is the utility's own rule and
	// is not a tree question. What is a tree question is whether the rule
	// applies at all, and that is exactly what is deferred.
	Into []string

	// Recursive marks a Remove that takes a DIRECTORY with it, everything
	// inside included — `rm -r`/`-R`/`--recursive`, and the source of an `mv`,
	// which moves a directory whole without asking for a flag.
	//
	// The third case of one line meaning different things depending on the
	// tree, after MTimeOnly and Into: `rm -rf scanners/x` removes one file if
	// scanners/x is a file and every file under it if it is a directory, and
	// only a stat can tell. So the line states that it WOULD reach inside, and
	// filemod, which stats, lists what is inside. Without it a directory
	// removal named no file at all, and a rule guarding a file was defeated by
	// deleting the folder it sits in.
	//
	// False for a plain `rm dir`, which fails on a directory and removes
	// nothing.
	Recursive bool

	// Grounded is the sr-file call that names this target, parsed — the one
	// program whose line carries citations for the file it changes (see
	// internal/grounding). Nil for every other program. It travels on the
	// target so its citations are keyed by the same path, resolved against the
	// same `cd`s, as the change itself.
	Grounded *grounding.FileCommand
}

FileTarget is one path a command line is about to touch, and what it will do to it.

func FileTargets

func FileTargets(raw string) []FileTarget

FileTargets finds every path a command line is about to change.

Exported for the same reason ExtractCommand is: it is a pure function of a string, so it can be tested by handing it a command line and reading what comes back, with no harness and no payload in the way. The file module is its caller — see filemod's extractPending, and the placement argument there.

It returns paths, never events. What kind of event a path deserves depends on what is at that path in the tree, and this package does not read the tree: doing so would make a command's meaning depend on the filesystem, which is exactly what safeConfig refuses for expansion and for the same reason.

Like walk, it never returns an error and recovers from a panic. A parser bug here would otherwise exit the guardrail non-zero, which a harness reads as a refusal — so a bug would block the agent's work rather than degrade the rule. Whatever was collected before a panic is kept: half a list still lets a rule fire, where none lets it silently pass.

type Invocation

type Invocation struct {
	// Bin is the program, as its basename — `npm`, not `/usr/local/bin/npm`.
	// A rule about npm should not have to enumerate the paths npm can live at,
	// and an agent should not be able to evade one by spelling the path out.
	Bin string

	// Argv is the resolved argument vector, program included. Resolved means
	// quoting and escaping are already undone: `n\pm` and `n""pm` both arrive
	// here as `npm`.
	Argv []string

	// Gaps are the positions in Argv where a word of the line was dropped
	// because it could not be resolved without running something or guessing
	// at an environment the line does not set (`$(pwd)/x`, `$UNSET`, `${X:-y}`):
	// i means a word stood immediately before Argv[i], len(Argv) that one
	// trailed the vector. Argv alone cannot tell a `-C $D` from a `-C` followed
	// by the next word; a consumer that must not judge the wrong thing (a gate
	// resolving a folder) reads this to fail closed. Nil when every word resolved.
	Gaps []int

	// Flags are the flags parsed out of Argv, keyed by name without its
	// leading dashes. Each value is every occurrence of that flag, in the
	// order given — a flag given once is a one-element slice, not a bare
	// string, so a repeated flag (`--cite:user a --cite:user b`) is not lost
	// to a last-wins collision the way a plain map would lose it. A flag
	// given without a value carries an empty string at that position, so a
	// rule can still ask whether a flag is present without knowing whether
	// that flag takes one.
	Flags map[string][]string

	// Cwd is the directory this program runs in, as far as the line itself
	// says — threaded through every `cd` ahead of it in its own scope (a
	// subshell's `cd` does not leak out of the subshell; see cwd.go):
	//
	//	"."          where the command line started — no `cd` moved it
	//	"sub/dir"    relative to where the line started (`cd sub/dir && …`)
	//	"/abs/dir"   absolute, once a `cd` named an absolute directory
	//	""           unknown — a `cd` this module cannot resolve without
	//	             running something (`cd "$DIR"`, `cd -`, `pushd`)
	//
	// Where the line started is the harness's working directory for the tool
	// call, which this pure function of a string does not have: a consumer
	// joins a relative Cwd onto it. Empty means unknown rather than "the
	// start", because a program whose directory was lost must not be read as
	// running where the line began — that is a guess, and a path resolved
	// against it could name a file the command never touches.
	Cwd string

	// Env is the environment the line itself sets for this program, NAME to
	// value: a prefix (`FOO=1 cmd`), a wrapper's assignments (`env FOO=1 cmd`,
	// `sudo FOO=1 cmd`), and an `export FOO=1` earlier in the same shell scope
	// (a subshell's export stays inside it). The value is "" when it is not a
	// literal word (`FOO=$X`, `FOO=$(cmd)`): the name is certain, the value is
	// not. Nil/empty when the line sets nothing. Only what the text says: the
	// environment the harness started with is not in it.
	Env map[string]string

	// Stdin is the text the line itself feeds this program: the body of a heredoc
	// (`<<EOF`, `<<'EOF'`, `<<-EOF`) or the word of a here-string (`<<<word`, newline
	// appended) attached to its command. Nil unless that text is literal: a pipe from
	// another command, a `< file`, a variable or substitution in an unquoted heredoc
	// body all leave it nil, because what the program reads is then not knowable.
	Stdin *string
}

Invocation is one program a command line invokes.

type Module

type Module struct{}

Module produces command events.

func New

func New() *Module

New returns the command module.

func (*Module) Extract

func (m *Module) Extract(in module.Input) ([]event.Event, error)

Extract implements module.Module.

Only the pre phase produces anything. A command that already ran is not something this module can report on — what it changed shows up as the file module's Post events, established by diff rather than inferred from a string that was never executed by us.

Gated on HarnessCommandTools first, by tool name. This module used to dispatch on argument shape alone — any tool carrying a `command` key produced an event, and the doc comment here argued explicitly against naming the tool for the same drift reason filemod's extractPending once gave for itself: a harness renaming its shell tool, or adding a second one, should not silently stop being watched. That reasoning is preserved in git history rather than restated; see harnesstools.go for why this project chose a named allowlist anyway, and the maintenance obligation that choice carries.

func (*Module) Kinds

func (*Module) Kinds() []module.KindDecl

Kinds implements module.Module.

Both fields are carried on purpose. `invocations` is what a rule matches against — resolved, flattened, with the quoting already undone. `raw` is what a refusal quotes back, because telling an author that `npm` was refused is less use than showing them the line they actually wrote.

func (*Module) Name

func (*Module) Name() string

Name implements module.Module.

type Payload

type Payload struct {
	Kind PayloadKind

	// Text is the literal bytes, for PayloadLiteral and PayloadAppend. Exact,
	// including any trailing newline — see echoOutput, where the newline is the
	// difference between a correct fingerprint and a wrong one.
	Text string

	// From are the paths whose current contents become the result, IN ORDER, for
	// PayloadCopyOf. Spelled as the command line spells them, like
	// FileTarget.Path.
	//
	// One entry for a copy, several for a concatenation. The order is the
	// command's own — `cat b.md a.md > c.md` is not the same file as
	// `cat a.md b.md > c.md`, so a set would be the wrong shape here.
	From []string
}

Payload is what a command line determines about one target's resulting bytes.

type PayloadKind

type PayloadKind int

PayloadKind says how a target's resulting content is determined.

const (
	// PayloadNone means the line does not determine the resulting bytes.
	//
	// The zero value, deliberately: every FileTarget built anywhere in this
	// package is honest by default, and a construction site that has not
	// thought about content cannot accidentally claim to know it.
	PayloadNone PayloadKind = iota

	// PayloadLiteral means the resulting bytes are known outright and are in
	// Payload.Text. `echo hi > f.md`, a quoted heredoc, `touch new.md`.
	PayloadLiteral

	// PayloadCopyOf means the resulting bytes are whatever other paths hold
	// right now, named in Payload.From. `cp a.md b.md`, `mv a.md b.md`,
	// `dd if=a.md of=b.md`, and `cat a.md b.md > c.md`.
	//
	// A reference rather than the bytes, because resolving it means reading the
	// filesystem, which is the caller's half of the split.
	//
	// SEVERAL sources rather than one, because `cat` concatenates: the result is
	// their bytes in order, and a single-source shape could not say that. A copy
	// is the one-element case of the same statement, so both spellings resolve
	// through one branch in filemod rather than two that could disagree about
	// what an unreadable source means.
	PayloadCopyOf

	// PayloadAppend means the resulting bytes are the target's CURRENT contents
	// followed by Payload.Text. `echo x >> f.md`.
	//
	// Distinct from PayloadLiteral because the base is the file itself, which
	// only the caller can read — and distinct from PayloadCopyOf because the
	// literal tail has to be carried alongside the reference.
	PayloadAppend
)

type Pending

type Pending interface {
	// Tool is what the harness calls it. Read only to know how to read the
	// arguments — a rule never sees it.
	Tool() string
	// Arguments are the tool's own, as the harness gave them.
	Arguments() json.RawMessage
}

Pending is what a module is given about an action a harness is about to take. A module reads what it recognises and ignores the rest.

Declared here rather than imported from the file module: two modules reading the same payload is not two modules sharing a type, and making one depend on the other so they can agree on two method names would couple them for nothing.

type ReadTarget

type ReadTarget struct {
	// Path is the path as the command line names it, quoting undone, exactly as
	// FileTarget.Path is — not resolved against a root or cleaned.
	Path string
}

ReadTarget is one path a command line is about to read in full.

func ReadTargets

func ReadTargets(raw string) []ReadTarget

ReadTargets finds every path a command line reads whole, through one of readBins or an input redirection (`< file`, `<<< "$(cat file)"` is not resolved, but a plain `< file` is syntax and needs no vocabulary at all).

Mirrors FileTargets exactly in structure — parse once, walk both node kinds that matter, recover from a panic the same way and for the same reason (a parser bug here must degrade the read-detection rather than exit the process non-zero, which a harness would read as an unrelated refusal).

Jump to

Keyboard shortcuts

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