feat: operator-controlled sensitivity source map and the lineage sensitivity report (#5042) - #5293
feat: operator-controlled sensitivity source map and the lineage sensitivity report (#5042)#5293chernistry wants to merge 6 commits into
Conversation
Classifications have to enter the lineage graph from somewhere. This is the mirror of load_trust_source_map on the sensitivity axis: a reviewed table mapping a source name to the class its results carry, resolved from a project-local file when present and from the bundled default otherwise. The fail-closed end is inverted with the axis. An unlisted source is the highest class, not the lowest, and an unrecognised class token is dropped rather than coerced, because on this axis the cheapest wrong guess is also the most damaging one.
…#5042) 'This is confidential' invites an argument. 'This is confidential because it derives, through these three hops, from that document' ends it. The command renders the projection an operator can already compute but had no way to read: the maximum sensitivity class over an artefact's lineage closure, the closure member that raised the level, and the walk that reaches it. The gate runs first and a failure exits 1 without printing a class, because a class projected from an unverified log is not evidence of anything. An unknown artefact and a missing log both report the fail-closed class through the same path, so absence of a classification is never rendered as harmlessness.
…ge operator guide (#5042)
|
VERDICT: request-changes Missing release-notes fragment for user-visible CLI surface. This PR adds
F1 - Missing release-notes fragmentFile: What is wrong: The PR introduces Evidence checked:
Smallest fix: Add a fragment file Scope and implementation qualityThe implementation is clean and well-aligned with existing patterns:
Tests as evidence: All 15 tests in
No logic, security, or hygiene issues found. The code is minimal, focused, and follows the provenance.py template closely. bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
|
VERDICT: approve bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
# Conflicts: # docs/release-notes/unreleased.md # src/bernstein/cli/commands/lineage_cmd.py # typos.toml
What
Slices 2 and 3 of #5042, on top of the projection merged in #5167.
load_sensitivity_source_map()andsensitivity_class_for_source()incore/lineage/sensitivity.py, mirroringload_trust_source_map()/trust_class_for_source()inprovenance.py,plus a bundled reviewed table at
templates/provenance/sensitivity_sources.yamlbeside the existing
trust_sources.yaml.bernstein lineage sensitivity <artefact|entry-hash>in
cli/commands/lineage_cmd.py, rendering the effective class, the closuremember that raised it and the walk through the graph that reaches it, with
--jsonfor scripts.docs/compliance/lineage-export.md, and aline each in
docs/release-notes/unreleased.md.Explicitly not in this PR:
(
core/security/toolcall_interlock.py, refusals chained, off by default) andslice 5 (clearance levels for models and agents) are untouched.
DLP scanner's guesses do not enter this field.
trust_class, its ordering, its map or its fail-closed end.sensitivityonto entries automatically. The mapanswers "what class does this source carry"; deciding where that answer is
applied at write time belongs with enforcement.
Why
#5167 gave lineage entries a
sensitivityfield and a projection over theclosure, but nothing tells an operator which sources carry which class, and
nothing renders the projection. Two gaps follow from that.
The map: a classification has to come from somewhere reviewable.
trust_classalready has that shape — a YAML table of source names the operator edits,
resolved project-local-then-bundled — and sensitivity needs the same one so the
two axes are configured the same way instead of one being a table and the other
being scattered constants.
The report: a verdict nobody can read is not operable. "This is confidential"
invites an argument. "This is confidential because it derives, through these
three hops, from that entry" ends it, and the second form is only available if
something walks the graph and prints the walk. That is the whole reason the
projection carries
raised_byandpathrather than just a class.Both are read-only surfaces over the signed graph: the class the command prints
is recomputed from
log.jsonlon the spot, not stored anywhere, so it cannotdrift from the entries it summarises.
How
The map.
load_sensitivity_source_map(workdir=...)resolves<workdir>/templates/provenance/sensitivity_sources.yamlwhen present, else thebundled file, exactly as
load_trust_source_mapresolvestrust_sources.yamlfrom the same directory. A project-local file replaces the bundled table rather
than merging into it, so the table in force is exactly what the operator wrote
and a source they left out reads as unlisted rather than inheriting a bundled
row they never saw.
sensitivity_class_for_source()returnsHIGHEST_SENSITIVITY_CLASSfor an unlisted source — the mirror oftrust_class_for_sourcefalling topublic, with the fail-closed end invertedwith the axis.
Two decisions the issue leaves open, and how they are settled:
An unrecognised class token is dropped, not coerced.
_coerce_trust_classin
provenance.pydrops unparseable rows and this follows it, but the reason issharper on this axis: coercing would pick some class for a row the operator
got wrong, and the cheapest guess (the least sensitive class) is also the most
damaging one. A dropped row leaves the source unlisted, which fails closed high.
The fail-closed class is not written into entries.
sensitivity_class_for_sourceanswers a question; it is not a label to stamp on an entry for a source nobody
classified. Recording
restrictedbecause a source is unlisted would assert aclassification nobody made, and would take the fail-closed decision twice —
once at write time and again in
effective_sensitivity, which already appliesit to an unlabelled closure at read time. One rule, applied at read.
The report.
sensitivity_cmdruns the lineage gate first and exits 1without printing a class when it fails: a class projected from an unverified log
is not evidence of anything, which is the same posture
lineage verifytakes.A target starting with
sha256:is read as an entry hash and goes toeffective_sensitivity; anything else is an artefact path and goes tosensitivity_for_artefact, which resolves the tip first. A missing log yieldsan empty entry list and therefore the ordinary fail-closed verdict rather than a
second error path — absence of a log and absence of a classification are the
same fact, so they get one rule. Human output renders entry hashes with
soft_wrapso an operator copying one out of the report gets the whole hash.The bundled table classifies the sources the repository already names in
trust_sources.yaml: operator attachments confidential, operator prompts andworkspace/repository reads internal, web and public-tracker fetches public,
gateway results internal.
Tests
tests/unit/lineage/test_sensitivity_sources.py(9) andtests/unit/lineage/test_sensitivity_cli.py(6). All 15 failed on theunmodified tree: the sources file fails collection with
ImportError: cannot import name 'load_sensitivity_source_map', and each CLItest fails with
Error: No such command 'sensitivity'/No such option '--log'.Slice 2:
test_bundled_sensitivity_source_map_classifies_the_reviewed_sources— thebundled table is real reviewed data, not an empty stub, and every value is a
SensitivityClassrather than a raw string.test_unknown_source_fails_closed_to_the_highest_class— load-bearing.The inverted fail-closed end; an unlisted source is
restricted, notpublic.test_default_map_is_loaded_when_no_mapping_is_passed— the bundled table isused when no mapping is supplied.
test_workdir_map_overrides_the_bundled_default— a project-local filereplaces the bundled table rather than merging into it, so a source the
operator dropped reads as unlisted.
test_malformed_rows_are_dropped_without_dropping_the_whole_map— one badrow does not silently empty the table.
test_unrecognised_class_token_is_dropped_rather_than_downgraded— a typoleaves the source unlisted (and so fail-closed high) instead of being coerced
to the nearest class.
test_class_tokens_are_read_case_and_whitespace_insensitively—" Confidential "parses.
test_unreadable_map_yields_an_empty_table_not_a_partial_one— an unparseablefile yields
{}, which by rule 2 reads as fail-closed-high everywhere ratherthan as a permissive default.
test_map_without_a_sources_list_yields_an_empty_table— same for astructurally wrong file.
Slice 3:
test_cli_reports_the_effective_class_and_the_path_that_produced_it—load-bearing. The summary case end to end: the command names the class,
the closure member that raised it, and the hops that reach it.
test_cli_refuses_to_report_a_verdict_from_a_log_that_fails_the_gate—exit 1, no class printed.
test_cli_reports_the_fail_closed_default_for_an_unknown_artefact—restricted, not "unknown" and not an error.test_cli_reports_the_fail_closed_default_when_there_is_no_log— a missinglog takes the same path.
test_cli_json_output_carries_every_verdict_field—--jsonemits target,class, resolved, raised_by, path, closure and the sensitivity records.
test_cli_accepts_an_entry_hash_target— asha256:target reports on thatentry directly rather than being treated as a path.
Verification run:
uv run python scripts/run_tests.py --parallel 2 -x tests/unit/lineage/test_sensitivity.py tests/unit/lineage/test_sensitivity_sources.py tests/unit/lineage/test_sensitivity_cli.py— 36 passed.origin/maincopies of the two touched sourcefiles and the template, running both new files, then restoring.
--affected origin/mainselects far past the reviewable bound, so instead thetests of the touched modules and of their importers were run:
tests/unit/lineage/test_cli.py,test_conflict_cli.py,test_spine_cli.py,tests/unit/test_lineage_v2_cli.py,tests/unit/lineage/test_provenance.py—72 passed;
tests/unit/test_cli_command_registration.py— 699 passed, 1skipped;
tests/unit/test_readme_api_coverage.py,test_feature_matrix_drift.py,test_unreleased_notes_rotation.py,tests/unit/test_nist_ai_rmf_mapping.py— 48 passed. CI runs the full suite.uv run ruff check src/anduv run ruff format --checkon the two touchedfiles — clean.
uv run mypy --config-file mypy.gate.ini— success, 91 source files.Checklist
uv run ruff check src/passesuv run pyright src/passes (no new findings on the touched files;sensitivity.pyis clean, and the three pre-existingreportUnknown*findings on the conflict-resolution branch oflineage_cmd.pyare byte-identical before and after this change)uv run python scripts/run_tests.py -xpasses for the scope described aboveDocumentation duty
documented in
docs/compliance/, not a README featuredocs/compliance/lineage-export.md— new section covering the command,its exit codes and the source map
docs/api/schema: no public schema changeduv run bernstein agents-md syncrun — no changes produceddocs/release-notes/unreleased.mdentry addedPart of #5042
Remaining
core/security/toolcall_interlock.py, refusalswritten to the chain, off by default behind policy — acceptance test 8,
test_refusal_at_the_read_boundary_is_a_chain_event.it belongs with slice 4, where there is a policy to say when it applies.