Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/741.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Decision logs record the effective policy version, with policy history and verification exposing version provenance and unledgered snapshots (#741)
16 changes: 16 additions & 0 deletions src/doberman/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
3 changes: 3 additions & 0 deletions src/doberman/hosthooks/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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``.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/doberman/hosthooks/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/doberman/hosthooks/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/doberman/hosthooks/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -224,6 +225,7 @@ def _record_history(
auth_result=auth_result,
auth_path=auth_path,
human_confirmed=human_confirmed,
enforcement_effective=enforcement_effective,
)


Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/doberman/hosthooks/spine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions src/doberman/proxy/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -1148,15 +1162,17 @@ 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)
if artifact_gate is not None:
# 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
Expand All @@ -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
12 changes: 10 additions & 2 deletions src/doberman/storage/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading