feat: resolve operand lineage taint at the tool-call approval gate (#2957) - #5166
Merged
Conversation
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.
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:
Key improvements:
Security impact:
Tests verification:
Files modified:
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 Signed review receipt - verify with
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Connects the lineage taint verdict that already ships to the per-tool-call
approval gate that already ships.
bernstein.core.approval.gatenow resolvesthe 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 bottomsout 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
recipientsextraction; no per-tool-callreceipt or ledger anchoring; no benign-flow fixture corpus or false-positive
ceiling;
scan_tool_outputandrecord_tool_resultremain uncalled fromsrc/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 aderived_trustargument since the provenance trust classes landed, and_downgrade_for_taintturns an APPROVE into ASK when the operand's lineageclosure is untrusted. No production caller ever passed it —
git grep derived_trust src/returned onlyauto_approve.pyitself, and_classifyinsrc/bernstein/core/approval/gate.pycalled the classifier with the tool nameand args alone.
The consequence is a data-flow hole in the live gate: an agent fetches a page
recorded at
publictrust, and the follow-up read of that artefact isauto-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 samepath/file_pathkeys_policy_rejectalready reads, now via a shared_operand_pathhelper), loads.sdd/lineage/log.jsonl, and projects theverdict with
taint_for_artefact._smart_classifier_decisionalready holdsthe
workdir, so the value threads through_classifyinto the classifierwith 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_artefactfails closed — an unknown path comes back
resolved=False, tainted=True, trust=public. Nothing insrc/writestrust_classtoday, so passing theverdict unconditionally would downgrade every APPROVE to ASK in every
workspace and make the gate useless. Passing it only when
verdict.resolvedisTrue 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:
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.
that record no provenance pay nothing on the approval hot path.
Tests
tests/unit/test_approval_gate_taint_wiring.py, all driving the productionentry point
await_tool_callrather than the classifier directly. Every testwas run against the unmodified tree first; tests 1, 6 and 7 failed there for
the stated reason (the gate auto-approved, and
derived_trustarrived asNone), and tests 2-5 and 8 passed and must keep passing.test_untrusted_operand_downgrades_auto_approve_to_ask— load-bearing.With a
third_partylineage record for the operand, a call the classifierwould APPROVE is no longer auto-approved. Failed before the change with
decision=ALLOW.test_absent_lineage_log_still_auto_approves— no lineage log at all leavesthe verdict untouched.
test_empty_lineage_log_still_auto_approves— an initialised but emptystore is not evidence of taint.
test_operand_absent_from_a_populated_log_still_auto_approves— a log thatrecords 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.
test_trusted_operand_keeps_auto_approval— anoperator-trust record doesnot downgrade anything: taint only ever tightens a decision.
test_absolute_operand_path_resolves_repo_relative_lineage_record— anabsolute operand path does not bypass the record keyed on its repo-relative
form. Failed before the change.
test_gate_passes_resolved_trust_class_into_classifier— the resolvedTrustClass.THIRD_PARTYreaches the classifier and the resulting verdictcarries
matched_pattern="provenance:untrusted_derivation", so an auditorcan see which rule fired. Failed before the change with
derived_trust=None.test_gate_passes_no_trust_class_when_provenance_is_unresolved— theunresolved case reaches the classifier as
None, not as apublicverdict.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/mainset 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.pyexceeds the runner's 300sper-file cap here. That timeout reproduces identically on a clean
origin/mainworktree 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/cleanuv run pyright src/bernstein/core/approval/gate.py— 0 errors, 0warnings. The repo-wide
pyright src/invocation reports a largepre-existing error count untouched by this two-file diff.
uv run python scripts/run_tests.py -xpasses for the new file and everyapproval/security/lineage file in the affected set
Documentation duty (every PR that touches a feature)
gate's inputs and outputs are unchanged)
docs/operations/<area>.mdupdated — N/A (no operator-facing knob added)docs/api/schema regenerated — N/A (no public signature changed; thenew helpers are module-private)
uv run bernstein agents-md sync— N/A (no new module)Part of #2957
Remaining
and whether it wraps or replaces the
permissions:block inbernstein.yaml.recipientsextraction per tool (which tools, which argument keys), ordrop it from scope.
through
eval/pentest_scorer.py(acceptance criterion 3).scan_tool_output(
core/security/promptware_ingest.py) andrecord_tool_result(
core/lineage/provenance.py) — each is its own slice with its own decisions.receipts exist to replay.