Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 15 Oct 18:20
· 13 commits to main since this release

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/policy evaluates an api.Report under trusted,
    caller-owned Go configuration and returns an Allow, Review or Deny
    decision with deterministic, sorted reason codes
    (ADR-0017). New validates
    the configuration and copies its maps, defaulting Unknown to Deny,
    Conservative to Review, Exfiltration to Deny, Cradle to Review
    and Ingest to Allow, with empty Effects/Grades maps; 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 with invalid_report, an otherwise-valid report whose Covered() is
    false denies with uncovered_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> and grade:<grade>, never analysed text. The package depends
    only on api and engine, 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 of api as the sole public surface while api remains the
    analysis entry point. A new specs/domains/policy.md records the contract,
    and docs/embedding.md, README.md, SECURITY.md,
    specs/architecture/layers.md and specs/INDEX.md name the decision layer.
  • sudo wrapper elevation (refined -u). The bash frontend now carries each
    peeled wrapper's option words and source position into bind.Call
    (Wrapper{Name, Options, Pos} — a source-breaking type change for code that
    imported bind directly and read Wrappers as []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 in kb/data/security.yaml) binds
    its own command line via parseArgs and elevates by the refined -u doctrine —
    no -u/--user, or a literal value naming root (root, or the numeric
    spellings 0, 00, #0), yields a certain (Direct) PrivEsc targeting
    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 touching Result.Conservative. A line
    that names nothing to run (the inert flag-only forms sudo -l, -v, -k,
    -h, --version, --help, --list, … — including -l/--list with a
    command word, which sudo only lists permissions for, and the print-and-exit
    --version/--help; -h/-v/-K are likewise inert with a command word,
    whereas -k with 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/--group never decides nor elevates: a bare -g runs 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 cmd reports 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), rule priv.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/pkexec and 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 -i runs 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
    gains PrivEsc alongside 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 the Top nor the Conservative flag — Bash true and : — denies
    with the distinct uncovered_report code rather than being reported as
    malformed. Validation still precedes coverage, so an invalid report always
    uses invalid_report.
  • Review hardening of the sudo elevation (2026-10 code review).
    sudo -u '#0' … (and the 00 spelling) is classified numerically instead of
    falling into a false "not root" de-elevation note; #1000 is classified by
    the same numeric reading and keeps its correct de-elevation; a repeated
    -u/--user no 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 cmd keep 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 ⊤ CodeExec row.
    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, so env sudo -k / nohup sudo -k — and sudo sudo -k —
    no longer report PrivEsc with a "the command still runs" note for a line
    that executes nothing; sudo env FOO=1 keeps its elevation (the inner
    wrapper is a process sudo execs), and bind's package documentation now
    names both deliberate empty-result shapes of a direct Bind call. A
    trailing bare -u/--user with 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-lint GitHub Action is pinned to a version compatible
    with Go 1.27.2 (.github/workflows/ci.yml).