Repository navigation
v0.6.0
A policy-and-privilege release. A new optional public policy package turns a
report into an allow/review/deny decision, and the bash binder learns to
elevate a command through the sudo wrapper.
Added
- Report policy package. The optional public package
github.com/v0lka/flowsh/policyevaluates anapi.Reportunder trusted,
caller-owned Go configuration and returns anAllow,RevieworDeny
decision with deterministic, sorted reason codes
(ADR-0017).Newvalidates
the configuration and copies its maps, defaultingUnknowntoDeny,
ConservativetoReview,ExfiltrationtoDeny,CradletoReview
andIngesttoAllow, with emptyEffects/Gradesmaps;Evaluate
validates the report's tool name/version, schema version, canonical language
and grade before applying rules, so a missing, invalid or incompatible report
denies withinvalid_report, an otherwise-valid report whoseCovered()is
false denies withuncovered_report, and a nil or zero policy denies with
invalid_policy. All matching restrictions combine with
Deny > Review > Allow; unknown (Top || HasTop()) and conservative
analysis always require Review or Deny, and an Allow rule never overrides
another restriction. Decisions carry only the stable signal names
(unknown,conservative,exfiltration,cradle,ingest),
effect:<kind>andgrade:<grade>, never analysed text. The package depends
only onapiandengine, performs no I/O or command execution, and leaves
analysis, scoring, the CLI report and the report contract unchanged; it amends
ADR-0010's framing ofapias the sole public surface whileapiremains the
analysis entry point. A newspecs/domains/policy.mdrecords the contract,
anddocs/embedding.md,README.md,SECURITY.md,
specs/architecture/layers.mdandspecs/INDEX.mdname the decision layer. - sudo wrapper elevation (refined -u). The bash frontend now carries each
peeled wrapper's option words and source position intobind.Call
(Wrapper{Name, Options, Pos}— a source-breaking type change for code that
importedbinddirectly and readWrappersas[]string; the serialized
report contract is unchanged), and the binder runs a wrapper-elevation pass
before resolving the inner command: a wrapper whose knowledge-base record
declares a PrivEsc parameter (sudo, new inkb/data/security.yaml) binds
its own command line viaparseArgsand elevates by the refined -u doctrine —
no-u/--user, or a literal value naming root (root, or the numeric
spellings0,00,#0), yields a certain (Direct)PrivEsctargeting
root; a literal non-root target (sudo -u nobody …,sudo -u '#1000' …)
yields no elevation plus an explanatory note; a missing, dynamic, repeated
(sudo -u a -u b) or option-shaped value keeps the certain elevation over an
unresolved (⊤) target user without touchingResult.Conservative. A line
that names nothing to run (the inert flag-only formssudo -l,-v,-k,
-h,--version,--help,--list, … — including-l/--listwith a
command word, which sudo only lists permissions for, and the print-and-exit
--version/--help;-h/-v/-Kare likewise inert with a command word,
whereas-kwith a command still runs it, only invalidating the cached
credentials first) neither elevates, nor claims a de-elevation, nor
binds the words that follow: the report degrades to ⊤ instead.
-g/--groupnever decides nor elevates: a bare-gruns the command as
the invoking user (man sudo), and the unmodelled group membership is named by
a note. Unrecognized wrapper flags surface in the notes, and
sudo sudo cmdreports exactly one elevation. Every elevation is justified
in the why-trace by the wrapper's own tokens (command(sudo)at the
wrapper's position,flag(-u),operand(root), rulepriv.escalate).
The conformance corpus gains a sudo bundle (testdata/corpus/sudo_bash.json;
the latency baseline is re-pinned at 359 cases), and the accepted precision
trade-offs — the flag-only ⊤ verdict, the unpeeled path-qualified wrapper,
group escalation,doas/pkexecand the wrapper-less PowerShell path — are
recorded in SECURITY.md. The elevation also covers the call shapes that
never reach the ordinary binding flow: a wrapper that consumed the whole
command line (sudo -iruns a root shell with no inner command), a wrapper
over a dynamically-named command (sudo $X), over a code-execution sink
(sudo sh -c '…'), over a code-executing builtin (sudo eval '…') — the
interpreter routes all four through the resolver seam (elevateWrappers),
folding a result only when it carries elevation evidence, so a wrapper over
an effect-free line stays benign; when it does carry evidence, the report
gainsPrivEscalongside the interpreter's ⊤, and a ⊤ the interpreter
already derived is not duplicated by the binder's own.
Fixed
- Policy coverage diagnostics (2026-10 code review). The coverage gate is
applied to the validated report as its own step instead of being folded into
the report-validation check, so an otherwise-valid report with no effects and
neither theTopnor theConservativeflag — Bashtrueand:— denies
with the distinctuncovered_reportcode rather than being reported as
malformed. Validation still precedes coverage, so an invalid report always
usesinvalid_report. - Review hardening of the sudo elevation (2026-10 code review).
sudo -u '#0' …(and the00spelling) is classified numerically instead of
falling into a false "not root" de-elevation note;#1000is classified by
the same numeric reading and keeps its correct de-elevation; a repeated
-u/--userno longer resolves by the first match — the elevation
stays certain over an unresolved target with an ambiguity note; an option
token glued after an unsatisfied dynamic-u(sudo -u $U -g root …) is
never read as the target user;sudo -u"$U" cmd/sudo -u$U cmdkeep the
same -u semantics as the separated spelling (the recorded unsatisfied
option) instead of reporting a confident root escalation; and a
path-qualified/usr/bin/sudo …degrades to ⊤ like the unpeeled spelling it
is, instead of the knowledge-base record's narrow PrivEsc-only binding that
silently dropped the wrapped command's own effects. Under a download-cradle
flow (curl … | sudo $X) the report now carries a single ⊤CodeExecrow.
Inertness is decided at the privileged wrapper's own position in the chain,
not chain-wide: a wrapper above it execs the privileged wrapper, not the
other way round, soenv sudo -k/nohup sudo -k— andsudo sudo -k—
no longer reportPrivEscwith a "the command still runs" note for a line
that executes nothing;sudo env FOO=1keeps its elevation (the inner
wrapper is a process sudo execs), andbind's package documentation now
names both deliberate empty-result shapes of a directBindcall. A
trailing bare-u/--userwith no value on an inert line carries its own
explanatory note (real sudo usage-errors on the missing argument) instead
of staying silent.
Changed
- CI. The
golangci-lintGitHub Action is pinned to a version compatible
with Go 1.27.2 (.github/workflows/ci.yml).