diff --git a/.gitignore b/.gitignore index 96e22f2..c375bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ bench/real/ # tool working dirs (not release content) .claude/ .gitnexus/ +.dorian/local/ # ephemeral host-hook runtime packet (last-decision.json); never tracked # internal program/audit working docs — provenance only, never shipped in the release /docs/design/C4_IMPORT_BINDING_REPORT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e9c50..284f453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,45 @@ and security posture are unchanged; the core stays zero-dependency; `REVOKED` re verification path**. `claude_code.build_plan` gained an optional `manifest=` parameter (backward compatible) so the loop installer reuses the same scaffolding machinery. +### Added — Governance Foundation (1.4.0) +- **`dorian goal add` / `dorian goal show` / `dorian goal check`** (`src/dorian/goals.py`, + `cmd_goal` in `commands.py`) — a **human-authored goal record** (sidecar + `.dorian/goals/.goal.json`, `schema_version: 1`) plus a deterministic, **path-derived + goal↔claim coverage diff** (`goals.coverage_diff` → `{covered, uncovered}` over git-changed, + in-scope paths vs. warranted paths). `goal check --fail-on-uncovered` exits **4** when an + in-scope changed path has no warrant. The goal's `statement` is **human context only** — never + fed to a model, never used to decide a verdict or "completion". +- **`dorian gate`** (`cmd_gate`) — a host-mappable **preflight body**: reads a tool-call JSON on + stdin, runs the *same pure loop preflight* as `dorian loop preflight`, and prints the + **`continue` / `repair` / `escalate`** decision packet. Emits **only** contract codes — + `0`/`4` via `--fail-on {never|repair|escalate}`, plus `2` for malformed stdin — and **never** + uses exit 2 as a veto, reads a clock, or reads a nonce. +- **Claude Code governance adapter** via **`dorian governance install`** (`src/dorian/governance.py`, + scaffolded through the shared `claude_code.build_plan`/`apply` machinery) — one concrete adapter: + a `SubagentStop` hook that shells `dorian gate` and writes an ephemeral + `.dorian/local/last-decision.json` (the **only** place wall-clock/nonce are stamped), a + **fail-closed** `PreToolUse` veto (the exit-2 tool-block lives in the *host hook*; fails closed + under `unattended`/`godmode`, fails open when attended, freshness window `FRESH_SECONDS = 900`), + a settings example, and bundle docs. **No generic provider abstraction** is introduced. +- **Safe sidecar writer** (`src/dorian/sidecars.py`) — atomic (temp + `os.replace`), deterministic + (sorted-key JSON, `\n`), path-safe (`ensure_within` rejects repo escapes) writer shared by every + `.dorian/` record. +- **Deterministic core import firewall** (`tests/test_firewall_import_closure.py`) — a standing + guard (AST import-closure over the verdict modules) proving no model/network import sits on the + verification path. +- **Docs**: [`docs/DORIAN_PANE.md`](docs/DORIAN_PANE.md) (the pane *vision* — deferred, not shipped), + [`docs/GOVERNANCE_DATA_MODEL.md`](docs/GOVERNANCE_DATA_MODEL.md), a C4-flakiness note in + [`docs/VALIDATION_HONESTY.md`](docs/VALIDATION_HONESTY.md), and a gate fail-closed / + formalization-drift note in [`docs/SECURITY_BOUNDARY.md`](docs/SECURITY_BOUNDARY.md); + `.dorian/local/` is gitignored. + +**Deferred / cut (not in v1.4):** claim **provenance** sidecars → **v1.5**; **effort presets** → +**cut from core** (the binding-floor/breadth mapping lives in adapter docs); the **pane / TUI** → +**deferred** (Claude Design owns the final design; v1.4 ships only the CLI + hook spine); a +**generic provider abstraction** → **deferred** until a second concrete adapter exists. Purely +additive — warrant schema, checker grammar, exit codes, fold policy, and security posture are +unchanged; the core stays zero-dependency; **no model touches the verification path**. + ## [1.3.0] — 2026-06-28 The **Dorian Claim Warrants for Claude Code** integration: one command scaffolds a project-local Claude diff --git a/README.md b/README.md index 78760ee..59201c7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ now and is re-checked on every future change, so a confident summary doesn't qui - [Why not just watch files?](#why-not-just-watch-files) - [How it works](#how-it-works) - [Using dorian with Claude Code](#using-dorian-with-claude-code) +- [Governance foundation (preview)](#governance-foundation-preview) - [What gets committed](#what-gets-committed) - [Getting started](#getting-started) - [Writing claims an agent can be held to](#writing-claims-an-agent-can-be-held-to) @@ -365,6 +366,33 @@ success, replace tests/review, or sandbox execution — and `REVOKED` is a steer not a halt. Full guide: [`docs/DORIAN_LOOP_GUARD.md`](docs/DORIAN_LOOP_GUARD.md); how it fits loop engineering: [`docs/LOOP_ENGINEERING_ALIGNMENT.md`](docs/LOOP_ENGINEERING_ALIGNMENT.md). +## Governance foundation (preview) + +The deterministic spine of the *"give your goal and go to sleep"* direction — a goal record, a +preflight gate, and a Claude Code adapter that can enforce the loop decision. **The pane/TUI is not +shipped** (see [`docs/DORIAN_PANE.md`](docs/DORIAN_PANE.md)); this is the CLI + hook layer it will +sit on. + +- **`dorian goal add --id --title [--statement … --scope …]`** — record a + **human-authored** goal (written to `.dorian/goals/.goal.json`) with a path-scoped coverage + contract. The `statement` is context for humans; it never feeds a verdict. Read it back with + **`dorian goal show --id `**. +- **`dorian goal check --id [--since ] [--fail-on-uncovered]`** — a deterministic, + path-derived **coverage diff**: which changed, in-scope paths are not yet covered by a warrant + (exit **4** with `--fail-on-uncovered`). It does **not** judge whether the goal is "done". +- **`dorian gate`** — reads a tool-call JSON on stdin and emits the same + **`continue`/`repair`/`escalate`** decision as Loop Guard; exits `0`/`4` on the decision (or `2` + only for malformed input) and never uses exit 2 as a veto. +- **`dorian governance install`** — scaffold the **Claude Code** governance adapter: a + `SubagentStop` hook that runs `dorian gate`, plus a **fail-closed** `PreToolUse` veto (blocks a + mutating tool on a standing `escalate` under a strict policy — `unattended`, or + `DORIAN_EFFORT=godmode`; fails open when a human is attended). It is **steering plus a host-side + veto, not a sandbox**. + +Data model and trust boundary: [`docs/GOVERNANCE_DATA_MODEL.md`](docs/GOVERNANCE_DATA_MODEL.md), +[`docs/SECURITY_BOUNDARY.md`](docs/SECURITY_BOUNDARY.md). Provenance is deferred to v1.5; effort +presets are not in core. + ## What gets committed - the artifact (e.g. `docs/changes/login.md`), diff --git a/docs/DORIAN_PANE.md b/docs/DORIAN_PANE.md new file mode 100644 index 0000000..c741e9b --- /dev/null +++ b/docs/DORIAN_PANE.md @@ -0,0 +1,110 @@ +# Dorian Pane (vision) + +*The "give your goal and go to sleep" surface. This document describes a **future** product +direction built on the deterministic primitives that ship in v1.4 — it is not a shipped feature, +and it makes no final design decisions.* + +> **Status: deferred — NOT part of v1.4.** v1.4 ships the CLI spine (`dorian goal`, `dorian gate`) +> and a Claude Code hook adapter. The pane is the surface those records are *designed to feed* once +> built. **Claude Design owns the final TUI/pane design;** this doc only specifies the data +> contracts and product jobs behind it. + +--- + +## What the pane is for + +A long-running coding agent can churn for hours. Today a human reads scrollback to answer "is this +still on track, and do I need to step in?" The pane's job is to answer that from **deterministic +evidence** — warrants, coverage, and loop decisions — instead of from the agent's own narration. + +"Give your goal and go to sleep" is a **product direction, not a guarantee.** What v1.4 actually +guarantees is narrow and real: a human-authored goal with a path-scoped coverage contract, a +deterministic gate that emits `continue`/`repair`/`escalate`, and a host adapter that can fail +closed under an unattended policy. The pane would make that loop *legible* — it does not make the +agent more autonomous than the underlying determinism allows. + +## What the pane is **not** + +- **Not a verdict author.** The pane *displays* deterministic results; it never computes trust, + never runs a model, and never decides whether a goal is "done." Goal completion is **not** + model-evaluated — coverage is a path-derived floor (see `docs/GOVERNANCE_DATA_MODEL.md`). +- **Not a sandbox or a security boundary.** It is a read-mostly viewer over the same records the + CLI writes. Enforcement lives in the host hooks, not the UI. +- **Not multi-provider (yet).** v1.4 ships exactly one concrete adapter — Claude Code. The pane is + described against that adapter; a generic provider abstraction is deferred until a second concrete + adapter exists. + +## The views (data jobs, not layouts) + +Each view is defined by the **deterministic record it reads**. Layout, ordering, and visual +treatment are Claude Design's call. + +| View | Question it answers | Source record (v1.4) | +|---|---|---| +| **Goal** | What did the human ask for, and under what policy? | `.dorian/goals/.goal.json` (`title`, `statement`, `policy_ref`) | +| **Scope / denylist** | Which paths are in/out of jurisdiction? | `goal.scope`, `goal.deny_paths`; gate packet `scope`, `sensitive_globs` | +| **Warrants** | What has the agent sworn and proven? | `.warrant` sidecars + the warrant store | +| **Coverage** | Which changed, in-scope paths are *not* yet warranted? | `goals.coverage_diff` → `{covered, uncovered}` | +| **Gate / loop decision** | Continue, repair, or escalate — and why? | gate packet: `decision`, `reason`, `loop_instruction`, `trust_summary`, `broken_claims[]` | +| **Repair** | How many repair attempts, against what cap? | gate packet `repair` `{attempts, max}` | +| **Escalation** | Does a human need to act, and on what? | gate packet `human_escalation` `{required, reason, message}` | +| **Host adapter state** | Is enforcement live, fresh, and identity-matched? | `.dorian/local/last-decision.json` (`created_at_epoch`, `repo_root`, `base_ref`, `nonce`); policy mode | + +The **escalation** and **host adapter** views are where "go to sleep" earns or loses trust: they +must surface a stale packet, an identity mismatch, or a standing `escalate` *prominently*, because +under a strict policy those are exactly the states that fail closed. + +## The deterministic data contract (what the pane may rely on) + +Everything the pane renders comes from records this repo already writes deterministically: + +- **Goal record** — `schema_version: 1`, byte-stable JSON (`docs/GOVERNANCE_DATA_MODEL.md` §2). +- **Gate decision packet** — `schema_version: 1`, the stdout of `dorian gate`, model-free and + clock-free (§5). The pane reads this as the single source of "what should happen next." +- **Last-decision packet** — `.dorian/local/last-decision.json`, the gate packet plus host-stamped + freshness/identity fields, **ephemeral and gitignored** (§6). The pane treats it as *runtime + state*, never as history. + +The pane must not invent fields or persist its own authoritative state; if it needs history, that is +a future record type (a sibling sidecar with its own `schema_version`), not a mutation of the frozen +warrant schema. + +--- + +## Claude Design handoff + +**Ownership.** Dorian/Opus owns the deterministic data contracts and the governance invariants +(what is true, what may influence a verdict, what fails open vs. closed). **Claude Design owns the +final pane/TUI design** — layout, information hierarchy, typography, color, interaction model, and +component choices. Nothing in this doc fixes those decisions. + +**Data Claude Design can rely on being available.** The eight views above, each backed by the cited +record. All of it is local, deterministic, and already produced by the v1.4 CLI/adapter — no new +verification path is required to build the pane. + +**Failure states the design must represent first-class** (not as afterthoughts): + +- **No fresh decision** — `.dorian/local/last-decision.json` missing or older than the freshness + window (`FRESH_SECONDS = 900`). Under a strict policy this *fails closed*; the pane must make that + obvious, not silent. +- **Identity mismatch** — packet `repo_root`/`base_ref`/`nonce` disagree with the current run. +- **Standing escalate** — a decision the agent cannot self-clear; needs a human. +- **Degraded/errored claims** — `trust_summary` non-zero `degraded`/`errored`; a verdict exists but + is weaker than "all green." + +**Accessibility requirements** (constraints, not designs): decisions must be distinguishable without +relying on color alone (the three decisions and the fail-open/closed state each need a non-color +signal); all evidence must be reachable as text for screen readers and for copy-into-a-bug-report; +the pane must remain legible in a plain terminal. + +**Decisions explicitly deferred to Claude Design:** the actual layout and navigation; how much +evidence to show inline vs. on demand; live-tail vs. snapshot refresh model; how repair/escalation +history is visualized; any keyboard/interaction grammar. + +--- + +## See also + +- `docs/GOVERNANCE_DATA_MODEL.md` — the exact records and fields the pane reads. +- `docs/DORIAN_LOOP_GUARD.md` — the `continue`/`repair`/`escalate` decision the pane surfaces. +- `docs/SECURITY_BOUNDARY.md` — why enforcement lives in the host hooks, not the pane. diff --git a/docs/GOVERNANCE_DATA_MODEL.md b/docs/GOVERNANCE_DATA_MODEL.md new file mode 100644 index 0000000..7f79692 --- /dev/null +++ b/docs/GOVERNANCE_DATA_MODEL.md @@ -0,0 +1,176 @@ +# Dorian Governance Data Model (v1.4) + +*The concrete records the governance-foundation slice reads and writes. Every field here is +either deterministic input or human-authored context — none of it is a model summary, and none +of the prose fields ever decide a verdict.* + +> **Status:** v1.4 (`[Unreleased]`). Additive only — no existing warrant, checker, exit code, +> fold policy, or security posture changed. Provenance sidecars are **deferred to v1.5**; the +> pane/TUI and a generic provider abstraction are **not** part of this model yet. + +--- + +## 1. What's in this model, and what is deliberately not + +v1.4 adds three durable record types and one ephemeral runtime file: + +| Record | Path | Written by | Tracked in git? | +|---|---|---|---| +| **Goal** | `.dorian/goals/.goal.json` | `dorian goal add` | yes (a repo can commit its goals) | +| **Warrant** (unchanged) | `.warrant` | `dorian seal`/`verify` | yes | +| **Gate decision packet** | stdout of `dorian gate` (not persisted by core) | `dorian gate` | n/a — emitted, not stored | +| **Last-decision packet** | `.dorian/local/last-decision.json` | the SubagentStop **host hook** | **no** — ephemeral, gitignored | + +Two hard exclusions, stated up front because the whole design depends on them: + +- **The `Warrant` schema is frozen.** `Warrant.compute_id` is `sha256` over the canonical body. + Governance never adds a field to `Warrant`; new concepts become sibling sidecars under + `.dorian/` with their own `schema_version`. +- **No prose feeds a verdict.** `Goal.statement` (and any other free-text field) is human context + only. It is never sent to a model and never read by coverage or gate logic. + +--- + +## 2. Goal record — `.dorian/goals/.goal.json` + +A **human-authored** objective plus a **structural** coverage contract. Created by +`dorian goal add`; read by `dorian goal show` / `dorian goal check`. +(`src/dorian/goals.py`, `schema_version = 1`.) + +| Field | Type | Role | Default | +|---|---|---|---| +| `goal_id` | `str` | identity; also the sidecar filename | — (required) | +| `title` | `str` | short human title | — (required) | +| `statement` | `str` | **human prose only — never fed to a model, never in verdict/coverage logic** | `""` | +| `policy_ref` | `str` | which loop policy this goal expects | `"default-assist"` | +| `scope` | `tuple[str, ...]` | jurisdiction globs (fnmatch); **empty = all paths** | `()` | +| `deny_paths` | `tuple[str, ...]` | extra denied globs | `()` | +| `base_ref` | `str` | the ref changed paths are computed against | `"HEAD"` (CLI) | +| `coverage_contract` | `dict` | **structural** contract only (e.g. a minimum strength floor) — no prose | `{}` | +| `status` | `str` | lifecycle marker | `"open"` | +| `warrant_ids` | `tuple[str, ...]` | warrants associated with this goal | `()` | +| `schema_version` | `int` | record version | `1` | + +**Load is strict.** `goals.load` raises `ValueError` on non-JSON, a non-object body, a missing +required field, or `schema_version != 1` — an unknown future version fails loudly rather than +being silently misread. + +--- + +## 3. Sidecar writer guarantees (`src/dorian/sidecars.py`) + +Every `.dorian/` record is written through one deterministic writer so two runs on the same input +produce byte-identical files: + +- **Deterministic bytes:** `json.dumps(payload, sort_keys=True, indent=2) + "\n"`, UTF-8, `\n` + newlines. +- **Atomic:** written to a same-directory temp file, then `os.replace()` — a reader never sees a + partial file. +- **Path-safe:** `write_sidecar(repo, rel_path, payload)` rejects an absolute or empty `rel_path`; + `ensure_within(base, target)` resolves both paths and raises `ValueError` if the target escapes + the repo (it is a containment check for trusted repos, **not** a sandbox). + +--- + +## 4. Coverage diff — what is authoritative, and what never is + +`goals.coverage_diff(goal, changed_paths, warranted_paths) -> {"covered": [...], "uncovered": [...]}` +is a **pure, path-derived** function. `dorian goal check` calls it with: + +- **`changed_paths`** — derived from git (`gitio.changed_paths(repo, since)`), default `--since HEAD~1`. +- **`warranted_paths`** — the artifact paths of the repo's current warrants (the warrant store). + +It keeps the changed paths that fall **in scope** (fnmatch against `goal.scope`; empty scope = all) +and reports which of those are **not** covered by a warrant. With `--fail-on-uncovered`, a non-empty +`uncovered` list exits `4` (REVOKED) — otherwise it just reports. + +**What coverage means, precisely:** "a changed, in-scope path has at least one warrant." It does +**not** mean the goal is achieved. `Goal.statement`, `coverage_contract` prose, model output, and +the pane UI **never** influence this result. Coverage is a deterministic *floor* ("did the agent at +least sworn-claim the files it touched?"), not a judgment of correctness or completion. + +--- + +## 5. Gate decision packet — `dorian gate` (stdout) + +`dorian gate` reads a tool-call JSON on stdin, runs the **same pure loop preflight** as +`dorian loop preflight`, and prints the decision packet via `loop.render_json` +(`json.dumps(..., indent=2, sort_keys=True)`). The packet is the public, host-mappable contract: + +| Key | Meaning | +|---|---| +| `schema_version` | packet version (`1`) | +| `decision` | one of `continue` · `repair` · `escalate` | +| `reason`, `loop_instruction` | human-readable steering text | +| `policy` | the effective policy (`cautious`/`assist`/`unattended`) | +| `candidates` | number of warrants considered | +| `trust_summary` | counts: `trusted`/`warranted`/`degraded`/`revoked`/`errored` | +| `broken_claims[]` | per broken claim: `artifact`, `claim_id`, `kind`, `load_bearing`, `verdict`, `paths`, `sensitive`, `in_scope`, `suggested_next_step`, … | +| `human_escalation` | `{required, reason, message}` | +| `repair` | `{attempts, max}` | +| `scope`, `sensitive_globs`, `notes` | the inputs echoed for the host | + +**Exit codes from `dorian gate` are only `0` or `4`** (mapped from `decision` by `--fail-on +{never|repair|escalate}`, default `never`), plus `2` for malformed stdin. The gate **never** exits +`2` as a REPAIR/ESCALATE veto and **never** reads a clock or a nonce — those belong to the host +adapter (see §6 and `docs/SECURITY_BOUNDARY.md`). The verdict path is pure, deterministic, and +model-free. + +--- + +## 6. Ephemeral host-hook state — `.dorian/local/last-decision.json` + +This file is the **only** place wall-clock time and run identity enter the picture, and it is owned +entirely by the Claude Code adapter's host hooks — never by `dorian gate` or the core. + +- **Written by** the `SubagentStop` hook (`dorian_loop_preflight.py`): it shells `dorian gate`, + accepts return codes `0`/`4`, and persists the packet **augmented with host-stamped fields**. +- **Read by** the `PreToolUse` veto hook (`dorian_preflight_veto.py`) to decide whether to block a + mutating tool call. + +Host-stamped fields (added by the hook, **not** in the gate's pure output): + +| Field | Source | +|---|---| +| `created_at_epoch` | `int(time.time())` — freshness; a packet older than `FRESH_SECONDS = 900` is stale | +| `repo_root` | `DORIAN_REPO_ROOT` or `os.getcwd()` | +| `base_ref` | `DORIAN_BASE` | +| `nonce` | `DORIAN_NONCE` (empty string if unset) | + +`.dorian/local/` is **gitignored** and ephemeral: deleting it costs nothing, and its absence under a +strict policy is treated as *fail-closed* (no fresh decision ⇒ block), while under an attended policy +it *fails open* (a human is present). A **strict** policy is `DORIAN_POLICY=unattended` or +`DORIAN_EFFORT=godmode`; an **attended** policy is `cautious`/`assist` (the default). It is runtime +state, never an authoritative ledger — the core never reads it back into a verdict. + +--- + +## 7. Authoritative vs. advisory — the one-glance table + +| Influences a Dorian verdict? | Records / fields | +|---|---| +| **Yes — authoritative, deterministic** | warrant bodies & ids; checker results; `coverage_diff` over git-changed paths ∩ scope vs. warranted paths; the gate `decision` derived from revalidate + policy + scope + repair caps | +| **No — never touches truth** | `Goal.statement` and any prose; `coverage_contract` text; model summaries; the pane UI; `.dorian/local/last-decision.json` (runtime only); provenance sidecars (deferred, v1.5) | + +--- + +## 8. Schema-version map + +| Record | `schema_version` | Notes | +|---|---|---| +| `Warrant` | (frozen, content-addressed id) | never extended; new concepts = sibling sidecars | +| Goal | `1` | `src/dorian/goals.py` | +| Gate / loop decision packet | `1` | `src/dorian/loop.py` (`LoopDecision.to_dict`) | +| `.dorian/local/last-decision.json` | gate packet `1` + host-stamped fields | ephemeral; owned by the adapter | + +New record types arrive as **new sibling sidecars with their own `schema_version`**, so the frozen +warrant schema and the existing exit-code/fold contracts stay untouched. + +--- + +## See also + +- `docs/SECURITY_BOUNDARY.md` — the core-vs-host-hook trust boundary and gate fail-closed behavior. +- `docs/DORIAN_PANE.md` — the future surface these records are designed to feed. +- `docs/DORIAN_LOOP_GUARD.md` — the loop preflight decision (`continue`/`repair`/`escalate`) the gate reuses. +- `docs/VALIDATION_HONESTY.md` — the trigger-vs-truth axes and the C4 determinism caveat. diff --git a/docs/SECURITY_BOUNDARY.md b/docs/SECURITY_BOUNDARY.md index ae9a627..e0344c9 100644 --- a/docs/SECURITY_BOUNDARY.md +++ b/docs/SECURITY_BOUNDARY.md @@ -119,3 +119,31 @@ hardening** (see [`NEXT_ALGORITHMIC_BETS.md`](NEXT_ALGORITHMIC_BETS.md)); until then, treat `checker_trust: base` as a checker-spec trust root for *semi-trusted* contributors, and for genuinely untrusted forks rely on required review of the `.warrant` diff and branch protection (plus `deny_exec`), not on base mode alone. + +## Governance gate & veto: enforcement is in the host hook, not the core + +The v1.4 governance adapter adds a host-side enforcement layer. The boundary is deliberate: + +- **`dorian gate` and the core never veto.** `gate` emits a `continue`/`repair`/`escalate` + decision and exits only `0`/`4` (or `2` for malformed input). It never uses exit 2 as a veto, + never reads a clock, and never reads a nonce — the verdict path stays pure and portable. +- **The exit-2 tool-block lives in the Claude Code `PreToolUse` host hook.** Under a **strict** + policy (`DORIAN_POLICY=unattended` or `DORIAN_EFFORT=godmode`) the veto **fails closed**: a + missing, stale (older than `FRESH_SECONDS = 900`), or identity-mismatched decision packet + (`repo_root`/`base_ref`/`nonce`) blocks the mutating tool, as does a standing `escalate`. Under + an **attended** policy it **fails open** — a human is present, so the agent is not trapped — + except a standing `escalate` on a *sensitive* path still blocks. +- **Still not a sandbox.** The veto can refuse to *start* a mutating tool; it does not contain what + an allowed tool then does. `.dorian/local/last-decision.json` is ephemeral, gitignored runtime + state — never an authoritative ledger, and never read back into a verdict. + +## Formalization drift: claim/goal emission is the human-reviewed step + +The verdict path is model-free, but the *translation* of human intent into checker specs and a goal +record happens at **emission** — drafted by an agent or a human, before any check runs. That +translation can drift from intent: a checker can be well-formed yet too weak to falsify the claim's +kind (a `behavior` claim backed only by a `symbol:` existence check). `--strength-gate` and the +binding diagnostics catch *categories* of weak backing, but a checker that is adequate-by-grammar +yet semantically off-target can still pass. Treat claim/goal emission as a reviewed step — +[`VALIDATION_HONESTY.md`](VALIDATION_HONESTY.md) is the contract for what a passing checker does +and does not prove. diff --git a/docs/TESTING.md b/docs/TESTING.md index f0b4ec9..d357b81 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -76,3 +76,12 @@ make coverage # coverage (term-missing + HTML under htmlcov/), measured over Pinned by `tests/test_determinism.py::test_reseal_of_identical_content_is_idempotent`. - **CI is Ubuntu-only:** path / subprocess behavior on macOS and Windows is not yet gated in CI (the suite passes locally on macOS). +- **Local subprocess tests can flake in sandboxed / worktree environments:** the + out-of-process tests (`python -m dorian` black-box, packaging, C4 real-`pytest` + spawns) need a working editable install on `PATH`; under endpoint-security scanning + or an unstable worktree editable install they can stall or `ModuleNotFoundError` + even when the code is correct. Confirm logic with the focused in-process tests + (`PYTHONPATH=src uv run --no-sync pytest `), and treat **CI + (`uv sync --all-extras` on 3.11 / 3.12 / 3.13) as authoritative** for the full + suite. Do not work around the stall by changing the C4 PATH-interpreter contract, + and do not report a green run that did not happen. diff --git a/docs/VALIDATION_HONESTY.md b/docs/VALIDATION_HONESTY.md index 18aed25..952085c 100644 --- a/docs/VALIDATION_HONESTY.md +++ b/docs/VALIDATION_HONESTY.md @@ -54,6 +54,17 @@ means a claim is false. is reproducibility on a handful of frozen SHAs — hermetic reproduction, not broad validation. +## C4 verdict determinism is conditional on a deterministic test + +A `C4` (`pytest:`) verdict is deterministic *given a deterministic test*. If the bound test is +**flaky** — passing on some runs, failing on others with the source unchanged — the warrant's +trust state can flip between `TRUSTED` and `REVOKED` across `revalidate` runs. That is a +**test-quality issue, not Dorian nondeterminism**: the C1/C3/C5-typed families are deterministic by +construction, and the same flaky test would flip a CI run too. But it means a `C4` claim's truth is +only as stable as the test backing it. An **N-run stability lever** (consensus across repeats) is a +**deferred (v1.5) idea**; until then, read a flaky `C4` flip as a signal about the test, not the +code, and prefer deterministic tests for load-bearing behavior claims. + ## Hermetic reproduction ≠ broad validation Byte-identical reproduction on frozen inputs proves the *process* is diff --git a/src/dorian/cli.py b/src/dorian/cli.py index bed794d..3fbb334 100644 --- a/src/dorian/cli.py +++ b/src/dorian/cli.py @@ -325,6 +325,9 @@ def build_parser() -> argparse.ArgumentParser: ) _add_loop_parser(sub) + _add_goal_parser(sub) + _add_gate_parser(sub) + _add_governance_parser(sub) return p @@ -467,6 +470,103 @@ def _add_loop_parser(sub: argparse._SubParsersAction) -> None: ) +def _add_goal_parser(sub: argparse._SubParsersAction) -> None: + """`dorian goal`: author/read a human-set governance goal record (no completion logic).""" + gp = sub.add_parser( + "goal", + help="author or read a human-set governance goal record", + description=( + "Record a human-authored goal (objective + scope/deny-paths + base ref + coverage " + "contract). The goal is durable context only; Dorian never decides goal completion." + ), + ) + gp_sub = gp.add_subparsers(dest="goal_command", required=True) + + add = gp_sub.add_parser("add", help="write (or overwrite) a goal record") + add.add_argument("--id", required=True, help="goal id (used as the sidecar filename)") + add.add_argument("--title", required=True, help="short human title") + add.add_argument( + "--statement", default="", help="human objective; never used to judge completion" + ) + add.add_argument( + "--base-ref", default="HEAD", help="ref that changed paths are computed against" + ) + add.add_argument( + "--policy-ref", default="default-assist", help="policy name this goal runs under" + ) + add.add_argument("--scope", action="append", help="human-set jurisdiction glob (repeatable)") + add.add_argument("--deny-path", action="append", help="extra denied glob (repeatable)") + add.add_argument( + "--min-strength", + default=None, + help="minimum checker strength for load-bearing claims (structural coverage contract)", + ) + + show = gp_sub.add_parser("show", help="print a goal record as JSON") + show.add_argument("--id", required=True, help="goal id to print") + + chk = gp_sub.add_parser( + "check", help="report changed paths in the goal's scope that no warrant covers" + ) + chk.add_argument("--id", required=True, help="goal id to check") + chk.add_argument("--since", default="HEAD~1", help="ref to compute changed paths against") + chk.add_argument( + "--fail-on-uncovered", + action="store_true", + help="exit 4 (refusal, not a false claim) if any in-scope changed path is uncovered", + ) + + +def _add_gate_parser(sub: argparse._SubParsersAction) -> None: + """`dorian gate`: the deterministic body of a host veto hook (emits only 0/4). + + Reads host/tool JSON on stdin and runs the same pure preflight as `loop preflight`. The + exit-2 tool-blocking veto and any host-specific hook output belong to a host hook script, + never to this command. + """ + g = sub.add_parser( + "gate", + help="host-mappable preflight: reads tool JSON on stdin, emits a 0/4 decision", + description=( + "Read host/tool JSON on stdin, run the deterministic loop preflight, print the " + "LoopDecision packet as JSON, and exit with a Dorian contract code (0, or 4 " + "under --fail-on). A host hook maps the decision to a tool-blocking veto; this " + "command never does." + ), + ) + _add_loop_steer_flags(g) + g.add_argument( + "--fail-on", + choices=["never", "repair", "escalate"], + default="never", + help="exit 4 when the decision is at/above this severity (default never: always exit 0)." + " The host hook — not this command — maps a decision to a tool-blocking veto.", + ) + + +def _add_governance_parser(sub: argparse._SubParsersAction) -> None: + """`dorian governance install`: scaffold the Claude Code governance adapter (host hooks).""" + gp = sub.add_parser( + "governance", + help="install the Claude Code governance adapter (hooks that enforce loop decisions)", + ) + gp_sub = gp.add_subparsers(dest="governance_command", required=True) + ins = gp_sub.add_parser( + "install", + help="scaffold the governance hooks + settings example into .claude/", + description="Scaffold two Claude Code host hooks (a SubagentStop preflight writer and a" + " fail-closed PreToolUse veto), a settings example, and docs into .claude/. Writes files" + " only; never overwrites without --force; idempotent. Not a sandbox — trusted repos.", + ) + ins.add_argument( + "--force", action="store_true", help="overwrite existing scaffolded files (default: skip)" + ) + ins.add_argument("--dry-run", action="store_true", help="print the plan; write nothing") + ins.add_argument( + "--target", help="target repo/project root (default: --repo, i.e. current directory)" + ) + + def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: diff --git a/src/dorian/commands.py b/src/dorian/commands.py index 1c6c97a..3f4352d 100644 --- a/src/dorian/commands.py +++ b/src/dorian/commands.py @@ -29,6 +29,8 @@ claude_code, datachecks, gitio, + goals, + governance, init, intoto, loop, @@ -1110,6 +1112,147 @@ def _print_claim_warrants_next_steps(*, with_hook: bool) -> None: print(f"\nTrust boundary: {claude_code.TRUST_BOUNDARY}") +def cmd_goal(args: argparse.Namespace) -> int: + """`dorian goal add|show`: author or read a human-set governance goal record. + + Durable human context only — this never decides whether the goal is complete and is not + wired into revalidate / loop preflight / the verdict path. + """ + repo = _repo(args) + if _missing_repo(repo, "goal"): + return EXIT_USAGE + if args.goal_command == "add": + goal = goals.Goal( + goal_id=args.id, + title=args.title, + statement=args.statement or "", + policy_ref=args.policy_ref, + scope=tuple(args.scope or ()), + deny_paths=tuple(args.deny_path or ()), + base_ref=args.base_ref, + coverage_contract=( + {"min_strength_load_bearing": args.min_strength} if args.min_strength else {} + ), + ) + try: + goals.save(repo, goal) + except (ValueError, OSError) as exc: + print(f"dorian goal: {exc}", file=sys.stderr) + return EXIT_USAGE + print(f"goal {goal.goal_id} written (scope={list(goal.scope)})") + return EXIT_OK + if args.goal_command == "show": + try: + goal = goals.load(repo, args.id) + except (FileNotFoundError, ValueError, OSError) as exc: + print(f"dorian goal: {exc}", file=sys.stderr) + return EXIT_USAGE + print(json.dumps(goal.to_dict(), sort_keys=True)) + return EXIT_OK + if args.goal_command == "check": + try: + goal = goals.load(repo, args.id) + except (FileNotFoundError, ValueError, OSError) as exc: + print(f"dorian goal: {exc}", file=sys.stderr) + return EXIT_USAGE + try: + changed, _renames = gitio.changed_paths(repo, args.since) + except gitio.GitError as exc: + print(f"dorian goal: {exc}", file=sys.stderr) + return EXIT_USAGE + conn = store.connect(repo) + try: + try: + store.sync(repo, conn) + except _SIDECAR_ERRORS as exc: + print(f"dorian goal: corrupt warrant sidecar: {exc}", file=sys.stderr) + return EXIT_REVOKED + warranted = [w["artifact_uri"] for w in store.get_warrants(conn)] + finally: + conn.close() + result = goals.coverage_diff(goal, changed, warranted) + print(json.dumps(result, sort_keys=True)) + if result["uncovered"] and args.fail_on_uncovered: + return EXIT_REVOKED # refusal, NOT a false claim (NN6) + return EXIT_OK + print(f"dorian goal: '{args.goal_command}' not implemented", file=sys.stderr) + return EXIT_USAGE + + +def cmd_gate(args: argparse.Namespace) -> int: + """`dorian gate`: the deterministic body of a host veto hook. + + Reads host/tool JSON on stdin (carried as context only — the host hook interprets it), + runs the same pure loop preflight as `dorian loop preflight`, prints the LoopDecision + packet as JSON, and returns ONLY Dorian contract codes: EXIT_OK, or EXIT_REVOKED under + --fail-on. It NEVER returns exit 2 as a REPAIR/ESCALATE veto (exit 2 is malformed-input / + usage only); the exit-2 tool-blocking veto belongs to a host hook, not this command. + """ + try: + json.loads(sys.stdin.read() or "{}") # validate tool JSON; not interpreted here + except ValueError: + print("dorian gate: invalid tool JSON on stdin", file=sys.stderr) + return EXIT_USAGE + packet, code = _run_loop_preflight(args) + if packet is None: + return code # usage (2) or fail-closed sidecar (4) error, already reported on stderr + print(loop.render_json(packet), end="") + return _loop_exit_code(packet["decision"], args.fail_on) + + +def cmd_governance(args: argparse.Namespace) -> int: + """Dispatch `dorian governance ` (install).""" + if args.governance_command == "install": + return _cmd_governance_install(args) + print(f"dorian governance: '{args.governance_command}' not implemented", file=sys.stderr) + return EXIT_USAGE + + +def _cmd_governance_install(args: argparse.Namespace) -> int: + """Scaffold the Claude Code governance adapter (two host hooks + settings example + docs) + into .claude/. Writes files only; never overwrites without --force; idempotent. Not a + sandbox. The hooks own the exit-2 veto and wall-clock; Dorian core stays pure.""" + target = Path(args.target).resolve() if args.target else _repo(args) + if _missing_repo(target, "governance install"): + return EXIT_USAGE + try: + plan = claude_code.build_plan( + target, groups=governance.DEFAULT_GROUPS, manifest=governance.GOVERNANCE_MANIFEST + ) + result = claude_code.apply(plan, force=args.force, dry_run=args.dry_run) + except (ValueError, OSError) as exc: + print(f"dorian governance install: {exc}", file=sys.stderr) + return EXIT_USAGE + if args.json: + print( + json.dumps( + { + "repo": str(plan.repo_root), + "is_git": plan.is_git, + "dry_run": args.dry_run, + "created": list(result.created), + "overwritten": list(result.overwritten), + "skipped": list(result.skipped), + "warnings": list(result.warnings), + }, + indent=2, + ) + ) + else: + verb = "would write" if args.dry_run else "wrote" + print( + f"dorian governance install: {verb} {len(result.created)} file(s)" + f" (skipped {len(result.skipped)}, overwrote {len(result.overwritten)})" + f" under {plan.repo_root}" + ) + for created in result.created: + print(f" + {created}") + for skipped in result.skipped: + print(f" = {skipped} (exists; --force to overwrite)") + print("next: merge settings.dorian-governance.example.json into .claude/settings.json") + return EXIT_OK + + def cmd_loop(args: argparse.Namespace) -> int: """Dispatch `dorian loop ` (preflight | prompt | install).""" if args.loop_command == "preflight": diff --git a/src/dorian/goals.py b/src/dorian/goals.py new file mode 100644 index 0000000..8c0aa94 --- /dev/null +++ b/src/dorian/goals.py @@ -0,0 +1,126 @@ +"""Human-authored Goal records — durable context for a governed run. Stdlib only. + +A Goal is the human-authored "charge" for a run: the objective plus its jurisdiction +(scope / deny-paths), a policy reference, the base ref that changed paths are computed +against, and a structural coverage contract. It is written as a sibling sidecar via +``dorian.sidecars`` and read back deterministically. + +IMPORTANT (Non-Negotiable): the goal ``statement`` is human context ONLY. Dorian never feeds +it to a model and never uses it — or any field here — to decide whether the goal is +"complete" or whether a loop may continue. Nothing in this module is imported by the verdict +path (model / checkers / seal / revalidate / fold / policy / loop); there is no +model-evaluated goal satisfaction. Scope/policy are human-set, never model-set at runtime. +""" + +from __future__ import annotations + +import fnmatch +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path, PurePosixPath + +from dorian import sidecars + +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class Goal: + goal_id: str + title: str + statement: str # human prose; never fed to a model; never in verdict/completion logic + policy_ref: str = "default-assist" + scope: tuple[str, ...] = () # human-set jurisdiction; never model-set at runtime + deny_paths: tuple[str, ...] = () + base_ref: str = "" # the ref changed paths are computed against (used by a later task) + coverage_contract: dict = field(default_factory=dict) # structural contract only + status: str = "open" + warrant_ids: tuple[str, ...] = () + schema_version: int = SCHEMA_VERSION + + def to_dict(self) -> dict: + d = asdict(self) + d["scope"] = list(self.scope) + d["deny_paths"] = list(self.deny_paths) + d["warrant_ids"] = list(self.warrant_ids) + return d + + +def goal_path(goal_id: str) -> str: + """Repo-relative sidecar path for a goal record.""" + return f".dorian/goals/{goal_id}.goal.json" + + +def save(repo: Path, goal: Goal) -> Path: + """Write a goal record as a deterministic sibling sidecar (not a ledger). Returns the path. + + Path containment (no ``../`` escape) is enforced by ``sidecars.write_sidecar``. + """ + return sidecars.write_sidecar(repo, goal_path(goal.goal_id), goal.to_dict()) + + +def load(repo: Path, goal_id: str) -> Goal: + """Read back a goal record, validating its schema version. + + Raises ``ValueError`` for a malformed record (not JSON / not an object / missing required + field) or an unsupported ``schema_version``. + """ + raw = (repo / goal_path(goal_id)).read_text(encoding="utf-8") + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"malformed goal record {goal_id!r}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"malformed goal record {goal_id!r}: expected a JSON object") + version = data.get("schema_version") + if version != SCHEMA_VERSION: + raise ValueError( + f"unsupported goal schema_version {version!r} for {goal_id!r} " + f"(expected {SCHEMA_VERSION})" + ) + try: + return Goal( + goal_id=data["goal_id"], + title=data["title"], + statement=data["statement"], + policy_ref=data.get("policy_ref", "default-assist"), + scope=tuple(data.get("scope", ())), + deny_paths=tuple(data.get("deny_paths", ())), + base_ref=data.get("base_ref", ""), + coverage_contract=data.get("coverage_contract", {}), + status=data.get("status", "open"), + warrant_ids=tuple(data.get("warrant_ids", ())), + schema_version=version, + ) + except KeyError as exc: + raise ValueError(f"malformed goal record {goal_id!r}: missing field {exc}") from exc + + +def _norm_path(path: str) -> str: + """Normalize a path to a stable repo-relative POSIX string (strips ``./``, unifies seps).""" + return PurePosixPath(path.strip().replace("\\", "/")).as_posix() + + +def _in_scope(path: str, scope: tuple[str, ...]) -> bool: + """True if ``path`` is governed by ``scope``. Empty scope -> all paths (loop convention).""" + return True if not scope else any(fnmatch.fnmatch(path, glob) for glob in scope) + + +def coverage_diff(goal: Goal, changed_paths, warranted_paths) -> dict: + """Pure, deterministic structural coverage check — no model, no I/O, no semantics. + + Returns ``{"covered": [...], "uncovered": [...]}`` (sorted, de-duplicated). ``uncovered`` + is the set of changed paths *inside the goal's scope* that no warrant artifact path covers; + ``must_cover`` is DERIVED only from ``changed_paths`` filtered by ``goal.scope`` (fnmatch) — + never from ``goal.statement``, ``coverage_contract`` prose, or any model. + + ``uncovered`` is a coverage/refusal signal, NOT a false claim, a failed goal, or a lie. + """ + warranted = {_norm_path(p) for p in warranted_paths} + in_scope = { + norm for norm in (_norm_path(c) for c in changed_paths) if _in_scope(norm, goal.scope) + } + return { + "covered": sorted(p for p in in_scope if p in warranted), + "uncovered": sorted(p for p in in_scope if p not in warranted), + } diff --git a/src/dorian/governance.py b/src/dorian/governance.py new file mode 100644 index 0000000..38a5acc --- /dev/null +++ b/src/dorian/governance.py @@ -0,0 +1,51 @@ +"""Manifest for the Claude Code governance adapter (host hooks that enforce loop decisions). + +Scaffolded by ``dorian governance install`` through the shared ``claude_code.build_plan`` / +``apply`` machinery — exactly like ``dorian loop install``. Stdlib only; the templates are inert +package data. This is ONE concrete Claude Code adapter; no generic provider abstraction is +introduced until a second concrete adapter exists. +""" + +from __future__ import annotations + +from dorian.claude_code import _Template + +HOOKS_DIR = ".claude/hooks" +BUNDLE_DIR = ".claude/dorian-governance" + +# Install all three groups by default — the hooks are the whole point of the adapter +# (claude_code's DEFAULT_GROUPS omits "hook", so governance install passes this set). +DEFAULT_GROUPS = frozenset({"skill", "settings", "hook"}) + +GOVERNANCE_MANIFEST: tuple[_Template, ...] = ( + _Template( + "dorian-governance/hooks/dorian_preflight_veto.py", + f"{HOOKS_DIR}/dorian_preflight_veto.py", + "hook", + "PreToolUse veto (exit 2 on standing escalate; fails closed under strict modes)", + ), + _Template( + "dorian-governance/hooks/dorian_loop_preflight.py", + f"{HOOKS_DIR}/dorian_loop_preflight.py", + "hook", + "SubagentStop: run `dorian gate`, stamp freshness, write the runtime packet", + ), + _Template( + "dorian-governance/settings.dorian-governance.example.json", + ".claude/settings.dorian-governance.example.json", + "settings", + "example wiring for both hooks (merge into .claude/settings.json)", + ), + _Template( + "dorian-governance/README.md", + f"{BUNDLE_DIR}/README.md", + "skill", + "what the adapter does, the env vars it reads, the trust boundary", + ), + _Template( + "dorian-governance/reference/enforcement-modes.md", + f"{BUNDLE_DIR}/reference/enforcement-modes.md", + "skill", + "fail-open vs fail-closed by policy mode, and the freshness window", + ), +) diff --git a/src/dorian/sidecars.py b/src/dorian/sidecars.py new file mode 100644 index 0000000..db5627a --- /dev/null +++ b/src/dorian/sidecars.py @@ -0,0 +1,63 @@ +"""Safe, atomic, deterministic writers for local governance sidecar files. Stdlib only. + +No model, no network, no clock, no randomness, no global/process state. These helpers only +*write* local sidecar files inside a repository; their output is never read back as an input +to ``loop._decide`` (so they cannot influence a verdict), and they are deliberately NOT a +ledger and NOT a sandbox: + +- Containment is enforced by resolving both the root and the target and checking + ``Path.is_relative_to`` (never a string-prefix or ``commonpath`` compare). ``resolve`` + follows symlinks, so a symlinked path component that escapes the root is rejected. +- There is a benign time-of-check/time-of-use window between the containment check and the + atomic replace; for local, single-writer sidecars in a trusted repo this is acceptable + (the trust boundary is the repo + git identity, not OS-level isolation). +- Durability is git's job at commit time, so we do not ``fsync`` — we only guarantee that a + reader never observes a torn/partial file, via a same-directory temp write + ``os.replace``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def ensure_within(base: Path, target: Path) -> Path: + """Resolve ``target`` and confirm it lies inside ``base``; return the normalized path. + + ``target`` may be relative (resolved under ``base``) or absolute. Both ``base`` and the + resolved target are passed through ``Path.resolve`` so that ``..`` segments and symlinks + are normalized consistently before the containment check. Path traversal (``../``) and + absolute paths that land outside ``base`` raise ``ValueError``. This is containment, not + sandboxing. + """ + base_resolved = base.resolve() + target_resolved = (target if target.is_absolute() else base_resolved / target).resolve() + if not target_resolved.is_relative_to(base_resolved): + raise ValueError(f"path escapes root: {target} is not within {base}") + return target_resolved + + +def write_sidecar(repo: Path, rel_path: str, payload: dict) -> Path: + """Atomically write ``payload`` as deterministic JSON to ``rel_path`` under ``repo``. + + ``rel_path`` must be a relative path naming a file under ``repo``. Parent directories are + created as needed; writes outside ``repo`` (``../`` or absolute) are refused via + ``ensure_within``. The JSON is serialized with sorted keys, 2-space indent, and a single + trailing newline (stable on disk and in git), then moved into place via a same-directory + temp file and ``os.replace`` so a reader never sees a partial file. Returns the final + path. Does not read or influence Dorian verdicts; not a ledger; no hash chain; no + timestamps, nonces, clocks, or randomness. + """ + rel = Path(rel_path) + if rel.is_absolute(): + raise ValueError(f"rel_path must be relative, got absolute: {rel_path!r}") + if not rel.parts: + raise ValueError(f"rel_path must name a file, got empty or '.': {rel_path!r}") + + target = ensure_within(repo, rel) + target.parent.mkdir(parents=True, exist_ok=True) + data = json.dumps(payload, sort_keys=True, indent=2) + "\n" + tmp = target.with_name(target.name + ".tmp") + tmp.write_text(data, encoding="utf-8", newline="\n") + tmp.replace(target) + return target diff --git a/src/dorian/templates/claude_code/dorian-governance/README.md b/src/dorian/templates/claude_code/dorian-governance/README.md new file mode 100644 index 0000000..7d4a662 --- /dev/null +++ b/src/dorian/templates/claude_code/dorian-governance/README.md @@ -0,0 +1,45 @@ +# Dorian governance adapter (Claude Code) + +Two host hooks that turn Dorian's deterministic loop decision into Claude Code enforcement: + +- **`hooks/dorian_loop_preflight.py`** (`SubagentStop`) — after each sub-agent turn, shells the + pure `dorian gate` (which emits only contract codes `0`/`4` and a JSON decision packet), + stamps run identity + wall-clock freshness, and writes the packet to the ephemeral + `.dorian/local/last-decision.json`. It returns the steer as a soft `additionalContext` nudge + and never blocks at this point. +- **`hooks/dorian_preflight_veto.py`** (`PreToolUse`) — before a mutating tool runs, reads that + packet and **exits `2` to block** the tool when the standing decision is `escalate`. + +The split is deliberate (Dorian's non-negotiables): + +- **Dorian core / `dorian gate` only ever emit contract codes `0`/`4`** and a plain JSON packet — + no `exit 2` veto, no wall-clock, no randomness, no Claude-Code-specific output. +- **The exit-2 tool-block veto, the `hookSpecificOutput`, and the wall-clock/nonce stamping live + only in these host hooks** — the mapping from a deterministic decision to Claude Code's blocking + convention is host-side, not in the verifier. + +## Wire it up + +1. Merge `settings.dorian-governance.example.json` into your `.claude/settings.json`. +2. Before launching the agent, export the loop window + identity: + - `DORIAN_BASE` — the loop's base git ref (required; without it the hooks stay silent). + - `DORIAN_POLICY` — `cautious` | `assist` | `unattended` (default `assist`). + - `DORIAN_EFFORT` — set to `godmode` for the strictest court. + - `DORIAN_SCOPE` — comma-separated jurisdiction globs (e.g. `src/**,tests/**`). + - `DORIAN_REPO_ROOT`, `DORIAN_NONCE` — run identity; under strict mode the veto refuses a + packet whose `repo_root`/`base_ref`/`nonce` don't match, or that is older than 15 minutes. +3. Add `.dorian/local/` to your `.gitignore` — the runtime decision packet is ephemeral and + must never be committed (it is non-authoritative and is never read back into a verdict). + +## Fail-open vs fail-closed + +See [`reference/enforcement-modes.md`](reference/enforcement-modes.md). In short: **strict modes +(`unattended` / godmode) fail CLOSED** — a missing, stale, or mismatched packet blocks the tool; +**attended modes fail OPEN** — a human is present, so a missing packet never traps the agent. + +## Trust boundary + +This is **not a sandbox**. It governs *which decisions block which tools* on a trusted, internal +repo; it does not isolate the filesystem, network, or process. For semi-trusted contexts pair it +with `--deny-exec` / `--checker-source base` and OS-level isolation. This is one concrete Claude +Code adapter — no generic provider abstraction exists until a second adapter does. diff --git a/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_loop_preflight.py b/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_loop_preflight.py new file mode 100644 index 0000000..b3c1c37 --- /dev/null +++ b/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_loop_preflight.py @@ -0,0 +1,71 @@ +"""Claude Code SubagentStop hook for the Dorian governance adapter. + +After each sub-agent turn it runs the deterministic Dorian gate (`dorian gate`, which emits +only contract codes 0/4 and a JSON decision packet), stamps run identity + wall-clock +freshness onto the packet, and writes it to the ephemeral, gitignored +.dorian/local/last-decision.json for the PreToolUse veto to read. It returns the steer as a +soft `additionalContext` nudge and never blocks at SubagentStop. Stdlib only; it fails open so +a hook error can never break the turn. The wall-clock and nonce live ONLY here, never in +Dorian core (which stays a pure, time-free, randomness-free emitter). + +Wire it by exporting DORIAN_BASE (the loop's base ref) before launching the agent; optionally +DORIAN_POLICY, DORIAN_SCOPE (comma-separated globs), DORIAN_REPO_ROOT, DORIAN_NONCE. +""" + +import json +import os +import subprocess +import sys +import time + + +def main() -> int: + base = os.environ.get("DORIAN_BASE") + if not base: + return 0 # no loop window configured -> stay silent + cmd = [ + "dorian", + "gate", + "--since", + base, + "--policy", + os.environ.get("DORIAN_POLICY", "assist"), + "--state-file", + ".dorian/local/loop-state.json", + ] + for glob in filter(None, os.environ.get("DORIAN_SCOPE", "").split(",")): + cmd += ["--scope", glob] + try: + out = subprocess.run( + cmd, input="{}", capture_output=True, text=True, timeout=600, check=False + ) + except Exception: + return 0 # a hook must never break the turn + if out.returncode not in (0, 4): + return 0 + try: + packet = json.loads(out.stdout) + except Exception: + return 0 + # The RUNNER stamps identity + freshness; wall-clock / nonce live only here. + packet["created_at_epoch"] = int(time.time()) + packet["repo_root"] = os.environ.get("DORIAN_REPO_ROOT", os.getcwd()) + packet["base_ref"] = base + packet["nonce"] = os.environ.get("DORIAN_NONCE", "") + try: + os.makedirs(".dorian/local", exist_ok=True) + with open(".dorian/local/last-decision.json", "w", encoding="utf-8") as fh: + json.dump(packet, fh) + except Exception: + return 0 + nudge = packet.get("loop_instruction") or packet.get("reason", "") + print( + json.dumps( + {"hookSpecificOutput": {"hookEventName": "SubagentStop", "additionalContext": nudge}} + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_preflight_veto.py b/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_preflight_veto.py new file mode 100644 index 0000000..58a1d9b --- /dev/null +++ b/src/dorian/templates/claude_code/dorian-governance/hooks/dorian_preflight_veto.py @@ -0,0 +1,58 @@ +"""Claude Code PreToolUse veto for the Dorian governance adapter. + +Blocks a mutating tool call when Dorian's last loop decision is ESCALATE. This host hook — +not `dorian gate` — is where the exit-2 tool-blocking veto lives (Dorian's own commands only +ever emit contract codes 0/4). It FAILS CLOSED under strict modes (unattended / godmode) and +on a stale, mismatched, or missing decision packet; it fails OPEN in attended modes so a human +at the keyboard is never trapped. Stdlib only. It reads the ephemeral packet the SubagentStop +hook wrote to .dorian/local/last-decision.json — it is not a sandbox. + +Env: DORIAN_POLICY (cautious|assist|unattended), DORIAN_EFFORT (godmode => strict), +DORIAN_REPO_ROOT, DORIAN_BASE, DORIAN_NONCE (must match the stamped packet under strict mode). +""" + +import json +import os +import sys +import time + +FRESH_SECONDS = 900 # a decision packet older than this is treated as stale + + +def main() -> int: + mode = os.environ.get("DORIAN_POLICY", "assist") + strict = mode == "unattended" or os.environ.get("DORIAN_EFFORT") == "godmode" + try: + tool = json.load(sys.stdin) + path = (tool.get("tool_input") or {}).get("file_path", "") + except Exception: + return 2 if strict else 0 # malformed tool input: blocked under strict modes + try: + with open(".dorian/local/last-decision.json", encoding="utf-8") as fh: + d = json.load(fh) + except Exception: + if strict: # FAIL CLOSED: no fresh packet under unattended / godmode + sys.stderr.write("Dorian VETO: no fresh decision packet under strict mode.") + return 2 + return 0 # attended: a human is present, do not trap the agent + stale = (time.time() - d.get("created_at_epoch", 0)) > FRESH_SECONDS + # Defaults MUST match how the SubagentStop hook stamps the packet (os.getcwd() and ""), + # or an unset DORIAN_REPO_ROOT/DORIAN_NONCE would false-mismatch and block every tool. + mismatch = ( + d.get("repo_root") != os.environ.get("DORIAN_REPO_ROOT", os.getcwd()) + or d.get("base_ref") != os.environ.get("DORIAN_BASE") + or d.get("nonce") != os.environ.get("DORIAN_NONCE", "") + ) + if strict and (stale or mismatch): + sys.stderr.write("Dorian VETO: stale or mismatched decision packet under strict mode.") + return 2 + sensitive = any(b.get("sensitive") for b in d.get("broken_claims", [])) + if d.get("decision") == "escalate" and (strict or sensitive or not path): + reason = d.get("human_escalation", {}).get("reason", "escalate") + sys.stderr.write("Dorian VETO: " + reason) + return 2 # exit-2 tool-block lives HERE in the host hook, never in `dorian gate` + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dorian/templates/claude_code/dorian-governance/reference/enforcement-modes.md b/src/dorian/templates/claude_code/dorian-governance/reference/enforcement-modes.md new file mode 100644 index 0000000..e93e636 --- /dev/null +++ b/src/dorian/templates/claude_code/dorian-governance/reference/enforcement-modes.md @@ -0,0 +1,38 @@ +# Enforcement modes — fail-open vs fail-closed + +The `PreToolUse` veto's behavior depends on the policy/effort mode, because the right default +differs when a human is at the keyboard vs when the loop runs unattended. + +`strict` = `DORIAN_POLICY=unattended` **or** `DORIAN_EFFORT=godmode`. + +| Condition (before a mutating tool runs) | Attended (cautious/assist) | Strict (unattended/godmode) | +|------------------------------------------------|----------------------------|-----------------------------| +| Malformed tool JSON on stdin | allow (exit 0) | **block (exit 2)** | +| No decision packet found | allow (exit 0) | **block (exit 2)** | +| Packet stale (> 15 min) or identity mismatch | allow (exit 0) | **block (exit 2)** | +| Decision is `escalate`, path is sensitive | **block (exit 2)** | **block (exit 2)** | +| Decision is `escalate`, ordinary path | allow (exit 0) | **block (exit 2)** | +| Decision is `continue` / `repair` | allow (exit 0) | allow (exit 0) | + +Rationale: + +- **Fail closed under strict modes.** With no human watching, a missing/stale/mismatched packet + must not silently let an escalated loop keep mutating — the safe default is to block and force a + human in. A leftover packet from a prior run or worktree can never enforce (identity guard). +- **Fail open under attended modes.** A human is present, so a missing packet should never trap the + agent mid-task; the human is the backstop. +- **Sensitive paths always block on `escalate`**, regardless of mode — the built-in + `SENSITIVE_GLOBS` jurisdiction (secrets, CI, infra, auth, migrations) is the load-bearing + defense and is never auto-repaired. + +## Freshness window + +`FRESH_SECONDS = 900` (15 minutes). The `SubagentStop` hook stamps `created_at_epoch` (host +wall-clock) when it writes the packet; under strict mode the veto rejects an older packet. Tune +the constant in `hooks/dorian_preflight_veto.py` if your turns run longer. + +## What is NOT here + +The exit-2 mapping, the wall-clock/nonce, and the `hookSpecificOutput` are all **host-hook** +concerns. `dorian gate` and the deterministic core never use exit 2 as a veto, never read a clock, +and never emit Claude-Code-specific output — keeping the verdict path pure and portable. diff --git a/src/dorian/templates/claude_code/dorian-governance/settings.dorian-governance.example.json b/src/dorian/templates/claude_code/dorian-governance/settings.dorian-governance.example.json new file mode 100644 index 0000000..3266a8c --- /dev/null +++ b/src/dorian/templates/claude_code/dorian-governance/settings.dorian-governance.example.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|Bash", + "hooks": [ + { "type": "command", "command": "python3 .claude/hooks/dorian_preflight_veto.py" } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { "type": "command", "command": "python3 .claude/hooks/dorian_loop_preflight.py" } + ] + } + ] + } +} diff --git a/tests/test_firewall_import_closure.py b/tests/test_firewall_import_closure.py new file mode 100644 index 0000000..7175069 --- /dev/null +++ b/tests/test_firewall_import_closure.py @@ -0,0 +1,282 @@ +"""Determinism firewall: prove no model/provider/network/UI dependency is reachable +from Dorian's deterministic verification (verdict) path. + +This is a *regression guard*, not a product subsystem. Non-Negotiable #1 ("no LLM/model +in the verification path") and #3 ("zero core runtime dependencies") are enforced by +construction today; this test makes them enforceable in CI rather than by convention. + +How it works: starting from the deterministic-core seed modules, it statically walks the +``dorian.*`` import graph with the stdlib ``ast`` module (it never imports project code, +runs no checker, and touches no network), collecting every *external* (non-``dorian``) +import root reachable from that closure. Only **module-level** (import-time) imports are +followed — function-local imports are the project's sanctioned way to keep the core import +surface clean for optional deps (see ``_module_level_import_nodes``). The test fails if any +reachable root is on the forbidden list. stdlib modules (e.g. ``json``, ``subprocess``, +``urllib.parse``) are allowed — only model SDKs, network clients, agent frameworks, and +TUI/UI libraries are forbidden, because none of those belong on a token-free, local-only +verdict path. +""" + +from __future__ import annotations + +import ast +import pathlib +import tempfile + +PKG = "dorian" +SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / PKG + +# The deterministic-core seeds: warrant model, checker execution, sealing, revalidation, +# fold/verdict logic, execution policy, loop decision classification, plus the storage, +# git, and claim-parsing modules the verdict path hard-depends on. The walker follows +# dorian.* edges transitively from here, so modules these reach (e.g. blast, pyast, +# _regex_worker, cli, claude_code) are guarded too — the seed list is a floor, not a cap. +VERDICT_SEEDS = [ + "model", + "checkers", + "seal", + "revalidate", + "fold", + "policy", + "loop", + "store", + "gitio", + "claims_io", +] + +# Forbidden top-level import roots on the verdict path. Model/provider SDKs, agent +# frameworks, network clients, and TUI/UI libraries. NB: stdlib ``urllib`` is allowed +# (used as ``urllib.parse``); the third-party ``urllib3`` is not. +FORBIDDEN = { + # model / provider SDKs + "anthropic", + "openai", + "google", + "genai", + "cohere", + # agent / orchestration frameworks + "langchain", + "langgraph", + "autogen", + "semantic_kernel", + "crewai", + "llama_index", + "mcp", + # network clients + "requests", + "httpx", + "aiohttp", + "urllib3", + "websockets", + # TUI / UI libraries + "textual", + "rich", + "urwid", + "blessed", + "prompt_toolkit", +} + +# Deterministic-core utility modules that are NOT on the verdict path (so they do not belong +# in VERDICT_SEEDS) but must still stay stdlib-only. Guarded so a core write/helper module +# cannot silently introduce a model/network/UI dependency. Add new such helpers here. +CORE_UTILITY_SEEDS = ["sidecars", "goals", "governance"] + + +def _module_path(modname: str, src: pathlib.Path) -> pathlib.Path | None: + """Resolve a dorian submodule name (``"loop"`` or ``"checkers.base"``) to a file. + + Returns the module file, the package ``__init__.py``, or ``None`` if the name does not + resolve to a file/package (e.g. it is a symbol re-exported from a package ``__init__``). + """ + rel = modname.replace(".", "/") + f = src / (rel + ".py") + if f.exists(): + return f + init = src / rel / "__init__.py" + if init.exists(): + return init + return None + + +def _module_level_import_nodes(tree: ast.Module) -> list[ast.stmt]: + """Import/ImportFrom nodes that execute at MODULE LOAD time — i.e. not inside a + function body. Imports nested in top-level ``if``/``try``/``with``/``class`` blocks + count (they run at import); imports inside ``def``/``async def`` do NOT. + + This is deliberate: Dorian's own convention is to hide optional or heavy dependencies + behind *function-local* imports (e.g. ``anthropic`` in ``extract.py``, the lazy + ``bindings``/``strength`` imports in ``seal.py``) precisely so the module import + surface stays clean and the core installs with zero runtime dependencies. NN1 is about + what loads when you ``import`` a verdict module, so the firewall scans the import-time + surface. A function-local optional import is the sanctioned escape hatch — and it is + backstopped in depth by the empty ``[project].dependencies`` (a function-local + ``import anthropic`` would simply ``ImportError`` in a clean core install).""" + nodes: list[ast.stmt] = [] + + def visit(parent: ast.AST) -> None: + for child in ast.iter_child_nodes(parent): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue # do not descend into function bodies + if isinstance(child, (ast.Import, ast.ImportFrom)): + nodes.append(child) + visit(child) + + visit(tree) + return nodes + + +def _scan(path: pathlib.Path, src: pathlib.Path) -> tuple[set[str], set[str]]: + """Return ``(internal_submodules, external_roots)`` imported at MODULE LEVEL by one file. + + ``internal_submodules`` are dorian-relative dotted names (``"checkers.base"``); + ``external_roots`` are top-level non-dorian package roots (``"json"``, ``"anthropic"``). + Handles absolute (``from dorian import x`` / ``from dorian.a.b import y`` / ``import + dorian.x``) and, defensively, relative imports (``from . import x``) even though the + current core uses only absolute imports. Function-local imports are intentionally + ignored (see ``_module_level_import_nodes``). + """ + tree = ast.parse(path.read_text(encoding="utf-8")) + internal: set[str] = set() + external: set[str] = set() + # dorian-relative package of this file, e.g. checkers/base.py -> ["checkers"] + rel_parts = path.relative_to(src).with_suffix("").parts + pkg_parts = list(rel_parts[:-1]) + + for node in _module_level_import_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + parts = alias.name.split(".") + if parts[0] == PKG: + if len(parts) > 1: + internal.add(".".join(parts[1:])) # import dorian.a.b -> a.b + else: + external.add(parts[0]) + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + # relative import: resolve against this file's package position + drop = node.level - 1 + base = pkg_parts[: len(pkg_parts) - drop] if drop else pkg_parts + tail = node.module.split(".") if node.module else [] + mod_parts = [*base, *tail] + if mod_parts: + internal.add(".".join(mod_parts)) + for alias in node.names: + internal.add(".".join([*mod_parts, alias.name]) if mod_parts else alias.name) + continue + parts = (node.module or "").split(".") + if parts[0] == PKG: + if len(parts) > 1: + internal.add(".".join(parts[1:])) # from dorian.a.b import x -> a.b + else: + # from dorian import gitio, store -> {gitio, store}. NB: a bare + # `import dorian` (handled above) and `from dorian import *` are not + # followed — the core uses neither; a future star-import into a verdict + # module would be a known blind spot to close if it ever appears. + for alias in node.names: + internal.add(alias.name) + elif parts[0]: + external.add(parts[0]) + return internal, external + + +def external_imports(seeds: list[str], src: pathlib.Path = SRC) -> dict[str, list[str]]: + """Map each external import root reachable from ``seeds`` to the sorted list of source + files (relative to ``src``) that import it. Follows dorian.* edges transitively.""" + if not src.is_dir(): + # Fail loudly instead of passing vacuously if the core source tree is missing or + # the repo layout changed — a silent empty result would defeat the guard. + raise FileNotFoundError(f"deterministic-core source tree not found: {src}") + seen: set[str] = set() + stack: list[str] = list(seeds) + sites: dict[str, set[str]] = {} + + while stack: + name = stack.pop() + if name in seen: + continue + seen.add(name) + path = _module_path(name, src) + if path is None: + # Not a file/package (e.g. a symbol re-exported from a package __init__, + # or a bare package dir without __init__). Expand a dir if one exists. + d = src / name.replace(".", "/") + if d.is_dir(): + for f in sorted(d.rglob("*.py")): + _absorb(f, src, sites, stack, seen) + continue + targets = [path] + if path.name == "__init__.py": + # a package seed: scan every module in the package, not just __init__ + targets += [p for p in sorted(path.parent.rglob("*.py")) if p != path] + for t in targets: + _absorb(t, src, sites, stack, seen) + + return {root: sorted(files) for root, files in sorted(sites.items())} + + +def _absorb( + f: pathlib.Path, + src: pathlib.Path, + sites: dict[str, set[str]], + stack: list[str], + seen: set[str], +) -> None: + internal, external = _scan(f, src) + rel = f.relative_to(src).as_posix() + for root in external: + sites.setdefault(root, set()).add(rel) + stack.extend(m for m in internal if m not in seen) + + +def test_no_model_or_network_on_verdict_path(): + """The deterministic verdict path imports nothing model/network/UI-shaped.""" + reachable = external_imports(VERDICT_SEEDS) + offenders = {root: files for root, files in reachable.items() if root in FORBIDDEN} + assert not offenders, ( + "Forbidden import(s) reachable from Dorian's deterministic verdict path " + f"(seeds={VERDICT_SEEDS}):\n" + + "\n".join( + f" - {root!r} imported by: {', '.join(files)}" for root, files in offenders.items() + ) + + "\nThe verification path must stay model-free, network-free, and UI-free " + "(Non-Negotiable #1, zero-dependency core). Move the dependency OUT of core " + "(into extract/adapters/pane) or behind a function-local optional import." + ) + + +def test_firewall_detects_planted_forbidden_imports(): + """Negative control: the walker catches a forbidden import — directly AND through a + transitive dorian.* edge — so a future violation cannot pass silently. Uses a synthetic + source tree (no production code is edited, no fragile re-import of this test module).""" + with tempfile.TemporaryDirectory() as tmp: + fake = pathlib.Path(tmp) / "dorian" + fake.mkdir() + # loop imports a forbidden root directly, and pulls in store via a dorian.* edge + (fake / "loop.py").write_text( + "import anthropic\nfrom dorian import store\nimport json\n", encoding="utf-8" + ) + # store (reached transitively) imports another forbidden root + (fake / "store.py").write_text("from openai import OpenAI\n", encoding="utf-8") + + reachable = external_imports(["loop"], src=fake) + + assert "anthropic" in reachable, "direct forbidden import not detected" + assert "openai" in reachable, "transitive forbidden import (loop -> store) not detected" + assert reachable["anthropic"] == ["loop.py"] + assert reachable["openai"] == ["store.py"] + assert set(reachable) & FORBIDDEN == {"anthropic", "openai"} + # stdlib imports are recorded but are NOT forbidden + assert "json" in reachable and "json" not in FORBIDDEN + + +def test_core_utility_modules_stay_stdlib_only(): + """`dorian.sidecars` and `dorian.goals` are deterministic-core helpers — not on the + verdict path (so not in VERDICT_SEEDS), but they must also stay stdlib-only. Guard them + explicitly so a core helper cannot silently introduce a model/network/UI dependency, and + confirm each is actually scanned (not an unguarded orphan).""" + reachable = external_imports(CORE_UTILITY_SEEDS) + scanned = {f for files in reachable.values() for f in files} + for mod in CORE_UTILITY_SEEDS: + assert f"{mod}.py" in scanned, f"dorian.{mod} not scanned — orphan core module?" + offenders = sorted(set(reachable) & FORBIDDEN) + assert not offenders, f"forbidden import in core utility module(s): {offenders}" diff --git a/tests/test_gate.py b/tests/test_gate.py new file mode 100644 index 0000000..7509b5c --- /dev/null +++ b/tests/test_gate.py @@ -0,0 +1,142 @@ +"""Tests for `dorian gate` — a host-mappable preflight body. + +`dorian gate` reuses the existing Loop Guard preflight and emits ONLY Dorian contract exit +codes (0, or 4 under --fail-on). It NEVER uses exit 2 as a REPAIR/ESCALATE veto — exit 2 is +reserved for malformed input / usage. The host-hook exit-2 veto mapping belongs to a later +task, not to this command. The loop preflight path is exercised end-to-end by the existing +loop tests; here we stub `_run_loop_preflight` to prove the gate's wiring deterministically. +""" + +import io +import json + +from dorian import cli, commands + + +def _ns(*argv: str): + return cli.build_parser().parse_args(list(argv)) + + +def _packet(decision: str) -> dict: + # minimal but field-shaped like LoopDecision.to_dict() + return { + "schema_version": 1, + "decision": decision, + "reason": f"stub {decision}", + "policy": "assist", + "notes": [], + } + + +def _run_gate(monkeypatch, tmp_path, *, decision, fail_on=None, stdin='{"tool_input": {}}'): + monkeypatch.setattr(commands.sys, "stdin", io.StringIO(stdin)) + monkeypatch.setattr(commands, "_run_loop_preflight", lambda args: (_packet(decision), 0)) + argv = ["--repo", str(tmp_path), "gate", "--since", "HEAD"] + if fail_on is not None: + argv += ["--fail-on", fail_on] + return commands.cmd_gate(_ns(*argv)) + + +# --- output + reuse -------------------------------------------------------------------- + + +def test_gate_prints_loopdecision_json_and_reuses_preflight(tmp_path, monkeypatch, capsys): + rc = _run_gate(monkeypatch, tmp_path, decision="continue") + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["decision"] == "continue" + assert "reason" in payload # the real LoopDecision packet, not a host-hook shape + assert rc == 0 + + +# --- default exit codes: a decision NEVER blocks by itself ------------------------------ + + +def test_gate_continue_default_exit_0(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="continue") == 0 + + +def test_gate_repair_default_exit_0(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="repair") == 0 + + +def test_gate_escalate_default_exit_0(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="escalate") == 0 + + +# --- --fail-on opts into a hard exit 4 (never 2) --------------------------------------- + + +def test_gate_escalate_fail_on_escalate_exit_4(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="escalate", fail_on="escalate") == 4 + + +def test_gate_repair_fail_on_repair_exit_4(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="repair", fail_on="repair") == 4 + + +def test_gate_escalate_fail_on_repair_exit_4(tmp_path, monkeypatch): + # repair threshold means "repair or worse" + assert _run_gate(monkeypatch, tmp_path, decision="escalate", fail_on="repair") == 4 + + +def test_gate_continue_fail_on_escalate_exit_0(tmp_path, monkeypatch): + assert _run_gate(monkeypatch, tmp_path, decision="continue", fail_on="escalate") == 0 + + +def test_gate_repair_fail_on_escalate_exit_0(tmp_path, monkeypatch): + # repair is below the escalate threshold -> not blocked + assert _run_gate(monkeypatch, tmp_path, decision="repair", fail_on="escalate") == 0 + + +def test_no_valid_decision_path_returns_2(tmp_path, monkeypatch): + for decision in ("continue", "repair", "escalate"): + for fail_on in (None, "never", "repair", "escalate"): + rc = _run_gate(monkeypatch, tmp_path, decision=decision, fail_on=fail_on) + assert rc in (0, 4), f"{decision}/{fail_on} -> {rc}; valid decisions must never be 2" + + +# --- malformed stdin: usage error, NOT a policy veto ----------------------------------- + + +def test_malformed_stdin_json_is_usage_error_not_a_veto(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(commands.sys, "stdin", io.StringIO("{ not json")) + + def _must_not_run(args): + raise AssertionError("preflight must not run on malformed stdin") + + monkeypatch.setattr(commands, "_run_loop_preflight", _must_not_run) + rc = commands.cmd_gate(_ns("--repo", str(tmp_path), "gate", "--since", "HEAD")) + out = capsys.readouterr().out + assert rc == 2 # EXIT_USAGE — malformed input, explicitly NOT a REPAIR/ESCALATE veto + assert out.strip() == "" # no fake LoopDecision packet printed + + +# --- preflight errors pass through with existing fail-closed semantics ------------------ + + +def test_gate_passes_through_preflight_usage_error(tmp_path, monkeypatch): + monkeypatch.setattr(commands.sys, "stdin", io.StringIO("{}")) + monkeypatch.setattr(commands, "_run_loop_preflight", lambda args: (None, 2)) + rc = commands.cmd_gate(_ns("--repo", str(tmp_path), "gate", "--since", "HEAD")) + assert rc == 2 # propagated usage error (e.g. bad ref), not invented + + +def test_gate_passes_through_preflight_sidecar_error(tmp_path, monkeypatch): + monkeypatch.setattr(commands.sys, "stdin", io.StringIO("{}")) + monkeypatch.setattr(commands, "_run_loop_preflight", lambda args: (None, 4)) + rc = commands.cmd_gate(_ns("--repo", str(tmp_path), "gate", "--since", "HEAD")) + assert rc == 4 # fail-closed evidence (corrupt sidecar), existing loop semantics + + +# --- host-hook boundary: gate is NOT the Claude Code hook ------------------------------ + + +def test_gate_emits_no_host_hook_json_and_writes_no_files(tmp_path, monkeypatch, capsys): + rc = _run_gate(monkeypatch, tmp_path, decision="escalate", fail_on="escalate") + out = capsys.readouterr().out + assert "hookSpecificOutput" not in out + assert "PreToolUse" not in out + assert "additionalContext" not in out + assert not (tmp_path / ".claude").exists() # gate creates no hook files + assert rc == 4 diff --git a/tests/test_goals_coverage.py b/tests/test_goals_coverage.py new file mode 100644 index 0000000..7447987 --- /dev/null +++ b/tests/test_goals_coverage.py @@ -0,0 +1,350 @@ +"""Tests for dorian.goals — human-authored Goal records and the `dorian goal` CLI. + +Task 3 scope only: the durable Goal record + add/show CLI. It must NOT decide whether a +goal is complete and must not be wired into the verdict path (coverage_diff is Task 4). +""" + +import json +from pathlib import Path + +import pytest + +from dorian import cli, commands, goals + + +def _ns(*argv: str): + return cli.build_parser().parse_args(list(argv)) + + +def _make_goal(**over): + base = dict( + goal_id="g-1", + title="Add idempotency", + statement="add idempotency keys to POST /orders", + policy_ref="default-assist", + scope=("src/payments/**",), + deny_paths=("infra/*",), + base_ref="main~1", + coverage_contract={"min_strength_load_bearing": "structural"}, + ) + base.update(over) + return goals.Goal(**base) + + +def test_goal_roundtrip(tmp_path): + g = _make_goal() + goals.save(tmp_path, g) + loaded = goals.load(tmp_path, "g-1") + assert loaded == g # frozen dataclass value equality + assert loaded.schema_version == goals.SCHEMA_VERSION + assert loaded.statement == "add idempotency keys to POST /orders" + assert loaded.scope == ("src/payments/**",) + assert loaded.policy_ref == "default-assist" + + +def test_goal_saved_to_expected_sidecar_path(tmp_path): + p = goals.save(tmp_path, _make_goal()) + assert p == (tmp_path / ".dorian/goals/g-1.goal.json").resolve() + assert p.exists() + + +def test_goal_payload_deterministic_and_sorted(tmp_path): + g = _make_goal() + b1 = goals.save(tmp_path, g).read_bytes() + b2 = goals.save(tmp_path, g).read_bytes() + assert b1 == b2 # same record -> identical bytes + text = b1.decode("utf-8") + assert text.index('"base_ref"') < text.index('"goal_id"') < text.index('"statement"') + + +def test_schema_version_present_and_unsupported_rejected(tmp_path): + goals.save(tmp_path, _make_goal()) + sidecar = tmp_path / ".dorian/goals/g-1.goal.json" + data = json.loads(sidecar.read_text()) + assert data["schema_version"] == goals.SCHEMA_VERSION + data["schema_version"] = 999 + sidecar.write_text(json.dumps(data)) + with pytest.raises(ValueError): + goals.load(tmp_path, "g-1") + + +def test_malformed_record_rejected(tmp_path): + sidecar = tmp_path / ".dorian/goals/g-1.goal.json" + sidecar.parent.mkdir(parents=True) + sidecar.write_text("{ not json") + with pytest.raises(ValueError): + goals.load(tmp_path, "g-1") + + +def test_base_ref_required_and_preserved(tmp_path): + goals.save(tmp_path, _make_goal(base_ref="release/v2")) + assert goals.load(tmp_path, "g-1").base_ref == "release/v2" + data = json.loads((tmp_path / ".dorian/goals/g-1.goal.json").read_text()) + assert "base_ref" in data + + +def test_statement_and_scope_preserved_exactly(tmp_path): + g = _make_goal(statement="exact human words; preserved verbatim", scope=("a/**", "b/**")) + goals.save(tmp_path, g) + loaded = goals.load(tmp_path, "g-1") + assert loaded.statement == "exact human words; preserved verbatim" + assert loaded.scope == ("a/**", "b/**") + + +def test_path_escape_refused_via_save(tmp_path): + # goal_path is .dorian/goals/.goal.json (2 levels deep) -> need 3+ ../ to escape + with pytest.raises(ValueError): + goals.save(tmp_path, _make_goal(goal_id="../../../escape")) + + +def test_cli_goal_add_creates_record(tmp_path, capsys): + rc = commands.cmd_goal( + _ns( + "--repo", + str(tmp_path), + "goal", + "add", + "--id", + "g-1", + "--title", + "Add idempotency", + "--statement", + "do X", + "--base-ref", + "HEAD", + "--scope", + "src/**", + "--deny-path", + "infra/*", + "--policy-ref", + "default-assist", + "--min-strength", + "structural", + ) + ) + assert rc == 0 + assert "g-1" in capsys.readouterr().out + g = goals.load(tmp_path, "g-1") + assert g.title == "Add idempotency" + assert g.scope == ("src/**",) + assert g.deny_paths == ("infra/*",) + assert g.base_ref == "HEAD" + assert g.coverage_contract == {"min_strength_load_bearing": "structural"} + + +def test_cli_goal_show_displays_record(tmp_path, capsys): + commands.cmd_goal( + _ns( + "--repo", + str(tmp_path), + "goal", + "add", + "--id", + "g-1", + "--title", + "T", + "--base-ref", + "HEAD", + ) + ) + capsys.readouterr() # clear add output + rc = commands.cmd_goal(_ns("--repo", str(tmp_path), "goal", "show", "--id", "g-1")) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["goal_id"] == "g-1" + assert payload["title"] == "T" + + +def test_cli_goal_show_missing_record_is_usage_error(tmp_path): + rc = commands.cmd_goal(_ns("--repo", str(tmp_path), "goal", "show", "--id", "nope")) + assert rc == 2 # EXIT_USAGE — clear error, not a crash + + +def test_goals_not_wired_into_verdict_path(): + """goals.py imports only sidecars + stdlib; no verdict module imports goals — so the + goal record can never feed verdict/completion/continuation logic.""" + gmod = Path(goals.__file__) + src = gmod.read_text(encoding="utf-8") + for forbidden in ( + "import revalidate", + "dorian.revalidate", + "dorian.loop", + "_decide", + "dorian.fold", + "dorian.seal", + "dorian.checkers", + "dorian.policy", + ): + assert forbidden not in src, f"goals.py must not touch verdict logic: {forbidden!r}" + pkg = gmod.parent + for mod in ("loop.py", "revalidate.py", "fold.py", "seal.py", "policy.py"): + text = (pkg / mod).read_text(encoding="utf-8") + assert "goals" not in text.replace("# ", ""), f"{mod} must not reference goals" + + +# --- Task 4: deterministic goal<->claim coverage diff (pure function) ----------------- + + +def test_coverage_uncovered_in_scope_without_warrant(): + g = _make_goal(scope=("src/**",)) + res = goals.coverage_diff(g, ["src/a.py"], []) + assert res == {"covered": [], "uncovered": ["src/a.py"]} + + +def test_coverage_covered_exact_warrant(): + g = _make_goal(scope=("src/**",)) + res = goals.coverage_diff(g, ["src/a.py"], ["src/a.py"]) + assert res == {"covered": ["src/a.py"], "uncovered": []} + + +def test_coverage_out_of_scope_path_ignored(): + g = _make_goal(scope=("src/**",)) + res = goals.coverage_diff(g, ["docs/readme.md", "src/a.py"], []) + assert res == {"covered": [], "uncovered": ["src/a.py"]} # docs/ neither covered nor uncovered + + +def test_coverage_sorted_and_deduped(): + g = _make_goal(scope=("src/**",)) + res = goals.coverage_diff(g, ["src/b.py", "src/a.py", "src/a.py"], ["src/a.py"]) + assert res["covered"] == ["src/a.py"] # deduped + assert res["uncovered"] == ["src/b.py"] # sorted, deduped + + +def test_coverage_empty_scope_means_all_changed_paths_in_scope(): + g = _make_goal(scope=()) + res = goals.coverage_diff(g, ["anywhere/x.py"], []) + assert res["uncovered"] == ["anywhere/x.py"] + + +def test_coverage_normalizes_paths(): + g = _make_goal(scope=("src/**",)) + # "./src/a.py" normalizes to "src/a.py" and matches the warrant "src/a.py" + res = goals.coverage_diff(g, ["./src/a.py"], ["src/a.py"]) + assert res == {"covered": ["src/a.py"], "uncovered": []} + + +def test_coverage_absolute_path_does_not_false_match_scope(): + g = _make_goal(scope=("src/**",)) + res = goals.coverage_diff(g, ["/etc/passwd"], []) + assert res == {"covered": [], "uncovered": []} # absolute -> out of "src/**" scope -> ignored + + +def test_coverage_ignores_statement_and_coverage_contract(): + a = _make_goal(scope=("src/**",), statement="alpha", coverage_contract={}) + b = _make_goal( + scope=("src/**",), + statement="totally different prose", + coverage_contract={"min_strength_load_bearing": "behavioral"}, + ) + changed, warranted = ["src/a.py"], [] + assert goals.coverage_diff(a, changed, warranted) == goals.coverage_diff(b, changed, warranted) + + +# --- Task 4: `dorian goal check` CLI (I/O boundaries stubbed for determinism) ---------- + + +def _seed_goal(tmp_path, scope="src/**"): + commands.cmd_goal( + _ns( + "--repo", + str(tmp_path), + "goal", + "add", + "--id", + "g-1", + "--title", + "T", + "--base-ref", + "HEAD", + "--scope", + scope, + ) + ) + + +class _FakeConn: + def close(self): + pass + + +def _stub_io(monkeypatch, *, changed, warranted): + monkeypatch.setattr(commands.gitio, "changed_paths", lambda repo, since: (list(changed), {})) + monkeypatch.setattr(commands.store, "connect", lambda repo: _FakeConn()) + monkeypatch.setattr(commands.store, "sync", lambda repo, conn: None) + monkeypatch.setattr( + commands.store, "get_warrants", lambda conn: [{"artifact_uri": p} for p in warranted] + ) + + +def test_cli_goal_check_prints_json_and_exits_ok_without_flag(tmp_path, monkeypatch, capsys): + _seed_goal(tmp_path) + capsys.readouterr() + _stub_io(monkeypatch, changed=["src/a.py"], warranted=[]) + rc = commands.cmd_goal( + _ns("--repo", str(tmp_path), "goal", "check", "--id", "g-1", "--since", "HEAD") + ) + out = json.loads(capsys.readouterr().out) + assert out == {"covered": [], "uncovered": ["src/a.py"]} + assert rc == 0 # uncovered exists but no --fail-on-uncovered -> EXIT_OK + + +def test_cli_goal_check_fail_on_uncovered_returns_4(tmp_path, monkeypatch, capsys): + _seed_goal(tmp_path) + capsys.readouterr() + _stub_io(monkeypatch, changed=["src/a.py"], warranted=[]) + rc = commands.cmd_goal( + _ns( + "--repo", + str(tmp_path), + "goal", + "check", + "--id", + "g-1", + "--since", + "HEAD", + "--fail-on-uncovered", + ) + ) + assert rc == 4 # EXIT_REVOKED — a refusal, NOT a false claim + + +def test_cli_goal_check_covered_passes_under_fail_flag(tmp_path, monkeypatch, capsys): + _seed_goal(tmp_path) + capsys.readouterr() + _stub_io(monkeypatch, changed=["src/a.py"], warranted=["src/a.py"]) + rc = commands.cmd_goal( + _ns( + "--repo", + str(tmp_path), + "goal", + "check", + "--id", + "g-1", + "--since", + "HEAD", + "--fail-on-uncovered", + ) + ) + out = json.loads(capsys.readouterr().out) + assert out == {"covered": ["src/a.py"], "uncovered": []} + assert rc == 0 + + +def test_cli_goal_check_missing_goal_is_usage_error(tmp_path): + rc = commands.cmd_goal( + _ns("--repo", str(tmp_path), "goal", "check", "--id", "nope", "--since", "HEAD") + ) + assert rc == 2 # EXIT_USAGE + + +def test_cli_goal_check_bad_ref_is_usage_error(tmp_path, monkeypatch): + _seed_goal(tmp_path) + + def _boom(repo, since): + raise commands.gitio.GitError("bad revision: " + since) + + monkeypatch.setattr(commands.gitio, "changed_paths", _boom) + rc = commands.cmd_goal( + _ns("--repo", str(tmp_path), "goal", "check", "--id", "g-1", "--since", "nope") + ) + assert rc == 2 # EXIT_USAGE — a bad ref is infra/usage, not a verdict diff --git a/tests/test_governance_install.py b/tests/test_governance_install.py new file mode 100644 index 0000000..a01f490 --- /dev/null +++ b/tests/test_governance_install.py @@ -0,0 +1,174 @@ +"""Tests for `dorian governance install` — scaffolds the Claude Code governance adapter +(the two host hooks + settings example + docs) via the shared build_plan/apply machinery. +""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import dorian +from dorian import cli, commands + +VETO_HOOK = ( + Path(dorian.__file__).parent + / "templates/claude_code/dorian-governance/hooks/dorian_preflight_veto.py" +) + +VETO = ".claude/hooks/dorian_preflight_veto.py" +STOP = ".claude/hooks/dorian_loop_preflight.py" +SETTINGS = ".claude/settings.dorian-governance.example.json" +README = ".claude/dorian-governance/README.md" +MODES = ".claude/dorian-governance/reference/enforcement-modes.md" + + +def _ns(*argv: str): + return cli.build_parser().parse_args(list(argv)) + + +def _install(capsys, repo, *extra: str): + rc = commands.cmd_governance( + _ns("--repo", str(repo), "--json", "governance", "install", *extra) + ) + return rc, json.loads(capsys.readouterr().out) + + +def test_install_writes_hooks_settings_and_docs(tmp_path, capsys): + rc, data = _install(capsys, tmp_path) + assert rc == 0 + for f in (VETO, STOP, SETTINGS, README, MODES): + assert (tmp_path / f).is_file(), f + assert VETO in data["created"] and STOP in data["created"] + + +def test_veto_hook_fails_closed_and_owns_exit_2(tmp_path, capsys): + _install(capsys, tmp_path) + veto = (tmp_path / VETO).read_text(encoding="utf-8") + assert "return 2" in veto # the exit-2 tool-block veto lives in the HOST hook + assert "strict" in veto # fail-closed under unattended/godmode + assert "FAIL CLOSED" in veto.upper() + + +def test_subagentstop_hook_shells_dorian_gate_and_emits_host_output(tmp_path, capsys): + _install(capsys, tmp_path) + stop = (tmp_path / STOP).read_text(encoding="utf-8") + assert "gate" in stop # reuses the pure `dorian gate` emitter + assert "hookSpecificOutput" in stop # host-side mapping lives in the hook, not in core + assert "created_at_epoch" in stop # runner stamps freshness (wall-clock lives here) + + +def test_install_idempotent_without_force(tmp_path, capsys): + _install(capsys, tmp_path) + (tmp_path / VETO).write_text("edited by user\n", encoding="utf-8") + rc, data = _install(capsys, tmp_path) + assert rc == 0 + assert VETO in data["skipped"] + assert data["created"] == [] + assert (tmp_path / VETO).read_text(encoding="utf-8") == "edited by user\n" # not clobbered + + +def test_install_force_overwrites(tmp_path, capsys): + _install(capsys, tmp_path) + (tmp_path / VETO).write_text("stale\n", encoding="utf-8") + rc, data = _install(capsys, tmp_path, "--force") + assert rc == 0 + assert VETO in data["overwritten"] + assert "return 2" in (tmp_path / VETO).read_text(encoding="utf-8") + + +def test_install_dry_run_writes_nothing(tmp_path, capsys): + rc, data = _install(capsys, tmp_path, "--dry-run") + assert rc == 0 + assert data["dry_run"] is True + assert VETO in data["created"] # planned + assert not (tmp_path / VETO).exists() # but not written + + +# --- functional veto-hook tests (execute the host hook as a subprocess) ---------------- + + +def _subprocess_cwd(tmp_path) -> str: + out = subprocess.run( + [sys.executable, "-c", "import os; print(os.getcwd())"], + cwd=tmp_path, + capture_output=True, + text=True, + ) + return out.stdout.strip() + + +def _run_veto(tmp_path, *, tool='{"tool_input": {"file_path": "src/x.py"}}', packet=None, env=None): + if packet is not None: + (tmp_path / ".dorian/local").mkdir(parents=True, exist_ok=True) + (tmp_path / ".dorian/local/last-decision.json").write_text( + json.dumps(packet), encoding="utf-8" + ) + full_env = {k: v for k, v in os.environ.items() if not k.startswith("DORIAN_")} + full_env.update(env or {}) + r = subprocess.run( + [sys.executable, str(VETO_HOOK)], + cwd=tmp_path, + input=tool, + capture_output=True, + text=True, + env=full_env, + ) + return r.returncode + + +def _fresh_packet(tmp_path, *, decision, sensitive=False, base="HEAD"): + return { + "decision": decision, + "created_at_epoch": int(time.time()), + "repo_root": _subprocess_cwd(tmp_path), # matches the veto's os.getcwd() default + "base_ref": base, + "nonce": "", + "broken_claims": [{"sensitive": sensitive}], + "human_escalation": {"reason": "needs a human"}, + } + + +def test_veto_strict_fresh_continue_allowed_with_default_identity(tmp_path): + # regression for the identity-default blocker: strict + fresh + matching CONTINUE -> allow + pkt = _fresh_packet(tmp_path, decision="continue") + rc = _run_veto(tmp_path, packet=pkt, env={"DORIAN_POLICY": "unattended", "DORIAN_BASE": "HEAD"}) + assert rc == 0 + + +def test_veto_strict_escalate_blocks(tmp_path): + pkt = _fresh_packet(tmp_path, decision="escalate") + rc = _run_veto(tmp_path, packet=pkt, env={"DORIAN_POLICY": "unattended", "DORIAN_BASE": "HEAD"}) + assert rc == 2 + + +def test_veto_strict_missing_packet_fails_closed(tmp_path): + rc = _run_veto( + tmp_path, packet=None, env={"DORIAN_POLICY": "unattended", "DORIAN_BASE": "HEAD"} + ) + assert rc == 2 + + +def test_veto_strict_stale_packet_fails_closed(tmp_path): + pkt = _fresh_packet(tmp_path, decision="continue") + pkt["created_at_epoch"] = int(time.time()) - 100_000 # well past the freshness window + rc = _run_veto(tmp_path, packet=pkt, env={"DORIAN_POLICY": "unattended", "DORIAN_BASE": "HEAD"}) + assert rc == 2 + + +def test_veto_attended_missing_packet_fails_open(tmp_path): + rc = _run_veto(tmp_path, packet=None, env={"DORIAN_POLICY": "assist"}) + assert rc == 0 # a human is present; don't trap the agent + + +def test_veto_attended_escalate_nonsensitive_allows(tmp_path): + pkt = _fresh_packet(tmp_path, decision="escalate", sensitive=False) + rc = _run_veto(tmp_path, packet=pkt, env={"DORIAN_POLICY": "assist", "DORIAN_BASE": "HEAD"}) + assert rc == 0 + + +def test_veto_escalate_on_sensitive_path_blocks_even_when_attended(tmp_path): + pkt = _fresh_packet(tmp_path, decision="escalate", sensitive=True) + rc = _run_veto(tmp_path, packet=pkt, env={"DORIAN_POLICY": "assist", "DORIAN_BASE": "HEAD"}) + assert rc == 2 diff --git a/tests/test_governance_packaging.py b/tests/test_governance_packaging.py new file mode 100644 index 0000000..41c38ed --- /dev/null +++ b/tests/test_governance_packaging.py @@ -0,0 +1,18 @@ +"""Every governance template ships as readable package data (resolves via importlib.resources), +so the adapter scaffolds correctly from an installed wheel — mirrors test_loop_packaging.py. +""" + +from dorian import claude_code, governance + + +def test_every_governance_template_is_readable_package_data(): + for t in governance.GOVERNANCE_MANIFEST: + assert claude_code._read_template(t.src), f"empty/missing governance template: {t.src}" + + +def test_manifest_covers_hooks_settings_and_docs(): + groups = {t.group for t in governance.GOVERNANCE_MANIFEST} + assert {"hook", "settings", "skill"} <= groups + dests = {t.dest for t in governance.GOVERNANCE_MANIFEST} + assert any(d.endswith("dorian_preflight_veto.py") for d in dests) + assert any(d.endswith("dorian_loop_preflight.py") for d in dests) diff --git a/tests/test_sidecars.py b/tests/test_sidecars.py new file mode 100644 index 0000000..40e7ee0 --- /dev/null +++ b/tests/test_sidecars.py @@ -0,0 +1,95 @@ +"""Tests for dorian.sidecars — safe, atomic, deterministic local sidecar writes.""" + +import json +from pathlib import Path + +import pytest + +from dorian import sidecars + + +def test_write_sidecar_roundtrip_and_deterministic(tmp_path): + p = sidecars.write_sidecar(tmp_path, ".dorian/goals/g-1.goal.json", {"b": 2, "a": 1}) + assert p == (tmp_path / ".dorian/goals/g-1.goal.json").resolve() + assert p.exists() + assert json.loads(p.read_text(encoding="utf-8")) == {"a": 1, "b": 2} + text = p.read_text(encoding="utf-8") + assert text.index('"a"') < text.index('"b"') # sorted keys on disk + assert text.endswith("\n") + + +def test_write_sidecar_deterministic_bytes(tmp_path): + # same payload, different key order -> byte-identical file (deterministic serialization) + b1 = sidecars.write_sidecar(tmp_path, "x.json", {"a": 1, "b": 2}).read_bytes() + b2 = sidecars.write_sidecar(tmp_path, "x.json", {"b": 2, "a": 1}).read_bytes() + assert b1 == b2 + + +def test_creates_parent_directories(tmp_path): + p = sidecars.write_sidecar(tmp_path, "a/b/c/deep.json", {"x": 1}) + assert p.exists() + assert p.parent == (tmp_path / "a/b/c").resolve() + + +def test_returns_final_path(tmp_path): + p = sidecars.write_sidecar(tmp_path, "x.json", {"k": "v"}) + assert isinstance(p, Path) + assert p.name == "x.json" + + +def test_no_leftover_tmp_file(tmp_path): + sidecars.write_sidecar(tmp_path, "x.json", {"k": "v"}) + assert list(tmp_path.rglob("*.tmp")) == [] + + +def test_refuses_dotdot_traversal(tmp_path): + root = tmp_path / "repo" + root.mkdir() + with pytest.raises(ValueError): + sidecars.write_sidecar(root, "../escape.json", {"x": 1}) + assert not (tmp_path / "escape.json").exists() # nothing written outside the root + + +def test_refuses_absolute_path(tmp_path): + with pytest.raises(ValueError): + sidecars.write_sidecar(tmp_path, "/etc/whatever.json", {"x": 1}) + + +def test_refuses_empty_and_dot(tmp_path): + with pytest.raises(ValueError): + sidecars.write_sidecar(tmp_path, "", {"x": 1}) + with pytest.raises(ValueError): + sidecars.write_sidecar(tmp_path, ".", {"x": 1}) + + +def test_ensure_within_accepts_inside_rejects_outside(tmp_path): + root = tmp_path / "repo" + (root / "sub").mkdir(parents=True) + assert sidecars.ensure_within(root, Path("sub/f.json")) == (root / "sub/f.json").resolve() + assert sidecars.ensure_within(root, root / "sub/f.json") == (root / "sub/f.json").resolve() + with pytest.raises(ValueError): + sidecars.ensure_within(root, tmp_path / "outside.json") # absolute outside root + with pytest.raises(ValueError): + sidecars.ensure_within(root, Path("../outside.json")) # traversal + + +def test_refuses_symlink_escape(tmp_path): + root = tmp_path / "repo" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + try: + (root / "link").symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + with pytest.raises(ValueError): + sidecars.write_sidecar(root, "link/evil.json", {"x": 1}) + assert not (outside / "evil.json").exists() + + +def test_overwrites_existing_atomically(tmp_path): + p1 = sidecars.write_sidecar(tmp_path, "x.json", {"v": 1}) + p2 = sidecars.write_sidecar(tmp_path, "x.json", {"v": 2}) + assert p1 == p2 + assert json.loads(p2.read_text(encoding="utf-8")) == {"v": 2} + assert list(tmp_path.rglob("*.tmp")) == []