Skip to content

Commit ca2a525

Browse files
fix(pro): check now enforces the doctrine it prints
An agent used the tool for real and found check exits 0 on detected drift by default. With stock defaults neither the permissive branch nor the strict branch fired, so it printed DNA DRIFT DETECTED and fell through to exit 0. A CI job running plain check passed on a drifting repository — the exact failure a fail-closed governance tool exists to prevent, and the opposite of the module's own Law 3. Fail-closed is now the default. Permissive (CHRONOLITH_MODE=permissive) is the only escape, and the message names it correctly; the old message told users to set CHRONOLITH_MODE=strict, which was already the default and changed nothing. Making the default strict exposed a second bug: report[security] collapsed any finding into danger, so check halted on the untracked sovereign keys sovereign-init creates. The tool told you to make a key and then failed you for having one. Severity is now respected — only a tracked key in the index is danger. Also: the pre-push hook wrote a bare python and a frozen absolute path, unusable under git-bash on Windows; it now calls sys.executable -m. And the status panel printed Status: Status: OK above the halt check, announcing OK on a halting run; the verdict is computed first and the word matches the exit code. Regression tests cover fail-closed default, --accept, permissive, the hook, and the panel.
1 parent d67bea5 commit ca2a525

3 files changed

Lines changed: 105 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
All notable changes to the Chronolith ecosystem will be documented in this file.
44

5+
## [Unreleased]
6+
### The tool did not enforce its own doctrine
7+
- **`check` now fails closed by default.** With stock defaults — not permissive,
8+
no `--strict` — the old exit logic printed the drift and then fell through to
9+
exit 0. A CI job running plain `chronolith-pro check` passed on a drifting
10+
repository, the exact failure the tool exists to catch and the opposite of its
11+
own Law 3. Safety is now opt-out: `CHRONOLITH_MODE=permissive` is the only way
12+
past an inconsistency. `--strict` is a deprecated no-op. Found by an agent
13+
running the tool for real, not by reading it.
14+
- **Severity is respected.** A finding of any kind was collapsed into a security
15+
failure, so `check` halted on the very sovereign keys `sovereign-init` creates
16+
— it told you to make a key, then condemned you for having it. Only a
17+
danger-level finding (a tracked key in the index) now fails the run; an
18+
untracked local key is a warning.
19+
- **The pre-push hook is portable.** It wrote a bare `python` and a hardcoded
20+
absolute site-packages path; under git-bash on Windows `python` is often not
21+
on PATH, so the hook died with "command not found" and blocked every push
22+
regardless of DNA state. It now invokes `sys.executable -m` the module.
23+
- **The status panel cannot lie.** It printed a hardcoded `Status: Status: OK`
24+
above the fail-closed check, announcing OK on a run about to halt. The verdict
25+
is computed first; the panel says OK or INCONSISTENT to match the exit code.
26+
- Regression tests cover all of the above, including the reproduction that
27+
exposed the exit-0 bug.
28+
29+
530
## [3.2.1] - 2026-07-18
631
### Commands that never ran, and checks that never passed
732
- **`chain` worked for the first time.** It rendered the transparency chain

