diff --git a/changelog.d/741.added.md b/changelog.d/741.added.md new file mode 100644 index 00000000..8a976032 --- /dev/null +++ b/changelog.d/741.added.md @@ -0,0 +1 @@ +- Decision logs record the effective policy version, with policy history and verification exposing version provenance and unledgered snapshots (#741) diff --git a/src/doberman/cli/main.py b/src/doberman/cli/main.py index 848d434f..6d36a905 100644 --- a/src/doberman/cli/main.py +++ b/src/doberman/cli/main.py @@ -2348,6 +2348,7 @@ def tune( "auth_path", "human_confirmed", "source_context", + "policy_version", ) # Keep every action type in one column even when a new enum member outgrows the @@ -2936,6 +2937,16 @@ def policy_history( """ rows = asyncio.run(read_policy_changes(path, limit=max(0, last))) if as_json: + from doberman.storage.policy_catalogue import read_observations + + versions_by_ledger_ts = {} + for observation in read_observations(path): + if observation["ledger_ts"] is not None: + versions_by_ledger_ts[observation["ledger_ts"]] = observation["version"] + + for row in rows: + row["to_version"] = versions_by_ledger_ts.get(row["ts"]) + # Same redacted row dicts the human view uses (no raw paths/secrets). typer.echo(json.dumps(rows, sort_keys=True, separators=(",", ":"), default=str)) return @@ -2995,6 +3006,11 @@ def policy_versions( typer.echo( "mismatch: stored content no longer hashes to " + ", ".join(report["mismatched"]) ) + elif report["status"] == "unledgered": + typer.echo( + "unledgered: policy version(s) were introduced without a ledgered policy change: " + + ", ".join(report["unledgered"]) + ) else: typer.echo( f"drift: the policy on disk is {report['current']} but the last recorded " diff --git a/src/doberman/hosthooks/claude_code.py b/src/doberman/hosthooks/claude_code.py index 237e9c84..744cfa0c 100644 --- a/src/doberman/hosthooks/claude_code.py +++ b/src/doberman/hosthooks/claude_code.py @@ -256,6 +256,7 @@ def evaluate_pre(payload: dict[str, Any]) -> dict[str, Any] | None: auth_result=auth_method, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=result.enforcement, ) return hook_result except Exception: # noqa: BLE001 — fail closed; never surface the payload in an error @@ -279,6 +280,7 @@ def _record_pre_history( auth_result: str | None = None, auth_path: str = AuthPath.host_hook_objective, human_confirmed: bool | None = None, + enforcement_effective: str | None = None, ) -> None: """Best-effort: record a PreToolUse AUTH/BLOCK decision in ``doberman log``. @@ -303,6 +305,7 @@ def _record_pre_history( auth_result=auth_result or _pre_auth_result(hook_result), auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=enforcement_effective, ) except Exception: # noqa: BLE001,S110 — history must never alter the hook's return value pass diff --git a/src/doberman/hosthooks/codex.py b/src/doberman/hosthooks/codex.py index d5769a92..8250a423 100644 --- a/src/doberman/hosthooks/codex.py +++ b/src/doberman/hosthooks/codex.py @@ -188,6 +188,7 @@ def evaluate_pre(payload: dict[str, Any]) -> dict[str, Any] | None: auth_result=auth_method, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=result.enforcement, ) return hook_result except Exception: # noqa: BLE001 — fail closed; never surface the payload in an error diff --git a/src/doberman/hosthooks/cursor.py b/src/doberman/hosthooks/cursor.py index e3a43b52..690e399a 100644 --- a/src/doberman/hosthooks/cursor.py +++ b/src/doberman/hosthooks/cursor.py @@ -343,6 +343,7 @@ def evaluate(payload: dict[str, Any]) -> dict[str, Any]: auth_result=auth_method, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=result.enforcement, ) response = _from_host_output(host_out) diff --git a/src/doberman/hosthooks/openclaw.py b/src/doberman/hosthooks/openclaw.py index aeb94e52..b38d84d8 100644 --- a/src/doberman/hosthooks/openclaw.py +++ b/src/doberman/hosthooks/openclaw.py @@ -204,6 +204,7 @@ def _record_history( auth_result: str, auth_path: str = AuthPath.host_hook_objective, human_confirmed: bool | None = None, + enforcement_effective: str | None = None, ) -> None: """Best-effort: persist one decision row to the local decision log. @@ -224,6 +225,7 @@ def _record_history( auth_result=auth_result, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=enforcement_effective, ) @@ -291,6 +293,7 @@ def evaluate_before_tool_call(payload: dict[str, Any]) -> dict[str, Any]: # host_hook_challenge is deliberately unreachable here. auth_path=AuthPath.host_hook_objective, human_confirmed=False, + enforcement_effective=result.enforcement, ) return out except Exception: # noqa: BLE001 — fail closed; never surface the payload in an error diff --git a/src/doberman/hosthooks/spine.py b/src/doberman/hosthooks/spine.py index a812e8e3..32e28f56 100644 --- a/src/doberman/hosthooks/spine.py +++ b/src/doberman/hosthooks/spine.py @@ -148,6 +148,7 @@ def evaluate_action( auth_result="executed", auth_path=AuthPath.host_hook_monitor, human_confirmed=False, + enforcement_effective=enforcement, ) return SpineResult( decision, @@ -169,6 +170,7 @@ def record_history( auth_result: str, auth_path: str = AuthPath.host_hook_objective, human_confirmed: bool | None = None, + enforcement_effective: str | None = None, ) -> None: """Best-effort: persist one decision row to the local decision log. @@ -197,6 +199,7 @@ def record_history( session_id=session_id, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=enforcement_effective, ) ) except Exception: # noqa: BLE001,S110 — history must never break a hook path diff --git a/src/doberman/proxy/executor.py b/src/doberman/proxy/executor.py index 8633ab5a..eb35f350 100644 --- a/src/doberman/proxy/executor.py +++ b/src/doberman/proxy/executor.py @@ -751,6 +751,7 @@ async def _persist( eid: str | None = None, auth_path: str = AuthPath.none, human_confirmed: bool | None = None, + enforcement_effective: str | None = None, ) -> None: """Append one redacted row to the local decision log (best-effort). @@ -775,6 +776,7 @@ async def _persist( entity_id=eid, auth_path=auth_path, human_confirmed=human_confirmed, + enforcement_effective=enforcement_effective, ) except Exception: # noqa: BLE001 — logging must never break the execution path _engine_logger.warning("decision log persist failed (action %s); continuing", action.id) @@ -789,6 +791,7 @@ async def _handle_auth( now: datetime, surprise_score: float, eid: str, + enforcement_effective: str, ) -> CallToolResult: """Run the tiered challenge for an AUTH decision and act on the outcome.""" # C2 (ADR 0094): before the challenge is rendered, compute a bounded, @@ -861,6 +864,7 @@ async def _handle_auth( # challenge.NON_HUMAN_METHODS), and this column answers only the # narrower question its name asks — did a person approve this. human_confirmed=False, + enforcement_effective=enforcement_effective, ) return _verdict_result(decision) @@ -881,6 +885,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_elevation, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(decision) try: @@ -901,6 +906,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_elevation, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(decision) @@ -920,6 +926,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_post_approval_gate, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(redecision) @@ -946,6 +953,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_post_approval_gate, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(diverged) @@ -968,6 +976,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_post_approval_gate, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(denial) result = await _forward(downstream, tool_name, arguments, action) @@ -992,6 +1001,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_post_approval_gate, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(gate) if not result.isError: @@ -1008,6 +1018,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_post_approval_gate, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return _verdict_result(artifact_gate) await _observe_allowed(action, eid, surprise_score) @@ -1019,6 +1030,7 @@ async def _handle_auth( eid=eid, auth_path=AuthPath.proxy_challenge, human_confirmed=human_answered(auth_result.method), + enforcement_effective=enforcement_effective, ) return result @@ -1116,12 +1128,12 @@ async def _decide_and_execute( acted = acted_verdict(decision, state) if acted is Verdict.BLOCK: - await _persist(decision, action, eid=eid) + await _persist(decision, action, eid=eid, enforcement_effective=state) return _verdict_result(decision) if acted is Verdict.AUTH: return await _handle_auth( - downstream, tool_name, arguments, action, decision, now, score, eid + downstream, tool_name, arguments, action, decision, now, score, eid, state ) # PASS — real, or a discretionary verdict softened by monitor/off — claim @@ -1132,7 +1144,9 @@ async def _decide_and_execute( "single-use elevation already spent or unclaimable (action %s); denying", action.id ) denial = _single_use_unclaimable_decision(action) - await _persist(denial, action, auth_result="unclaimable", eid=eid) + await _persist( + denial, action, auth_result="unclaimable", eid=eid, enforcement_effective=state + ) return _verdict_result(denial) result = await _forward(downstream, tool_name, arguments, action) # The output-secret gate runs on EVERY result — success OR error (CRIT-2): a @@ -1148,7 +1162,7 @@ async def _decide_and_execute( # the model even though the call itself was PASS. Log only the block — # skip the baseline "allowed" observation below, since the outcome is # not confirmed safe (mirrors the host-hook: one log row, the block). - await _persist(gate, action, auth_result="blocked", eid=eid) + await _persist(gate, action, auth_result="blocked", eid=eid, enforcement_effective=state) return _verdict_result(gate) if not result.isError: artifact_gate = await _verify_artifact_digest(action, result) @@ -1156,7 +1170,9 @@ async def _decide_and_execute( # RB.7: a pinned artifact's fetched content disagreed with its # expected digest — withhold it from the agent, same shape as the # secret-scan gate above (one log row, the block, not a clean one). - await _persist(artifact_gate, action, auth_result="blocked", eid=eid) + await _persist( + artifact_gate, action, auth_result="blocked", eid=eid, enforcement_effective=state + ) return _verdict_result(artifact_gate) if not softened: # Teach the baseline only on a GENUINE pass. A softened would-have @@ -1169,5 +1185,5 @@ async def _decide_and_execute( # (monitor's whole value is showing what would have happened); `off` is the # silent, non-recording state — matching the host-hook pre path. if not softened or state == "monitor": - await _persist(decision, action, eid=eid) + await _persist(decision, action, eid=eid, enforcement_effective=state) return result diff --git a/src/doberman/storage/db.py b/src/doberman/storage/db.py index 0b39f166..3bbc9d75 100644 --- a/src/doberman/storage/db.py +++ b/src/doberman/storage/db.py @@ -95,7 +95,10 @@ #: because its vocabulary differs per writer. Additive ALTER on an existing #: table; fresh DBs get both from _SCHEMA below. Pre-migration rows keep NULL #: in both: "not recorded" is the truth for them, and it must not be guessed. -SCHEMA_VERSION = 17 +#: Version 18 adds ``decisions.policy_version`` (#515), storing the ``pv1:`` +#: identifier of the effective policy snapshot used for each decision. +#: Additive ALTER on existing databases; pre-migration rows remain NULL. +SCHEMA_VERSION = 18 # Every table uses CREATE TABLE IF NOT EXISTS so opening an older DB transparently # adds the new tables (a forward-only, additive migration; the one re-shape — @@ -145,7 +148,8 @@ effects_capped INTEGER, effects_hits_git INTEGER, effects_hits_outside_repo INTEGER, - effects_digest_fp TEXT + effects_digest_fp TEXT, + policy_version TEXT ); CREATE TABLE IF NOT EXISTS secret_fingerprints ( @@ -520,6 +524,10 @@ async def _migrate_legacy(conn: aiosqlite.Connection) -> None: if auth_cols and "human_confirmed" not in auth_cols: await _add_column_if_missing(conn, "decisions", "human_confirmed INTEGER") + decision_cols = await _table_columns(conn, "decisions") + if decision_cols and "policy_version" not in decision_cols: + await _add_column_if_missing(conn, "decisions", "policy_version TEXT") + async def _ensure_schema(conn: aiosqlite.Connection) -> None: # Additive migration: executescript creates any missing tables on an older diff --git a/src/doberman/storage/log.py b/src/doberman/storage/log.py index 707bb0f4..14fdb518 100644 --- a/src/doberman/storage/log.py +++ b/src/doberman/storage/log.py @@ -40,8 +40,8 @@ "final_verdict, decided_layer, reason_codes_json, auth_required, auth_result, " "auth_path, human_confirmed, elevation_id, " "entity_id, session_id, effects_file_count, effects_dir_count, effects_capped, " - "effects_hits_git, effects_hits_outside_repo, effects_digest_fp) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "effects_hits_git, effects_hits_outside_repo, effects_digest_fp, policy_version) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) _INSERT_SHADOW = ( @@ -61,7 +61,7 @@ "final_verdict, decided_layer, reason_codes_json, auth_required, auth_result, " "auth_path, human_confirmed, elevation_id, " "entity_id, session_id, effects_file_count, effects_dir_count, effects_capped, " - "effects_hits_git, effects_hits_outside_repo, effects_digest_fp " + "effects_hits_git, effects_hits_outside_repo, effects_digest_fp, policy_version " "FROM decisions ORDER BY id DESC" ) @@ -72,7 +72,7 @@ "final_verdict, decided_layer, reason_codes_json, auth_required, auth_result, " "auth_path, human_confirmed, elevation_id, " "entity_id, session_id, effects_file_count, effects_dir_count, effects_capped, " - "effects_hits_git, effects_hits_outside_repo, effects_digest_fp " + "effects_hits_git, effects_hits_outside_repo, effects_digest_fp, policy_version " "FROM decisions WHERE id > ? ORDER BY id ASC" ) @@ -133,6 +133,7 @@ "effects_hits_git", "effects_hits_outside_repo", "effects_digest_fp", + "policy_version", ] @@ -216,6 +217,7 @@ def build_record( auth_path: str | None = None, human_confirmed: bool | None = None, source_context_override: str | None = None, + policy_version: str | None = None, ) -> dict: """Build the single redacted record persisted and handed to every sink. @@ -259,6 +261,7 @@ def build_record( "elevation_id": elevation_id, "entity_id": entity_id, "session_id": session_id, + "policy_version": policy_version, } record.update(_effects_fields(decision.effects)) return record @@ -277,6 +280,7 @@ async def record_decision( auth_path: str | None = None, human_confirmed: bool | None = None, source_context_override: str | None = None, + enforcement_effective: str | None = None, ) -> bool: """Persist one redacted decision row and fan it out to sinks (best-effort). @@ -294,18 +298,49 @@ async def record_decision( it. Sink fan-out and the device-metrics rollup stay best-effort and do not affect this return value: the local row is what matters for retry. """ + + decision_now = now or datetime.now(timezone.utc) + + try: + from doberman.storage.policy_catalogue import ( + ORIGIN_DECISION, + VERSION_PREFIX, + observe_current, + ) + + policy_version = observe_current( + repo_root, + origin=ORIGIN_DECISION, + enforcement_effective=enforcement_effective, + now=decision_now, + ) + digest = ( + policy_version[len(VERSION_PREFIX) :] + if policy_version.startswith(VERSION_PREFIX) + else "" + ) + if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + policy_version = None + except Exception: # noqa: BLE001 — policy stamping must never block logging + logger.warning( + "decision log: could not determine policy version for action %s; continuing", + decision.action_id, + ) + policy_version = None + try: record = build_record( decision, action, auth_result=auth_result, elevation_id=elevation_id, - now=now or datetime.now(timezone.utc), + now=decision_now, entity_id=entity_id, session_id=session_id, auth_path=auth_path, human_confirmed=human_confirmed, source_context_override=source_context_override, + policy_version=policy_version, ) except Exception: # noqa: BLE001 — the decision log must never break execution logger.warning("decision log: could not build record for action %s", decision.action_id) @@ -341,6 +376,7 @@ async def record_decision( record["effects_hits_git"], record["effects_hits_outside_repo"], record["effects_digest_fp"], + record["policy_version"], ), ) for fp in action.payload_fingerprints: diff --git a/src/doberman/storage/policy_catalogue.py b/src/doberman/storage/policy_catalogue.py index 47d722d2..3a845818 100644 --- a/src/doberman/storage/policy_catalogue.py +++ b/src/doberman/storage/policy_catalogue.py @@ -165,6 +165,7 @@ def effective_enforcement_at_save(doc: PolicyDoc, now: datetime) -> str: ORIGIN_CHANGE = "change" # written by save_policy right after a gated/ledgered write ORIGIN_OBSERVED = "observed" # doctor / policy-versions saw this version in force +ORIGIN_DECISION = "decision" _SCHEMA = """ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); @@ -393,7 +394,8 @@ def verify_catalogue(repo_root: str = ".") -> dict[str, Any]: ``status``: ``ok`` · ``mismatch`` (a stored canonical no longer hashes to its id — the store was altered) · ``drift`` (the policy on disk is not the last recorded version — a change nobody has observed yet, or no catalogue at all). - Read-only: it never records anything. + ``unledgered`` (a version was introduced without a corresponding ledgered + policy change). Read-only: it never records anything. """ rows = _read(repo_root, "SELECT version, canonical FROM policy_versions") mismatched = [ @@ -405,10 +407,28 @@ def verify_catalogue(repo_root: str = ".") -> dict[str, Any]: current = policy_version(snapshot) if snapshot is not None else None latest = read_observations(repo_root, limit=1) recorded = latest[0]["version"] if latest else None + + observations = read_observations(repo_root) + unledgered = [] + seen = set() + + for observation in reversed(observations): + version = observation["version"] + if version in seen: + continue + seen.add(version) + + if observation["origin"] in (ORIGIN_OBSERVED, ORIGIN_DECISION): + unledgered.append(version) + elif observation["origin"] == ORIGIN_CHANGE and observation["ledger_ts"] is None: + unledgered.append(version) + if mismatched: status = "mismatch" elif current is None or current != recorded: status = "drift" + elif unledgered: + status = "unledgered" else: status = "ok" return { @@ -417,4 +437,5 @@ def verify_catalogue(repo_root: str = ".") -> dict[str, Any]: "mismatched": mismatched, "current": current, "recorded": recorded, + "unledgered": unledgered, } diff --git a/tests/integration/test_decision_log.py b/tests/integration/test_decision_log.py index e98a11ec..eb57fda8 100644 --- a/tests/integration/test_decision_log.py +++ b/tests/integration/test_decision_log.py @@ -174,7 +174,87 @@ async def test_decision_without_effect_set_persists_null_not_zero(tmp_path): assert row[column] is None # "unknown/no preview", never a fabricated 0/False -async def test_effect_set_from_real_walk_never_leaks_secret_or_raw_path(tmp_path): +async def test_decision_persists_policy_version(tmp_path): + root = str(tmp_path) + decision, action = _decision_and_action(Verdict.PASS, "policy-version") + + await record_decision(decision, action, repo_root=root) + + rows = await read_decisions(root) + + assert len(rows) == 1 + assert rows[0]["policy_version"].startswith("pv1:") + + +async def test_policy_version_failure_keeps_decision_row(tmp_path, monkeypatch): + import doberman.storage.policy_catalogue as catalogue + + def boom(*args, **kwargs): + raise RuntimeError("failed to determine policy version") + + monkeypatch.setattr(catalogue, "observe_current", boom) + + root = str(tmp_path) + decision, action = _decision_and_action(Verdict.PASS, "policy-version-failure") + + result = await record_decision(decision, action, repo_root=root) + + assert result is True + + rows = await read_decisions(root) + + assert len(rows) == 1 + assert rows[0]["action_id"] == "policy-version-failure" + assert rows[0]["final_verdict"] == "PASS" + assert rows[0]["policy_version"] is None + + +async def test_invalid_policy_version_is_not_persisted(tmp_path, monkeypatch): + import doberman.storage.policy_catalogue as catalogue + + monkeypatch.setattr( + catalogue, "observe_current", lambda *args, **kwargs: "pv1:not-a-valid-digest" + ) + + root = str(tmp_path) + decision, action = _decision_and_action(Verdict.PASS, "invalid-policy-version") + + result = await record_decision(decision, action, repo_root=root) + + assert result is True + + rows = await read_decisions(root) + + assert len(rows) == 1 + assert rows[0]["action_id"] == "invalid-policy-version" + assert rows[0]["policy_version"] is None + + +async def test_decision_after_policy_change_gets_new_policy_version(tmp_path): + from doberman.config import save_policy + from doberman.policy.checklist import recommend_policy + + root = str(tmp_path) + + save_policy(recommend_policy().with_mode("balanced"), root) + + decision1, action1 = _decision_and_action(Verdict.PASS, "policy-version-before") + assert await record_decision(decision1, action1, repo_root=root) + + save_policy(recommend_policy().with_mode("strict"), root) + + decision2, action2 = _decision_and_action(Verdict.PASS, "policy-version-after") + assert await record_decision(decision2, action2, repo_root=root) + + rows = await read_decisions(root) + + assert len(rows) == 2 + assert rows[0]["policy_version"].startswith("pv1:") + assert rows[1]["policy_version"].startswith("pv1:") + assert rows[0]["policy_version"] != rows[1]["policy_version"] + + +async def test_effect_set_from_real_walk_never_leaboomks_secret_or_raw_path(tmp_path): # A synthetic secret AND a raw absolute path both live in the delete-class # operand this EffectSet was computed from. Neither may reach any column. secret = "AKIA-FAKE-EFFECTSET-SECRET-9999" # noqa: S105 — synthetic test value diff --git a/tests/unit/test_cli_log_jsonl.py b/tests/unit/test_cli_log_jsonl.py index 7b92f842..3c4f410c 100644 --- a/tests/unit/test_cli_log_jsonl.py +++ b/tests/unit/test_cli_log_jsonl.py @@ -48,6 +48,7 @@ "auth_required": 1, "auth_result": "denied", "elevation_id": None, + "policy_version": "pv1:" + "a" * 64, }, { "id": 1, @@ -64,6 +65,7 @@ "auth_required": 0, "auth_result": None, "elevation_id": None, + "policy_version": "pv1:" + "b" * 64, }, ] @@ -489,3 +491,21 @@ async def _rows(*_a, **_k): obj = json.loads(result.stdout.splitlines()[0]) assert obj["final_verdict"] == "BLOCK" assert obj["source_context"] == "ambient:stub.collector" + + +def test_log_jsonl_emits_policy_version_without_canonical(tmp_path): + import doberman.cli.main as main_mod + + async def _rows(*_a, **_k): + return list(_ROWS) + + with patch.object(main_mod, "read_decisions", _rows): + result = runner.invoke(app, ["log", "--path", str(tmp_path), "--jsonl"]) + + assert result.exit_code == 0 + + objs = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + + assert objs[0]["policy_version"] == "pv1:" + "a" * 64 + assert objs[1]["policy_version"] == "pv1:" + "b" * 64 + assert "canonical" not in result.stdout diff --git a/tests/unit/test_cli_policy_history_json.py b/tests/unit/test_cli_policy_history_json.py index 25bf9a62..0c1afdbd 100644 --- a/tests/unit/test_cli_policy_history_json.py +++ b/tests/unit/test_cli_policy_history_json.py @@ -74,6 +74,9 @@ def test_policy_history_json_populated_and_deterministic(tmp_path): # newest first assert rows[0]["rule_id"] == "network" assert rows[1]["rule_id"] == "shell" + # Issue #515: ledger rows without a matching observation have no version link. + assert rows[0]["to_version"] is None + assert rows[1]["to_version"] is None assert set(rows[0]) >= { "ts", "rule_id", @@ -87,3 +90,16 @@ def test_policy_history_json_populated_and_deterministic(tmp_path): } expected = json.dumps(rows, sort_keys=True, separators=(",", ":"), default=str) assert first.stdout.strip() == expected + + +def test_policy_history_json_links_to_version(tmp_path): + from doberman.storage.policy_catalogue import ORIGIN_CHANGE, observe_current + + root = str(tmp_path) + asyncio.run(_seed_policy_rows(root)) + version = observe_current(root, origin=ORIGIN_CHANGE, ledger_ts="2026-01-02T00:00:00+00:00") + result = runner.invoke(app, ["policy-history", "--path", root, "--json"]) + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert rows[0]["to_version"] == version + assert rows[1]["to_version"] is None diff --git a/tests/unit/test_cli_policy_versions.py b/tests/unit/test_cli_policy_versions.py index 4f6dfd7f..d2631784 100644 --- a/tests/unit/test_cli_policy_versions.py +++ b/tests/unit/test_cli_policy_versions.py @@ -15,8 +15,14 @@ def _two_versions(tmp_path) -> list[str]: - save_policy(recommend_policy().with_mode("strict"), str(tmp_path)) - save_policy(recommend_policy().with_mode("balanced"), str(tmp_path)) + save_policy( + recommend_policy().with_mode("strict"), str(tmp_path), ledger_ts="2026-08-30T10:00:00+00:00" + ) + save_policy( + recommend_policy().with_mode("balanced"), + str(tmp_path), + ledger_ts="2026-08-30T10:00:00+00:00", + ) return [v["version"] for v in read_versions(str(tmp_path))] @@ -105,3 +111,15 @@ def test_verify_is_read_only_and_reports_drift(tmp_path): as_json = runner.invoke(app, ["policy-versions", "--path", str(tmp_path), "--verify", "--json"]) assert as_json.exit_code == 1 assert json.loads(as_json.stdout)["status"] == "drift" + + +def test_verify_reports_unledgered(tmp_path): + save_policy(recommend_policy().with_mode("strict"), str(tmp_path)) + result = runner.invoke(app, ["policy-versions", "--path", str(tmp_path), "--verify"]) + assert result.exit_code == 1 + assert result.stdout.startswith("unledgered:") + as_json = runner.invoke(app, ["policy-versions", "--path", str(tmp_path), "--verify", "--json"]) + assert as_json.exit_code == 1 + report = json.loads(as_json.stdout) + assert report["status"] == "unledgered" + assert report["unledgered"] diff --git a/tests/unit/test_db_schema.py b/tests/unit/test_db_schema.py index a1fcd4d7..089d8355 100644 --- a/tests/unit/test_db_schema.py +++ b/tests/unit/test_db_schema.py @@ -446,5 +446,5 @@ def test_schema_text_change_requires_a_version_bump(): from doberman.storage.db import _SCHEMA - assert SCHEMA_VERSION == 17 - assert hashlib.sha256(_SCHEMA.encode()).hexdigest()[:16] == "7acc17c533b1f2de" + assert SCHEMA_VERSION == 18 + assert hashlib.sha256(_SCHEMA.encode()).hexdigest()[:16] == "a8142e7b355df262" diff --git a/tests/unit/test_elicitation_prompter.py b/tests/unit/test_elicitation_prompter.py index bdd58036..94bb1901 100644 --- a/tests/unit/test_elicitation_prompter.py +++ b/tests/unit/test_elicitation_prompter.py @@ -278,7 +278,12 @@ def read_code(self, message: str) -> str: # pragma: no cover — never reached # The signature gains feature-specific scores on later branches — fill any # extras neutrally so this contract test holds across the whole stack. extras: dict = {} - defaults = {"abnormality_score": 0.0, "surprise_score": 0.0, "eid": "entity-test"} + defaults = { + "abnormality_score": 0.0, + "surprise_score": 0.0, + "eid": "entity-test", + "enforcement_effective": "enforce", + } for name in inspect.signature(executor._handle_auth).parameters: if name in defaults: extras[name] = defaults[name] diff --git a/tests/unit/test_friction_tune.py b/tests/unit/test_friction_tune.py index cb25c8cc..242a4ca6 100644 --- a/tests/unit/test_friction_tune.py +++ b/tests/unit/test_friction_tune.py @@ -626,8 +626,8 @@ def test_redaction_never_leaks_the_raw_filename(tmp_path): def test_decision_columns_end_with_entity_session_and_effects_fields(): - # entity_id/session_id then the #556 EffectSet fields, in insert/select order. - assert _DECISION_COLUMNS[-8:] == [ + # entity_id/session_id, the #556 EffectSet fields, then the policy version. + assert _DECISION_COLUMNS[-9:] == [ "entity_id", "session_id", "effects_file_count", @@ -636,6 +636,7 @@ def test_decision_columns_end_with_entity_session_and_effects_fields(): "effects_hits_git", "effects_hits_outside_repo", "effects_digest_fp", + "policy_version", ] diff --git a/tests/unit/test_otel_sink.py b/tests/unit/test_otel_sink.py index 6903ceb6..a24f6810 100644 --- a/tests/unit/test_otel_sink.py +++ b/tests/unit/test_otel_sink.py @@ -48,9 +48,9 @@ def _build_record(**overrides: Any) -> dict[str, Any]: log.py breaks the tests here instead of silently emptying the export. Non-exportable fields (action_id, agent_role, target_path_class, - source_context, auth_required, elevation_id, entity_id, and the #556 - EffectSet fields) are included because the real producer always emits - them — the sink must strip them. + source_context, auth_required, elevation_id, entity_id, policy_version, + and the #556 EffectSet fields) are included because the real producer + always emits them — the sink must strip them. """ base: dict[str, Any] = { # ── exported (in _ALLOWED_FIELDS) ────────────────────────────────── @@ -83,6 +83,7 @@ def _build_record(**overrides: Any) -> dict[str, Any]: "effects_hits_git": None, "effects_hits_outside_repo": None, "effects_digest_fp": None, + "policy_version": None, } base.update(overrides) return base diff --git a/tests/unit/test_policy_catalogue.py b/tests/unit/test_policy_catalogue.py index ab1d0c97..708da40e 100644 --- a/tests/unit/test_policy_catalogue.py +++ b/tests/unit/test_policy_catalogue.py @@ -20,6 +20,7 @@ from doberman.storage.policy_catalogue import ( CATALOGUE_SCHEMA_VERSION, ORIGIN_CHANGE, + ORIGIN_DECISION, ORIGIN_OBSERVED, SNAPSHOT_SCHEMA, VERSION_PREFIX, @@ -233,19 +234,23 @@ def test_find_versions_matches_hex_prefixes(tmp_path): def test_verify_reports_ok_drift_and_mismatch(tmp_path): root = str(tmp_path) assert verify_catalogue(root)["status"] == "drift" # nothing recorded yet - observe_current(root, origin=ORIGIN_OBSERVED, now=_T1) + # Issue #515: an "ok" version must come from a ledgered policy change. + observe_current(root, origin=ORIGIN_CHANGE, ledger_ts=_T1.isoformat(), now=_T1) report = verify_catalogue(root) assert report["status"] == "ok" and report["current"] == report["recorded"] assert report["versions"] == 1 and report["mismatched"] == [] # A hand edit of policies.yaml (bypassing every gate) shows as drift ... - save_policy(recommend_policy().with_mode("paranoid"), root) # records a change + save_policy( + recommend_policy().with_mode("paranoid"), root, ledger_ts=_T2.isoformat() + ) # records a change (tmp_path / ".doberman" / "policies.yaml").write_text( (tmp_path / ".doberman" / "policies.yaml").read_text().replace("paranoid", "light") ) report = verify_catalogue(root) assert report["status"] == "drift" and report["current"] != report["recorded"] # ... and re-observing clears it. - observe_current(root, origin=ORIGIN_OBSERVED, now=_T2) + # Record the changed policy through the ledger before verifying again. + observe_current(root, origin=ORIGIN_CHANGE, ledger_ts=_T3.isoformat(), now=_T3) assert verify_catalogue(root)["status"] == "ok" # Tampering with stored content is a mismatch naming the id. conn = sqlite3.connect(str(catalogue_path(root))) @@ -257,6 +262,28 @@ def test_verify_reports_ok_drift_and_mismatch(tmp_path): assert report["status"] == "mismatch" and report["mismatched"] == [victim] +def test_verify_reports_unledgered_change_without_ledger_ts(tmp_path): + root = str(tmp_path) + + observe_current(root, origin=ORIGIN_CHANGE, now=_T1) + + report = verify_catalogue(root) + + assert report["status"] == "unledgered" + assert report["unledgered"] == [report["current"]] + + +def test_verify_reports_unledgered_version_first_seen_by_decision(tmp_path): + root = str(tmp_path) + + observe_current(root, origin=ORIGIN_DECISION, now=_T1) + + report = verify_catalogue(root) + + assert report["status"] == "unledgered" + assert report["unledgered"] == [report["current"]] + + def test_catalogue_schema_and_permissions(tmp_path): root = str(tmp_path) record_version(root, _snap(), origin=ORIGIN_OBSERVED, now=_T1)