From 575ba7974cfac684b0851e74a9a14eabde024230 Mon Sep 17 00:00:00 2001 From: coreytshaffer <78175888+coreytshaffer@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:44:26 -0700 Subject: [PATCH] Implement CR-117 task show signature verification --- docs/change/change_log.md | 1 + .../CR-117-task-show-verify-signatures.md | 76 ++++++++ docs/current_backlog.md | 11 +- ...ure-agent-maintainer-handoff-2026-07-07.md | 16 +- .../outer-loop-control-review-recipe.md | 2 +- docs/operations/reviewer-smoke-runbook.md | 16 ++ .../task-show-signature-verification.md | 51 +++++ ...test_cr_117_task_show_verify_signatures.py | 178 ++++++++++++++++++ triage_core/task_ledger.py | 147 +++++++++++++++ triage_core/tc_cli.py | 45 ++++- 10 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 docs/change/requests/CR-117-task-show-verify-signatures.md create mode 100644 docs/operations/task-show-signature-verification.md create mode 100644 tests/test_cr_117_task_show_verify_signatures.py diff --git a/docs/change/change_log.md b/docs/change/change_log.md index 3bddadf..5f40cb4 100644 --- a/docs/change/change_log.md +++ b/docs/change/change_log.md @@ -5,6 +5,7 @@ This file provides a chronological, human-readable record of applied codebase an *Note: For operational task and run history, consult `.triagecore/ledger.jsonl`.* ## [Unreleased] +- Implemented CR-117 (Task-Show Opt-In Signature Verification): Added an opt-in `tc task show --verify-signatures` path that verifies the signed ledger events (`route_audit`, `validation_result`, `route_decision`) belonging to the shown task via a new task-scoped `verify_task_event_signatures` helper, reusing the CR-097 fail-closed identity-registry-load categories. Fail-closed: invalid or malformed signatures — and identity-registry load failures — exit 1; unsigned signed-type events remain informational and exit 0; there is no `--strict` in this slice. Flag-off output is byte-for-byte unchanged, and the whole-ledger `tc audit --verify-signatures` behavior is untouched. Telemetry lane references shift to CR-118+ (schema candidate CR-118, probe candidate CR-119+) in active planning and handoff docs. Read-only and evidence-only: no changes to approval, signing, routing, or execution semantics, and no new egress, backends, or ledger writes. - Implemented CR-115 (Final Control-Plane Extraction Package): Added four docs-only doctrine/handoff documents — a control-plane invariant checklist mapping each invariant to its enforcement locus and reviewer verification command, a repeatable outer-loop control review recipe with risk classification and stop conditions, a final capability note recording that the assistant model's role was drafting/review/evidence collection under plan-gated human control with no runtime dependency on any model, and a future agent/maintainer handoff entry point with ordered next slices and binding conventions. Telemetry lane references renumbered from CR-115+ to CR-116+. All documents preserve the distinction that signatures, manifests, evaluator verdicts, and model recommendations are evidence, not approval. No runtime code, tests, tags, or approval/signature/identity/routing/privacy/audit semantics were changed. - Implemented CR-114 (Reviewer Checkpoint 2026-07-07 and Tag Reconciliation): Added a docs-only dated reviewer checkpoint consolidating the CR-100 through CR-113 arc at HEAD `f8bf33c` — fresh local validation evidence (803 passed / 2 skipped full regression, privacy invariant pass over 698 ledger records, route_decision signature verification, identity listing, backend-free benchmark fixture listing, doctor runtime-safety postures) — and recorded the finding that the `v0.1.0-reviewer-checkpoint-2026-07-02` tag recommended by the previous checkpoint was never created, with the `355c521` anchor preserved in-document and exact operator-run tag commands recommended but deliberately not executed. No runtime code, tests, tags, or approval/signature/identity/routing/privacy/audit semantics were changed. - Implemented CR-113 (Local Backend Telemetry Design Brief): Added a docs-only design brief defining the boundaries for a future read-only local backend telemetry slice — metadata-only availability and model/runtime identity observations for Ollama, LM Studio, and llama.cpp local endpoints, with candidate fields and per-field privacy considerations, a closed failure-category vocabulary, tiered evidence provenance (synthetic_fixture, local_metadata_probe, operator_recorded), an opt-in probe-disabled default posture, separation from fixture report determinism, and a reviewer path requiring no model execution — with no telemetry implementation, runtime changes, new dependencies, ledger writes, cloud telemetry, or energy/cost/quality/safety claims. diff --git a/docs/change/requests/CR-117-task-show-verify-signatures.md b/docs/change/requests/CR-117-task-show-verify-signatures.md new file mode 100644 index 0000000..837f5a3 --- /dev/null +++ b/docs/change/requests/CR-117-task-show-verify-signatures.md @@ -0,0 +1,76 @@ +# CR-117: Task-Show Opt-In Signature Verification + +## Status + +Implemented (runtime-safe, evidence-only) + +## Summary + +Add an opt-in `tc task show --verify-signatures` flag that verifies the +signatures of the signed ledger events belonging to the shown task and +reports the result inline. This decouples signature checking from the +whole-ledger audit command's CLI-abort mechanics by introducing a +task-scoped verification helper that returns a categorized summary and +never calls `sys.exit`; the command decides its own exit behavior. It +reuses the CR-097 fail-closed identity-registry-load categories. No +runtime, approval, signing, routing, or execution semantics change. + +## Scope + +- Add `verify_task_event_signatures(ledger_path, task_id)` to + `triage_core/task_ledger.py`. It reuses the existing per-event verifiers + (`route_audit`, `validation_result`, `route_decision`) and the shared + `LedgerSignatureVerificationSummary` / `LedgerSignatureVerificationFinding` + types, restricts attention to events whose `task_id` matches the request, + and covers all three signed event types in a single pass. It never calls + `sys.exit`. The whole-ledger `verify_ledger_event_signatures_in_ledger` + is left unchanged. +- Add `--verify-signatures` (default off) to the `tc task show` parser and + extend `tc_task_show` to run the task-scoped verifier when the flag is + set, printing a summary and per-event findings in place of the existing + static "not checked" line. +- On identity-registry load failure, reuse CR-097's + `_handle_registry_load_failure` (prints `reason=registry_load_failed` + plus category, exits 1). + +## Behavior and exit codes + +- Flag off (default): output is byte-for-byte unchanged, including the + "Signature verification: not checked by this command; run tc audit + --verify-signatures" line. +- Flag on: prints `valid_signed / invalid_signed / unsigned / malformed / + skipped_non_target` plus per-event `PASS`/`FAIL` findings. +- Exit codes: `0` when the task is found and verification is clean; + `1` for invalid or malformed signatures (fail-closed via + `should_fail(strict=False)`), identity-registry load failure, or the + existing task-not-found case. Unsigned signed-type events remain + informational and do not fail; there is no `--strict` in this slice. +- An unparseable ledger line cannot be attributed to a task, so it is + counted as `malformed` fail-closed rather than silently ignored. + +## Non-Goals + +- No change to whole-ledger `tc audit --verify-signatures` behavior. +- No `--strict` flag and no failing on unsigned events in this slice. +- No change to signing, approval, routing, admission, or execution + semantics; verification only surfaces existing evidence. +- No new backends, network egress, dependencies, or ledger writes; the + command is read-only over the local ledger and identity registry. + +## Validation + +- New `tests/test_cr_117_task_show_verify_signatures.py`: flag-off output + unchanged; valid signed event exits 0; tampered signature exits 1; + malformed ledger line exits 1; unsigned signed-type event exits 0; + identity-registry load failure exits 1 with `category=malformed_registry`; + task-not-found exits 1. +- Focused suite (task show, audit CLI, CR-097 registry load, task ledger, + tc CLI) plus the full `python -m pytest -q` regression. + +## Notes + +A valid signature is evidence that an event was signed by a known, +authorized identity — not evidence of approval, safety, or correctness. +Claiming CR-117 shifts the telemetry lane references to CR-118+ (schema +candidate CR-118, probe candidate CR-119+) in active planning and handoff +docs, following the same renumbering precedent CR-115 and CR-116 set. diff --git a/docs/current_backlog.md b/docs/current_backlog.md index 4047b9d..871c9a6 100644 --- a/docs/current_backlog.md +++ b/docs/current_backlog.md @@ -8,14 +8,14 @@ This document summarizes the active TriageCore backlog after CR-115. - CR-115: Final Control-Plane Extraction Package - Status: complete via CR-115 (docs-only); no tag — CR-114 carried the checkpoint tags, CR-115 is doctrine/handoff material committed normally - - Purpose: Bank the exit-window doctrine as four operations docs — control-plane invariant checklist (invariant → enforcement locus → reviewer verification command), outer-loop control review recipe (repeatable external review process with risk classes and stop conditions), Fable final capability note (model role was drafting/review/evidence collection under plan-gated human control; no runtime dependency on any model), and future agent/maintainer handoff (cold-start entry point, ordered next slices, binding conventions). Signatures, manifests, evaluator verdicts, and model recommendations remain evidence, not approval. Telemetry lane renumbered to CR-116+. + - Purpose: Bank the exit-window doctrine as four operations docs — control-plane invariant checklist (invariant → enforcement locus → reviewer verification command), outer-loop control review recipe (repeatable external review process with risk classes and stop conditions), Fable final capability note (model role was drafting/review/evidence collection under plan-gated human control; no runtime dependency on any model), and future agent/maintainer handoff (cold-start entry point, ordered next slices, binding conventions). Signatures, manifests, evaluator verdicts, and model recommendations remain evidence, not approval. Telemetry lane renumbered to CR-118+ after CR-117 claimed the task-show signature-verification slice. - CR-114: Reviewer Checkpoint 2026-07-07 and Tag Reconciliation - Status: complete via CR-114 (docs-only); recommended checkpoint tags remain operator-run future steps and do not exist until created and pushed deliberately - - Purpose: Consolidate the CR-100 through CR-113 route-worker telemetry and runtime strategy lanes into a dated reviewer checkpoint at HEAD `f8bf33c` with fresh validation evidence (803 passed / 2 skipped, privacy invariants, signature verification, identity list, backend-free benchmark listing), and reconcile the never-created `v0.1.0-reviewer-checkpoint-2026-07-02` tag by preserving the `355c521` anchor in-document and recommending exact tag commands without running them. Note: CR-114 was claimed by this checkpoint slice at commit time, shifting the telemetry probe implementation candidate previously referenced as "CR-114+"; after CR-115 was claimed by the extraction package, the telemetry lane is now CR-116+. + - Purpose: Consolidate the CR-100 through CR-113 route-worker telemetry and runtime strategy lanes into a dated reviewer checkpoint at HEAD `f8bf33c` with fresh validation evidence (803 passed / 2 skipped, privacy invariants, signature verification, identity list, backend-free benchmark listing), and reconcile the never-created `v0.1.0-reviewer-checkpoint-2026-07-02` tag by preserving the `355c521` anchor in-document and recommending exact tag commands without running them. Note: CR-114 was claimed by this checkpoint slice at commit time, shifting the telemetry probe implementation candidate previously referenced as "CR-114+"; after CR-115 was claimed by the extraction package and CR-117 by the task-show signature-verification slice, the telemetry lane is now CR-118+. - CR-113: Local Backend Telemetry Design Brief - - Status: complete via CR-113 (docs-only); telemetry probe implementation remains a gated future candidate (CR-116+) + - Status: complete via CR-113 (docs-only); telemetry probe implementation remains a gated future candidate (CR-118+) - Purpose: Define the boundaries for a future read-only local backend telemetry slice — metadata-only availability and model/runtime identity observations for Ollama, LM Studio, and llama.cpp local endpoints — including candidate fields with privacy considerations, a closed failure vocabulary, evidence-tier provenance, an opt-in probe posture, and a reviewer path that requires no model execution, before any probe code is written. - CR-112: Recorded Runtime Strategy Report Export @@ -208,6 +208,7 @@ For operator UX, future slices should focus on reviewability, export polish, and ## Next Candidate Slices -- **Local backend telemetry probe (read-only)**: Implement the metadata-only local backend probe bounded by the CR-113 design brief ([local-backend-telemetry.md](operations/local-backend-telemetry.md)) — opt-in, explicit-endpoint, fail-closed, no generation calls, no ledger writes. +- **[done] Signature verification on task show (CR-117, runtime-safe)**: Opt-in `tc task show --verify-signatures` verifies the shown task's signed ledger events via a task-scoped helper that reuses the CR-097 fail-closed categories; fail-closed (exits 1) on invalid or malformed signatures and registry-load failure, while unsigned signed-type events stay informational (exit 0). Whole-ledger `tc audit --verify-signatures` behavior is unchanged ([task-show-signature-verification.md](operations/task-show-signature-verification.md)). +- **Telemetry schema and synthetic-fixture validation (CR-118 candidate)**: Harden the existing local backend probe's serialized record contract before any further probe work — strict schema, pure validator, synthetic fixtures only, no endpoint calls, no routing integration, no ledger writes, and no CLI behavior changes. +- **Local backend telemetry probe follow-up (CR-119+ candidate)**: Keep any probe behavior changes bounded by the CR-113 design brief ([local-backend-telemetry.md](operations/local-backend-telemetry.md)) — opt-in, explicit-endpoint, fail-closed, no generation calls, no ledger writes. - **Reviewer checkpoint or release-hygiene slice**: Freeze the completed route-worker reviewer lane into a concise checkpoint, packaging note, or release-hygiene update instead of adding more telemetry features. -- **Signature verification options on task show**: Decouple signature checking from CLI-abort mechanics so `tc task show --verify-signatures` verifies signatures safely. diff --git a/docs/operations/future-agent-maintainer-handoff-2026-07-07.md b/docs/operations/future-agent-maintainer-handoff-2026-07-07.md index 32444f6..dabc8fd 100644 --- a/docs/operations/future-agent-maintainer-handoff-2026-07-07.md +++ b/docs/operations/future-agent-maintainer-handoff-2026-07-07.md @@ -16,8 +16,9 @@ linked from here. - **Regression suite:** 803 passed / 2 skipped at `f8bf33c` (the commit under `74260b3`); CI green on Python 3.10/3.11/3.12 at `88c9cfb`. - **CR ledger:** CR-100 → CR-115 complete. **CR-114 = the evidence - checkpoint. CR-115 = this extraction package. CR-116+ = the telemetry - lane** (the numbering marks the boundary between deterministic evidence + checkpoint. CR-115 = this extraction package. CR-117 = the task-show + signature-verification slice. CR-118+ = the telemetry lane** (the + numbering marks the boundary between deterministic evidence work and the lane's first non-deterministic slice). ## Reading Order @@ -26,17 +27,16 @@ linked from here. 2. [control-plane-invariant-checklist.md](control-plane-invariant-checklist.md) — what must never break 3. [fable-exit-audit-2026-07-07.md](fable-exit-audit-2026-07-07.md) — ranked evidence, gaps, and slice plan 4. [../current_backlog.md](../current_backlog.md) — work lanes -5. [local-backend-telemetry.md](local-backend-telemetry.md) — the CR-116+ boundary brief +5. [local-backend-telemetry.md](local-backend-telemetry.md) — the CR-118+ boundary brief 6. [fable-final-capability-note-2026-07-07.md](fable-final-capability-note-2026-07-07.md) — how to weigh model-authored artifacts ## Next Slices, In Order | Order | Slice | Risk class | |---|---|---| -| 1 | Review-queue residue disposition note (the ~100 pending `tc review list` items in local state are deliberate early-June experiment residue, not abandoned approvals) | docs-only | -| 2 | `tc task show --verify-signatures` opt-in, reusing CR-097 fail-closed categories | runtime-safe | -| 3 | Telemetry schema + `synthetic_fixture` validation only — no probe code (CR-116 candidate) | test-only | -| 4 | Telemetry probe, exactly within the CR-113 brief: opt-in, explicit endpoint, `probe_disabled` default, closed failure vocabulary (CR-117+ candidate) | runtime-risky | +| 1 | ~~`tc task show --verify-signatures` opt-in, reusing CR-097 fail-closed categories~~ — done (CR-117, runtime-safe): [task-show-signature-verification.md](task-show-signature-verification.md) | runtime-safe | +| 2 | Telemetry schema + `synthetic_fixture` validation only — no probe code (CR-118 candidate) | test-only | +| 3 | Telemetry probe, exactly within the CR-113 brief: opt-in, explicit endpoint, `probe_disabled` default, closed failure vocabulary (CR-119+ candidate) | runtime-risky | Deferred deliberately (do not start without a dedicated approved CR): Issue #73 key rotation; signed-event expansion to new event types; @@ -55,7 +55,7 @@ capture; any new execution surface. - **Claim CR numbers at commit time**, not draft time (parallel sessions have collided before). - **Boundary crossings get a docs-only brief first** (the CR-113 → - CR-116+ pattern), then a schema/fixture slice, then implementation. + CR-118+ pattern), then a schema/fixture slice, then implementation. - **Docs-only validation:** `git diff --check`. Code-bearing validation: `python -m pytest -q`. diff --git a/docs/operations/outer-loop-control-review-recipe.md b/docs/operations/outer-loop-control-review-recipe.md index dc003a7..863da03 100644 --- a/docs/operations/outer-loop-control-review-recipe.md +++ b/docs/operations/outer-loop-control-review-recipe.md @@ -85,7 +85,7 @@ Use this structure (the format of the 2026-07-07 exit audit): is a docs-only checkpoint that freezes the current verified state; the last slices are anything that crosses a determinism, autonomy, or enforcement boundary. A boundary crossing should always be preceded by a -docs-only brief that defines it (the CR-113 → CR-116+ pattern). +docs-only brief that defines it (the CR-113 → CR-118+ pattern). ## Stop Conditions diff --git a/docs/operations/reviewer-smoke-runbook.md b/docs/operations/reviewer-smoke-runbook.md index 560baf0..2e576ea 100644 --- a/docs/operations/reviewer-smoke-runbook.md +++ b/docs/operations/reviewer-smoke-runbook.md @@ -106,6 +106,22 @@ Expected interpretation: python -m pytest -q ``` +### `tc task show --verify-signatures` + +Expected interpretation: + +- Without the flag, `tc task show` prints the task's metadata and event + timeline and does not check signatures (unchanged behavior). +- With `--verify-signatures`, the command verifies the signed ledger + events (`route_audit`, `validation_result`, `route_decision`) belonging + to that task and prints a `valid_signed / invalid_signed / unsigned / + malformed / skipped_non_target` summary plus per-event findings. +- It is **fail-closed**: any invalid or malformed signature, or an + identity-registry load failure, exits 1. Unsigned signed-type events are + informational and exit 0; there is no `--strict` in this slice. +- A valid signature is evidence that an event was signed by a known, + authorized identity — not evidence of approval, safety, or correctness. + ## What This Smoke Runbook Does Not Claim - It does not prove production readiness. diff --git a/docs/operations/task-show-signature-verification.md b/docs/operations/task-show-signature-verification.md new file mode 100644 index 0000000..565d4ea --- /dev/null +++ b/docs/operations/task-show-signature-verification.md @@ -0,0 +1,51 @@ +# Task-Show Signature Verification (`tc task show --verify-signatures`) + +## Purpose + +`tc task show --verify-signatures` is an opt-in, read-only way to +verify the signatures of the signed ledger events that belong to a single +task, without leaving the task-inspection view. It is the task-scoped +companion to the whole-ledger `tc audit --verify-signatures` command. + +## What it does + +- Default (no flag): prints the task's metadata and event timeline and does + **not** check signatures — unchanged behavior. +- With `--verify-signatures`: verifies the signed event types + (`route_audit`, `validation_result`, `route_decision`) belonging to that + task and prints: + - a summary line — + `valid_signed / invalid_signed / unsigned / malformed / skipped_non_target`; + - one `PASS`/`FAIL` finding per signed event, including a `reason=` code + on failures (for example `signature_mismatch`, `unknown_agent`, + `revoked_agent`, `unauthorized_capability`). + +## Fail-closed behavior and exit codes + +- `0` — task found and verification clean (no invalid or malformed + signatures). +- `1` — any invalid or malformed signature; an identity-registry load + failure (reusing the CR-097 categories: `unreadable_registry`, + `malformed_registry`, `invalid_identity_record`); or the task was not + found. +- Unsigned signed-type events are informational and do **not** fail the + command; there is no `--strict` in this slice. +- An unparseable ledger line cannot be attributed to a task, so it counts + as `malformed` and is treated fail-closed. + +## Scope and guarantees + +- Read-only: no changes to the ledger, identity registry, or any approval, + signing, routing, or execution behavior. No network egress or new + backends. +- The whole-ledger `tc audit --verify-signatures` command is unchanged and + remains the tool for global signature and ledger-integrity checks. +- A valid signature is evidence that an event was signed by a known, + authorized identity — it is not evidence of approval, safety, or + correctness. + +## Example + +```powershell +python -m triage_core.tc_cli task show --verify-signatures +``` diff --git a/tests/test_cr_117_task_show_verify_signatures.py b/tests/test_cr_117_task_show_verify_signatures.py new file mode 100644 index 0000000..e18ef46 --- /dev/null +++ b/tests/test_cr_117_task_show_verify_signatures.py @@ -0,0 +1,178 @@ +"""CR-117: opt-in ``tc task show --verify-signatures`` fail-closed path. + +Verifies the signed ledger events belonging to a single task, reusing the +CR-097 fail-closed categories. Fail-closed: invalid or malformed signatures +exit 1; unsigned signed-type events remain informational (exit 0). The +default (flag-off) output must be byte-for-byte unchanged. +""" + +import json +import pytest +from pathlib import Path +from unittest.mock import patch + +from triage_core.tc_cli import tc_task_show +from triage_core.task_ledger import TaskLedger +from triage_core.agent_identity import AgentIdentityRegistry + + +def _seed_task(ledger, task_id, title="Task"): + ledger.append_event( + task_id, "task_created", {"title": title, "description": "x"} + ) + + +def test_flag_off_output_is_unchanged_and_exits_normally(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + _seed_task(ledger, "t1", title="Build UI Dashboard") + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_dir / "ledger.jsonl"): + tc_task_show("t1") # default: verify_signatures=False + + out = capsys.readouterr().out + assert "Task ID: t1" in out + assert ( + "Signature verification: not checked by this command; " + "run tc audit --verify-signatures" in out + ) + # No summary lines are emitted when the flag is off. + assert "valid_signed=" not in out + + +def test_verify_valid_signed_route_decision_exits_0(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + registry = AgentIdentityRegistry(ledger_dir=ledger_dir) + registry.generate_identity("router-tools", "router_tools", ["route_decision:sign"]) + + _seed_task(ledger, "t-valid") + ledger.append_signed_route_decision_event( + "t-valid", + { + "selected_route": "local_fast", + "reason": "signed_ok", + "route_source": "resilience_router", + }, + signing_registry=registry, + signing_agent_id="router-tools", + ) + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_dir / "ledger.jsonl"): + tc_task_show("t-valid", verify_signatures=True) # no SystemExit + + out = capsys.readouterr().out + assert "Signature verification:" in out + assert "valid_signed=1" in out + assert "invalid_signed=0" in out + assert "PASS event_type=route_decision task_id=t-valid agent_id=router-tools" in out + + +def test_verify_tampered_signature_exits_1(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + registry = AgentIdentityRegistry(ledger_dir=ledger_dir) + registry.generate_identity("router-tools", "router_tools", ["route_decision:sign"]) + + _seed_task(ledger, "t-bad") + ledger.append_signed_route_decision_event( + "t-bad", + { + "selected_route": "local_fast", + "reason": "tamper_target", + "route_source": "resilience_router", + }, + signing_registry=registry, + signing_agent_id="router-tools", + ) + + # Tamper only the signed line; keep task_created so the task is still found. + ledger_path = ledger_dir / "ledger.jsonl" + new_lines = [] + for line in ledger_path.read_text(encoding="utf-8").splitlines(): + event = json.loads(line) + if event.get("event_type") == "route_decision": + event["payload"] = dict(event["payload"]) + event["payload"]["selected_route"] = "cloud_primary" + new_lines.append(json.dumps(event)) + ledger_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_path): + with pytest.raises(SystemExit) as exc: + tc_task_show("t-bad", verify_signatures=True) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "invalid_signed=1" in out + assert "reason=signature_mismatch" in out + assert "tamper_target" not in out + + +def test_verify_malformed_ledger_line_exits_1(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + _seed_task(ledger, "t-mal") + + ledger_path = ledger_dir / "ledger.jsonl" + with ledger_path.open("a", encoding="utf-8") as f: + f.write("{not json\n") + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_path): + with pytest.raises(SystemExit) as exc: + tc_task_show("t-mal", verify_signatures=True) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "malformed=1" in out + + +def test_verify_unsigned_signed_type_event_exits_0(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + _seed_task(ledger, "t-uns") + ledger.append_event( + "t-uns", + "route_audit", + {"decision": "allowed", "reason_code": "legacy_unsigned"}, + ) + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_dir / "ledger.jsonl"): + tc_task_show("t-uns", verify_signatures=True) # no SystemExit + + out = capsys.readouterr().out + assert "unsigned=1" in out + assert "invalid_signed=0" in out + + +def test_verify_signatures_registry_load_failure_exits_1(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + ledger = TaskLedger(str(ledger_dir)) + _seed_task(ledger, "t-reg") + + registry = AgentIdentityRegistry(ledger_dir=ledger_dir) + registry.registry_path.parent.mkdir(parents=True, exist_ok=True) + registry.registry_path.write_text('{ "agents": [', encoding="utf-8") # malformed + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_dir / "ledger.jsonl"): + with patch("triage_core.tc_cli._identity_registry", return_value=registry): + with pytest.raises(SystemExit) as exc: + tc_task_show("t-reg", verify_signatures=True) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "reason=registry_load_failed" in out + assert "category=malformed_registry" in out + assert "Traceback" not in out + + +def test_task_not_found_exits_1_even_with_flag(tmp_path, capsys): + ledger_dir = tmp_path / ".triagecore" + TaskLedger(str(ledger_dir)) # create empty ledger dir + + with patch("triage_core.tc_cli._ledger_path", return_value=ledger_dir / "ledger.jsonl"): + with pytest.raises(SystemExit) as exc: + tc_task_show("does-not-exist", verify_signatures=True) + + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "reason=task_not_found" in out diff --git a/triage_core/task_ledger.py b/triage_core/task_ledger.py index 744c200..6af098d 100644 --- a/triage_core/task_ledger.py +++ b/triage_core/task_ledger.py @@ -916,6 +916,153 @@ def verify_ledger_event_signatures_in_ledger( return summary +def verify_task_event_signatures( + ledger_path: str | Path, + task_id: str, +) -> LedgerSignatureVerificationSummary: + """Verify signatures of the signed ledger events belonging to one task. + + Task-scoped counterpart to ``verify_ledger_event_signatures_in_ledger``: + it reuses the same per-event verifiers and the shared finding/summary + types, but restricts attention to events whose ``task_id`` matches the + requested task and covers all three signed event types in a single pass. + + This helper never calls ``sys.exit``; the caller decides exit behaviour + from the returned summary (fail-closed when ``invalid_signed`` or + ``malformed`` is greater than zero). An unparseable ledger line cannot be + attributed to a task, so it is counted as ``malformed`` fail-closed rather + than silently ignored. Whole-ledger audit behaviour is left unchanged. + """ + signed_event_verifiers = { + "route_audit": verify_route_audit_event_signature, + "validation_result": verify_validation_result_event_signature, + "route_decision": verify_route_decision_event_signature, + } + + summary = LedgerSignatureVerificationSummary() + ledger_path = Path(ledger_path) + registry = AgentIdentityRegistry(ledger_dir=ledger_path.parent) + registry.load() + target_task_id = str(task_id) + + with ledger_path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + summary.malformed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type="unknown", + task_id="unknown", + failure_reason="malformed_json", + ) + ) + continue + + if str(event.get("task_id", "")) != target_task_id: + continue + + event_type = event.get("event_type") + verifier = signed_event_verifiers.get(event_type) + if verifier is None: + summary.skipped_non_target += 1 + continue + + signature_metadata = event.get("signature_metadata") + if not signature_metadata: + summary.unsigned += 1 + continue + + agent_id = "" + if isinstance(signature_metadata, dict): + agent_id = str(signature_metadata.get("agent_id", "")) + try: + if verifier(event, registry): + summary.valid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="PASS", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + ) + ) + else: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="signature_mismatch", + ) + ) + except UnknownAgentError: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="unknown_agent", + ) + ) + except RevokedAgentError: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="revoked_agent", + ) + ) + except UnauthorizedCapabilityError: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="unauthorized_capability", + ) + ) + except UnsupportedKeyAlgorithmError: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="unsupported_key_algorithm", + ) + ) + except Exception: + summary.invalid_signed += 1 + summary.findings.append( + LedgerSignatureVerificationFinding( + status="FAIL", + event_type=event_type, + task_id=target_task_id, + agent_id=agent_id, + failure_reason="verification_error", + ) + ) + + return summary + + def verify_route_audit_signatures_in_ledger( ledger_path: str | Path, ) -> LedgerSignatureVerificationSummary: diff --git a/triage_core/tc_cli.py b/triage_core/tc_cli.py index e174ae2..863fab8 100644 --- a/triage_core/tc_cli.py +++ b/triage_core/tc_cli.py @@ -16,6 +16,7 @@ from triage_core.task_ledger import ( TaskLedger, verify_ledger_event_signatures_in_ledger, + verify_task_event_signatures, ) from triage_core.demo_dry_run import format_demo_dry_run, run_demo_dry_run from triage_core.agent_authority import ( @@ -1949,7 +1950,7 @@ def tc_runtime_strategy_recorded_report( print(format_recorded_strategy_delta_report(report)) -def tc_task_show(task_id: str) -> None: +def tc_task_show(task_id: str, verify_signatures: bool = False) -> None: from triage_core.task_ledger import TaskLedger from pathlib import Path @@ -1986,7 +1987,37 @@ def tc_task_show(task_id: str) -> None: timestamp = event.get("timestamp", "not_recorded") etype = event.get("event_type", "unknown") print(f"- {timestamp} | {etype}") - print("Signature verification: not checked by this command; run tc audit --verify-signatures") + + if not verify_signatures: + print("Signature verification: not checked by this command; run tc audit --verify-signatures") + return + + try: + summary = verify_task_event_signatures(ledger_path, task_id) + except AgentIdentityError as e: + if _handle_registry_load_failure(_identity_registry().registry_path, e): + return + raise + + print("Signature verification:") + print( + f" valid_signed={summary.valid_signed} " + f"invalid_signed={summary.invalid_signed} " + f"unsigned={summary.unsigned} " + f"malformed={summary.malformed} " + f"skipped_non_target={summary.skipped_non_target}" + ) + for finding in summary.findings: + line = ( + f" {finding.status} event_type={finding.event_type} " + f"task_id={finding.task_id} agent_id={finding.agent_id or 'unknown'}" + ) + if finding.failure_reason: + line += f" reason={finding.failure_reason}" + print(line) + + if summary.should_fail(strict=False): + sys.exit(1) def main(): @@ -2350,6 +2381,14 @@ def main(): task_subparsers = task_parser.add_subparsers(dest="task_command") task_show_parser = task_subparsers.add_parser("show", help="Show task evidence timeline") task_show_parser.add_argument("task_id", type=str, help="The ID of the task to show") + task_show_parser.add_argument( + "--verify-signatures", + action="store_true", + help=( + "Verify signatures of this task's signed ledger events " + "(fail-closed: exits 1 on invalid or malformed signatures)" + ), + ) # task-envelope task_envelope_parser = subparsers.add_parser("task-envelope", help="Manage task envelopes") @@ -2797,7 +2836,7 @@ def main(): ) elif args.command == "task": if args.task_command == "show": - tc_task_show(args.task_id) + tc_task_show(args.task_id, verify_signatures=args.verify_signatures) else: task_parser.error("task requires a subcommand: show") elif args.command == "task-envelope":