chronolith-pro/chronolith_pro/chronolith/run_chronolith_cycle.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,20 @@ def init(
228228
if not no_hook:
229229
hook_path = root / ".git" / "hooks" / "pre-push"
230230
hook_path.parent.mkdir(parents=True, exist_ok=True)
231-
hook_content = f"#!/bin/sh\n# Chronolith Pro Evolution Hook\necho '[*] Guarding Pro DNA...'\npython \"{Path(__file__).resolve()}\" check --strict || exit 1\n"
231+
# Invoke through this interpreter, as a module. The old hook wrote a
232+
# bare `python` and a hardcoded absolute site-packages path: under
233+
# git-bash on Windows `python` is often not on PATH, so the hook died
234+
# with "command not found" and blocked every push regardless of DNA
235+
# state — fail-closed by accident, and unusable. sys.executable is the
236+
# interpreter that installed the package; -m avoids the frozen path.
237+
# `--strict` is dropped: check fails closed by default now.
238+
py = Path(sys.executable).as_posix()
239+
hook_content = (
240+
"#!/bin/sh\n"
241+
"# Chronolith Pro Evolution Hook\n"
242+
"echo '[*] Guarding Pro DNA...'\n"
243+
f'"{py}" -m chronolith_pro.chronolith.run_chronolith_cycle check || exit 1\n'
244+
)
232245
hook_path.write_text(hook_content, encoding="utf-8")
233246
if os.name != "nt": os.chmod(hook_path, 0o755)
234247
console.log(f"[bold green][✔][/bold green] Pro Push Hook installed.")
@@ -237,7 +250,7 @@ def init(
237250
@app.command()
238251
def check(
239252
repo_root: Path = typer.Option(".", "--repo-root", help="Project root directory."),
240-
strict: bool = typer.Option(False, "--strict", help="Fail with exit code 1 if drift detected."),
253+
strict: bool = typer.Option(False, "--strict", help="Deprecated no-op: check now fails closed by default. Set CHRONOLITH_MODE=permissive to continue past inconsistencies instead."),
241254
scan_source: bool = typer.Option(True, "--scan-source/--no-scan-source", help="Scan source code files alongside documentation."),
242255
accept: bool = typer.Option(False, "--accept", help="Accept the current content as the new canonical baseline (like `git commit`): advances the signed baseline and appends a transparency-chain entry even though the root changed. Without this, an intentional edit is reported as drift and the baseline is NOT advanced."),
243256
):
@@ -371,7 +384,14 @@ def check(
371384
"doc_files": len(doc_files),
372385
"source_files": len(source_files),
373386
"doc_parity": doc_parity["status"],
374-
"security": "ok" if not secret_scan["findings"] else "danger",
387+
# Respect severity. The scanner separates a tracked key in the index
388+
# (danger — an actual leak) from an untracked local key (warning — where
389+
# keys are supposed to live). Collapsing both into "danger" made the
390+
# tool halt on the very sovereign keys `sovereign-init` creates: it
391+
# tells you to make a key, then condemns you for having it. Only a
392+
# danger-level finding is a security failure.
393+
"security": "danger" if any(f.get("status") == "danger" for f in secret_scan["findings"])
394+
else ("warning" if secret_scan["findings"] else "ok"),
375395
"findings": len(secret_scan["findings"]),
376396
"dna_drift": dna_drift,
377397
"signature_tampered": signature_tampered,
@@ -386,27 +406,41 @@ def check(
386406
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
387407
logger.info("Cycle complete", extra={"merkle_root": merkle_root})
388408

409+
# Compute the verdict BEFORE printing the status line, so the panel cannot
410+
# say OK on a run that is about to halt. The old panel printed a hardcoded
411+
# "Status: OK" (doubled label and all) above the fail-closed check, so a
412+
# halting run announced OK and then aborted.
413+
has_issues = report["doc_parity"] != "ok" or report["security"] == "danger" or dna_drift or signature_tampered or not chain_ok
414+
status_word = "INCONSISTENT" if has_issues else "OK"
415+
status_colour = "red" if has_issues else "green"
416+
389417
console.print(Panel(
390-
f"[bold magenta]Pro Status:[/bold magenta] "
391-
f"Status: OK | "
418+
f"[bold {status_colour}]Pro Status: {status_word}[/bold {status_colour}] | "
392419
f"Merkle: `{merkle_root[:16]}...` | "
393420
f"Nucleotides: {len(all_nucleotides)} "
394421
f"({len(doc_files)} docs + {len(source_files)} source) | "
395422
f"Entropy: {total_entropy:.2f}",
396423
title="Solemne Guardian", expand=False
397424
))
398-
425+
399426
if report["security"] == "danger":
400427
console.print(f"[bold red][!][/bold red] SECURITY ALERT: {len(secret_scan['findings'])} secrets detected in lineage!")
401428
for finding in secret_scan["findings"]:
402429
console.print(f" [red]{finding['type']}[/red] in [italic]{finding['file']}[/italic]")
403-
404-
# v3.0.3: Permissive mode — warn but do not halt
405-
has_issues = report["doc_parity"] != "ok" or report["security"] == "danger" or dna_drift or signature_tampered or not chain_ok
430+
431+
# Fail closed by default. The module's own doctrine (Law 3) is "any
432+
# inconsistency halts the pipeline with exit 1", and the earlier code did
433+
# the opposite: with stock defaults — not permissive, no --strict — this
434+
# branch printed the drift and then fell through to exit 0. A CI job
435+
# running plain `check` passed on a drifting repository, which is the exact
436+
# failure a governance tool exists to prevent.
437+
#
438+
# Safety is now opt-out, never opt-in: the only way to continue past an
439+
# inconsistency is to ask for it explicitly with CHRONOLITH_MODE=permissive.
406440
if has_issues:
407441
if PERMISSIVE_MODE:
408-
console.print("[bold yellow][!][/bold yellow] PERMISSIVE MODE: Drift detected but pipeline continues. Set CHRONOLITH_MODE=strict for fail-closed.")
409-
elif strict:
442+
console.print("[bold yellow][!][/bold yellow] PERMISSIVE MODE: inconsistency detected, pipeline continues (CHRONOLITH_MODE=permissive). Unset it for fail-closed.")
443+
else:
410444
console.print("[bold red][!][/bold red] FAIL-CLOSED: Project state inconsistent. Halting.")
411445
raise typer.Exit(code=1)
412446

chronolith-pro/tests/test_verify.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,41 @@ def _fingerprint(self) -> str:
6767
_p, pub = ac.load_sovereign_keys(self.root)
6868
return sv.key_fingerprint(pub)
6969

70+
def test_check_fails_closed_on_drift_by_default(self):
71+
"""Plain `check` must exit 1 when a governed file changed.
72+
73+
The previous logic only halted under --strict or refused to halt under
74+
permissive; with stock defaults it printed the drift and exited 0. A CI
75+
job running plain `check` passed on a drifting repository — the exact
76+
failure this tool exists to catch, and the opposite of its own Law 3.
77+
78+
Found by an agent running the tool for real, not by reading it.
79+
"""
80+
(self.root / "DOC.md").write_text("# Canonical\nan unaccepted edit\n", encoding="utf-8")
81+
result = self._run("check", "--no-scan-source")
82+
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
83+
self.assertIn("FAIL-CLOSED", result.stdout)
84+
85+
def test_check_accept_advances_baseline_and_exits_zero(self):
86+
"""--accept is the sanctioned way to record an intentional edit."""
87+
(self.root / "DOC.md").write_text("# Canonical\nan intentional edit\n", encoding="utf-8")
88+
accepted = self._run("check", "--no-scan-source", "--accept")
89+
self.assertEqual(accepted.returncode, 0, accepted.stdout + accepted.stderr)
90+
# And the baseline actually moved: a plain check is clean afterward.
91+
after = self._run("check", "--no-scan-source")
92+
self.assertEqual(after.returncode, 0, after.stdout + after.stderr)
93+
94+
def test_permissive_mode_is_the_only_escape_hatch(self):
95+
"""CHRONOLITH_MODE=permissive continues past drift; nothing else does."""
96+
import os
97+
(self.root / "DOC.md").write_text("# Canonical\ndrift\n", encoding="utf-8")
98+
env = dict(os.environ, CHRONOLITH_MODE="permissive")
99+
result = subprocess.run(
100+
[sys.executable, str(SCRIPT), "check", "--no-scan-source", "--repo-root", str(self.root)],
101+
capture_output=True, text=True, encoding="utf-8", errors="replace", env=env,
102+
)
103+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
104+
70105
def test_chain_command_runs(self):
71106
"""`chain` renders the transparency chain without crashing.
72107

0 commit comments

Comments
 (0)