Skip to content

feat: resolve operand lineage taint at the tool-call approval gate (#2957) - #5166

Merged
chernistry merged 2 commits into
mainfrom
run-20260901T2145Z-issue2957
Sep 2, 2026
Merged

feat: resolve operand lineage taint at the tool-call approval gate (#2957)#5166
chernistry merged 2 commits into
mainfrom
run-20260901T2145Z-issue2957

Conversation

@chernistry

Copy link
Copy Markdown
Collaborator

What

Connects the lineage taint verdict that already ships to the per-tool-call
approval gate that already ships. bernstein.core.approval.gate now resolves
the operand's effective trust class from the signed lineage log and passes it
to classify_tool_call, so a call on an artefact whose lineage closure bottoms
out at an untrusted origin is no longer auto-approved.

Explicitly not in this PR (all still open on #2957): no signed policy
format, signer, or key custody; no recipients extraction; no per-tool-call
receipt or ledger anchoring; no benign-flow fixture corpus or false-positive
ceiling; scan_tool_output and record_tool_result remain uncalled from
src/ and are left alone.

Why

Two halves of the confinement path were built and never joined.
classify_tool_call (src/bernstein/core/security/auto_approve.py) has taken a
derived_trust argument since the provenance trust classes landed, and
_downgrade_for_taint turns an APPROVE into ASK when the operand's lineage
closure is untrusted. No production caller ever passed it — git grep derived_trust src/ returned only auto_approve.py itself, and _classify in
src/bernstein/core/approval/gate.py called the classifier with the tool name
and args alone.

The consequence is a data-flow hole in the live gate: an agent fetches a page
recorded at public trust, and the follow-up read of that artefact is
auto-approved because the classifier only ever saw the call's shape. The
structural path was closed; the derivation path was open.

How

_derived_trust(tool_args, workdir) extracts the operand path (the same
path / file_path keys _policy_reject already reads, now via a shared
_operand_path helper), loads .sdd/lineage/log.jsonl, and projects the
verdict with taint_for_artefact. _smart_classifier_decision already holds
the workdir, so the value threads through _classify into the classifier
with no public signature change.

One decision the issue left open, and the reason the change is a net win rather
than a net loss: only a resolved verdict is forwarded. taint_for_artefact
fails closed — an unknown path comes back resolved=False, tainted=True, trust=public. Nothing in src/ writes trust_class today, so passing the
verdict unconditionally would downgrade every APPROVE to ASK in every
workspace and make the gate useless. Passing it only when verdict.resolved is
True means an absent record is treated as "no evidence", never as evidence of
taint. Taint can only tighten a decision, so this cannot open the gate.

Two smaller choices:

  • Absolute operand paths are also tried in their repo-relative form. Lineage
    entries key artefacts on repo-relative POSIX paths while tool arguments
    routinely carry the absolute path the agent passed; without this an absolute
    operand walks straight past the record written for its relative form.
  • A missing log file short-circuits before the lineage import, so workspaces
    that record no provenance pay nothing on the approval hot path.

Tests

tests/unit/test_approval_gate_taint_wiring.py, all driving the production
entry point await_tool_call rather than the classifier directly. Every test
was run against the unmodified tree first; tests 1, 6 and 7 failed there for
the stated reason (the gate auto-approved, and derived_trust arrived as
None), and tests 2-5 and 8 passed and must keep passing.

  1. test_untrusted_operand_downgrades_auto_approve_to_askload-bearing.
    With a third_party lineage record for the operand, a call the classifier
    would APPROVE is no longer auto-approved. Failed before the change with
    decision=ALLOW.
  2. test_absent_lineage_log_still_auto_approves — no lineage log at all leaves
    the verdict untouched.
  3. test_empty_lineage_log_still_auto_approves — an initialised but empty
    store is not evidence of taint.
  4. test_operand_absent_from_a_populated_log_still_auto_approves — a log that
    records other artefacts does not taint this one. Tests 2-4 together are
    the guard against the fail-closed backfire; they pass on the unmodified tree
    and break the moment an unresolved verdict is forwarded.
  5. test_trusted_operand_keeps_auto_approval — an operator-trust record does
    not downgrade anything: taint only ever tightens a decision.
  6. test_absolute_operand_path_resolves_repo_relative_lineage_record — an
    absolute operand path does not bypass the record keyed on its repo-relative
    form. Failed before the change.
  7. test_gate_passes_resolved_trust_class_into_classifier — the resolved
    TrustClass.THIRD_PARTY reaches the classifier and the resulting verdict
    carries matched_pattern="provenance:untrusted_derivation", so an auditor
    can see which rule fired. Failed before the change with derived_trust=None.
  8. test_gate_passes_no_trust_class_when_provenance_is_unresolved — the
    unresolved case reaches the classifier as None, not as a public verdict.

Also re-ran the neighbouring suites that own this seam:
test_approval_gate_classifier_wiring.py,
test_approval_gate_dispatch_wiring.py,
test_approval_gate_outer_fail_closed.py, test_approval_hook.py,
test_approval_queue.py, test_provenance_auto_approve_property.py,
test_provenance_egress_confinement.py, test_provenance_end_to_end.py,
test_approval.py, test_approval_gates.py,
test_approval_workflow_e2e.py, test_workspace_config_provenance.py,
test_cli_cache_policy_cmd.py — every file in the --affected origin/main
set that touches approval, security, lineage or provenance. All green.

One note on local verification: the machine this was prepared on could not
finish the full 200-file affected set, and
tests/unit/test_cli_command_registration.py exceeds the runner's 300s
per-file cap here. That timeout reproduces identically on a clean origin/main
worktree with the same interpreter and environment, so it is not caused by this
change; that file imports nothing from the approval or lineage packages.

Checklist

  • uv run ruff check src/ passes; uv run ruff format --check src/ clean
  • uv run pyright src/bernstein/core/approval/gate.py — 0 errors, 0
    warnings. The repo-wide pyright src/ invocation reports a large
    pre-existing error count untouched by this two-file diff.
  • uv run python scripts/run_tests.py -x passes for the new file and every
    approval/security/lineage file in the affected set
  • New code has type hints

Documentation duty (every PR that touches a feature)

  • User-visible README section updated — N/A (no user-visible surface; the
    gate's inputs and outputs are unchanged)
  • docs/operations/<area>.md updated — N/A (no operator-facing knob added)
  • docs/api/ schema regenerated — N/A (no public signature changed; the
    new helpers are module-private)
  • uv run bernstein agents-md sync — N/A (no new module)
  • Tests cover the documented behaviour

Part of #2957

Remaining

  • Define the "signed policy": format, signer, key custody, storage, matching,
    and whether it wraps or replaces the permissions: block in bernstein.yaml.
  • Define recipients extraction per tool (which tools, which argument keys), or
    drop it from scope.
  • Per-tool-call receipt plus ledger anchoring for each admitted or refused call.
  • A benign-flow fixture corpus and a concrete false-positive ceiling measured
    through eval/pentest_scorer.py (acceptance criterion 3).
  • Wire the two other uncalled primitives: scan_tool_output
    (core/security/promptware_ingest.py) and record_tool_result
    (core/lineage/provenance.py) — each is its own slice with its own decisions.
  • Replay equivalence for admission decisions (acceptance criterion 2), once
    receipts exist to replay.

chernistry and others added 2 commits September 2, 2026 00:55
The auto-approve classifier has accepted a derived_trust argument and
downgraded an APPROVE to ASK on an untrusted derivation since the
provenance trust classes landed, but no production caller ever passed it:
the approval gate called classify_tool_call with the tool name and args
only. A call whose operand was recorded at third_party or public trust was
therefore auto-approved on the strength of its shape alone.

The gate now projects the operand's taint verdict from the signed lineage
log and threads it into the classifier. Only a resolved verdict is
forwarded: taint_for_artefact fails closed and reports an unknown path as
public/tainted, so forwarding an unresolved verdict would downgrade every
auto-approval in every workspace that records no provenance. Absolute
operand paths are also tried in their repo-relative form so they cannot
walk past the record written for the relative path.
@bernstein-the-conductor

bernstein-the-conductor Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

VERDICT: approve

Resolved the critical data-flow hole in the approval gate: lineage taint verdict now threads through the classifier, preventing untrusted operands from auto-approving while preserving resolved verdicts

Summary of change:

  • Added _operand_path() to extract the artefact path from tool_args
  • Added _lineage_path_candidates() to handle repo-relative vs absolute path matching
  • Added _derived_trust() to resolve trust class from signed lineage log with proper handling of resolved vs unresolved verdicts
  • Modified _classify() to accept derived_trust parameter and pass it to classify_tool_call()
  • Modified _smart_classifier_decision() to call _derived_trust() and pass the resolved trust class to _classify()
  • Updated _policy_reject() to use _operand_path() instead of raw path

Key improvements:

  1. Proper taint resolution: The gate now resolves the operand's effective trust class from the signed lineage log, so taint can only tighten decisions, never loosen them
  2. Resolved verdict requirement: Only resolved=True verdicts are forwarded to the classifier, preventing downgrading of auto-approvals in workspaces without provenance recording
  3. Repo-relative path matching: Absolute paths now resolve against repo-relative lineage records to prevent bypassing taint checks
  4. Fail-closed behavior: Unresolved paths (no log record) yield trust=public, tainted=True, resolved=False which properly downgrades APPROVE to ASK without breaking existing functionality

Security impact:

  • Fixes a critical data-flow vulnerability where untrusted artefacts could be auto-approved
  • Maintains fail-closed behavior: unknown paths remain untrusted
  • Preserves existing security boundaries and denial patterns
  • No new attack surface introduced; the fix reduces the attack surface by properly propagating taint

Tests verification:

  • All 8 new wiring tests pass
  • The previously failing test now passes ( compliance)
  • Existing test suite continues to pass
  • Test verifies that untrusted derivation properly downgrades APPROVE to ASK

Files modified:

  • src/bernstein/core/approval/gate.py (main implementation)

This change completes the second half of the confinement path by joining the structural path (already implemented) with the data-flow path (now implemented).


bernstein v3.19.0 - unattended review run run-20260902T044049p1405795Z - no operator in the loop

Signed review receipt - verify with bernstein review-receipt verify

field value
diff_hash sha256:c6886fd328da83af1da6cbe8ba2983ec5c3a6873b956349459e6752ba5be7afb
journal_entry_hash sha256:ebd64d224e9cf60b5fe6b38eae829f1dd9339f98f32c0b4f2b6a08f9cb91465d

@bernstein-the-conductor bernstein-the-conductor Bot added the fleet-approved Reviewed, fixed and verified by the unattended contour label Sep 2, 2026
@bernstein-the-conductor
bernstein-the-conductor Bot marked this pull request as ready for review September 2, 2026 04:54
@chernistry
chernistry added this pull request to the merge queue Sep 2, 2026
@chernistry chernistry added the fleet-blocked Unattended fix budget exhausted; needs an operator label Sep 2, 2026
Merged via the queue into main with commit df5d126 Sep 2, 2026
64 of 65 checks passed
@chernistry
chernistry deleted the run-20260901T2145Z-issue2957 branch September 2, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core fleet-approved Reviewed, fixed and verified by the unattended contour fleet-blocked Unattended fix budget exhausted; needs an operator size/m tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant