@ictechgy/context-guard 0.4.15 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.ko.md +128 -2
  3. package/README.md +144 -3
  4. package/docs/distribution.md +100 -0
  5. package/package.json +4 -1
  6. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  7. package/plugins/context-guard/README.ko.md +43 -1
  8. package/plugins/context-guard/README.md +44 -1
  9. package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
  10. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  11. package/plugins/context-guard/bin/context-guard-audit +169 -66
  12. package/plugins/context-guard/bin/context-guard-bench +9865 -211
  13. package/plugins/context-guard/bin/context-guard-compress +90 -8
  14. package/plugins/context-guard/bin/context-guard-diet +1 -7
  15. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  16. package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
  17. package/plugins/context-guard/bin/context-guard-guard-read +496 -57
  18. package/plugins/context-guard/bin/context-guard-mcp +2 -1
  19. package/plugins/context-guard/bin/context-guard-pack +1570 -150
  20. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  21. package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
  22. package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
  23. package/plugins/context-guard/bin/context-guard-setup +1944 -222
  24. package/plugins/context-guard/bin/context-guard-statusline +163 -55
  25. package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
  26. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  27. package/plugins/context-guard/bin/context-guard-trim-output +795 -48
  28. package/plugins/context-guard/brief/README.md +19 -0
  29. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  30. package/plugins/context-guard/lib/context_guard_commands.py +10 -2
  31. package/plugins/context-guard/lib/credential_policy.py +185 -0
  32. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -10,8 +10,10 @@ from __future__ import annotations
10
10
  import argparse
11
11
  import copy
12
12
  import datetime as _dt
13
+ import hashlib
13
14
  import json
14
15
  import os
16
+ import pwd
15
17
  import re
16
18
  import selectors
17
19
  import shlex
@@ -21,6 +23,7 @@ import stat
21
23
  import subprocess
22
24
  import sys
23
25
  import time
26
+ import types
24
27
  import uuid
25
28
  from dataclasses import dataclass
26
29
  from pathlib import Path
@@ -46,8 +49,6 @@ RECOMMENDED_DENIES = [
46
49
  "Read(./vendor/**)",
47
50
  "Read(./.context-guard/**)",
48
51
  "Read(./.claude-token-optimizer/**)",
49
- "Read(./.env)",
50
- "Read(./.env.*)",
51
52
  "Read(./.npmrc)",
52
53
  "Read(./.pypirc)",
53
54
  "Read(./.netrc)",
@@ -57,11 +58,26 @@ RECOMMENDED_DENIES = [
57
58
  "Read(~/.kube/**)",
58
59
  "Read(~/.docker/**)",
59
60
  ]
61
+ PRODUCT_OWNED_ENV_READ_DENIES = frozenset({
62
+ "Read(./.env)",
63
+ "Read(./.env.*)",
64
+ })
60
65
  HELPER_STATUSLINE = "context-guard-statusline-merged"
66
+ HELPER_STATUSLINE_PLAIN = "context-guard-statusline"
61
67
  HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
62
68
  HELPER_GUARD_READ = "context-guard-guard-read"
63
69
  HELPER_FAILED_NUDGE = "context-guard-failed-nudge"
64
70
  HELPER_DIET = "context-guard-diet"
71
+ ROOT_PACKAGE_NAME = "@ictechgy/context-guard"
72
+ RECEIPT_PACKAGE_NAME = "@ictechgy/context-guard-receipt"
73
+ _BASH_REFERENCE_UNAVAILABLE = (
74
+ "bash_reference_v1 reference unavailable"
75
+ )
76
+ _BASH_REFERENCE_RECOVERY = (
77
+ "repair or reinstall the exact paired npm packages in the target project, "
78
+ "ensure a trusted system Node interpreter is available, then rerun setup"
79
+ )
80
+ BASH_REFERENCE_POLICY_MAX_BYTES = 512 * 1024
65
81
  HELPER_EQUIVALENT_BASENAMES = {
66
82
  "context-guard-rewrite-bash": {
67
83
  "context-guard-rewrite-bash",
@@ -96,6 +112,33 @@ DEFAULT_POST_SETUP_SCAN_TOP = 5
96
112
  POST_SETUP_SCAN_TIMEOUT_SECONDS = 20
97
113
  PATH_HELPER_PROBE_TIMEOUT_SECONDS = 5
98
114
  PATH_HELPER_PROBE_MAX_OUTPUT_BYTES = 4096
115
+ ISOLATED_RUNTIME_PATH = os.defpath
116
+ READ_GUARD_BEHAVIOR_ENV = (
117
+ "CONTEXT_GUARD_READ_GUARD",
118
+ "CLAUDE_TOKEN_READ_GUARD",
119
+ "CONTEXT_GUARD_READ_GUARD_MAX_BYTES",
120
+ "CLAUDE_TOKEN_READ_GUARD_MAX_BYTES",
121
+ "CONTEXT_GUARD_READ_GUARD_MAX_LINES",
122
+ "CLAUDE_TOKEN_READ_GUARD_MAX_LINES",
123
+ "CONTEXT_GUARD_READ_GUARD_PROOF_BYTES",
124
+ "CLAUDE_TOKEN_READ_GUARD_PROOF_BYTES",
125
+ )
126
+ REWRITE_BEHAVIOR_ENV = (
127
+ "CONTEXT_GUARD_SANITIZER_FAIL_OPEN",
128
+ "CLAUDE_TOKEN_SANITIZER_FAIL_OPEN",
129
+ )
130
+ STATUSLINE_BEHAVIOR_ENV = (
131
+ "CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES",
132
+ "CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES",
133
+ "CONTEXT_GUARD_STATUSLINE_CTX_WARN",
134
+ "CLAUDE_TOKEN_STATUSLINE_CTX_WARN",
135
+ "CONTEXT_GUARD_STATUSLINE_CACHE_TTL_SECONDS",
136
+ )
137
+ HOMEBREW_NODE_CANDIDATES = (
138
+ Path("/opt/homebrew/bin/node"),
139
+ Path("/usr/local/bin/node"),
140
+ )
141
+ BEHAVIOR_ENV_VALUE_RE = re.compile(r"^[A-Za-z0-9.+_-]{1,64}$")
99
142
  PRIVATE_DIR_MODE = stat.S_IRWXU
100
143
  ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
101
144
  "tmp": Path("/private/tmp"),
@@ -108,6 +151,8 @@ class Choices:
108
151
  denies: bool = True
109
152
  statusline: bool = True
110
153
  bash_hook: bool = True
154
+ # Provider-visible receipt handles are an explicit, default-off choice.
155
+ bash_reference_v1: bool = False
111
156
  read_guard: bool = True
112
157
  model_defaults: bool = True
113
158
  # 동일 Bash 명령이 두 번 연속 실패하면 /clear 권유 — recommended setup 기본 ON.
@@ -165,14 +210,19 @@ class SetupResult:
165
210
  # - native-skill / report-only agents are never written to; they are reported.
166
211
  # It never sends work to external providers and never promises token/cost savings.
167
212
 
168
- ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
169
- ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
213
+ LEGACY_ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
214
+ LEGACY_ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
215
+ ADAPTER_RULE_BLOCK_BEGIN = "<!-- BEGIN context-guard:repo-rules version=1 -->"
216
+ ADAPTER_RULE_BLOCK_END = "<!-- END context-guard:repo-rules -->"
170
217
  CODEX_SKILL_REL = ".agents/skills/context-guard/SKILL.md"
171
- CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
172
- CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
218
+ LEGACY_CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
219
+ LEGACY_CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
220
+ CODEX_SKILL_MARKER_BEGIN = "<!-- BEGIN context-guard:codex-skill version=1 -->"
221
+ CODEX_SKILL_MARKER_END = "<!-- END context-guard:codex-skill -->"
173
222
  BRIEF_MODE_LEVELS = ("lite", "standard", "ultra")
174
223
  BRIEF_MODE_OFF = "off"
175
224
  BRIEF_MODE_CHOICES = (*BRIEF_MODE_LEVELS, BRIEF_MODE_OFF)
225
+ NARRATION_MODE_CHOICES = ("quiet", "default")
176
226
  BRIEF_MODE_BLOCK_END = "<!-- END context-guard:brief-mode -->"
177
227
  BRIEF_MODE_BEGIN_RE = re.compile(
178
228
  r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
@@ -186,6 +236,214 @@ BRIEF_MODE_BLOCK_RE = re.compile(
186
236
  re.DOTALL,
187
237
  )
188
238
 
239
+ LEGACY_REPO_RULE_MARKER_BEGIN = LEGACY_ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
240
+ LEGACY_REPO_RULE_MARKER_END = LEGACY_ADAPTER_RULE_BLOCK_END.encode("ascii")
241
+ REPO_RULE_MARKER_V1_BEGIN = ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
242
+ REPO_RULE_MARKER_V1_END = ADAPTER_RULE_BLOCK_END.encode("ascii")
243
+ LEGACY_CODEX_SKILL_MARKER_V0_BEGIN = LEGACY_CODEX_SKILL_MARKER_BEGIN.encode("ascii")
244
+ LEGACY_CODEX_SKILL_MARKER_V0_END = LEGACY_CODEX_SKILL_MARKER_END.encode("ascii")
245
+ CODEX_SKILL_MARKER_V1_BEGIN = CODEX_SKILL_MARKER_BEGIN.encode("ascii")
246
+ CODEX_SKILL_MARKER_V1_END = CODEX_SKILL_MARKER_END.encode("ascii")
247
+ BRIEF_MODE_MARKER_END = BRIEF_MODE_BLOCK_END.encode("ascii")
248
+ NARRATION_MODE_MARKER_BEGIN = b"<!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->"
249
+ NARRATION_MODE_MARKER_END = b"<!-- END context-guard:narration-mode -->"
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class ManagedMarker:
254
+ kind: str
255
+ version: int
256
+ begin: bytes
257
+ end: bytes
258
+ variant: str | None = None
259
+
260
+
261
+ MANAGED_MARKERS = (
262
+ ManagedMarker(
263
+ "repo-rules",
264
+ 0,
265
+ LEGACY_REPO_RULE_MARKER_BEGIN,
266
+ LEGACY_REPO_RULE_MARKER_END,
267
+ "legacy",
268
+ ),
269
+ ManagedMarker(
270
+ "repo-rules",
271
+ 1,
272
+ REPO_RULE_MARKER_V1_BEGIN,
273
+ REPO_RULE_MARKER_V1_END,
274
+ ),
275
+ ManagedMarker(
276
+ "codex-skill",
277
+ 0,
278
+ LEGACY_CODEX_SKILL_MARKER_V0_BEGIN,
279
+ LEGACY_CODEX_SKILL_MARKER_V0_END,
280
+ "legacy",
281
+ ),
282
+ ManagedMarker(
283
+ "codex-skill",
284
+ 1,
285
+ CODEX_SKILL_MARKER_V1_BEGIN,
286
+ CODEX_SKILL_MARKER_V1_END,
287
+ ),
288
+ *(
289
+ ManagedMarker(
290
+ "brief-mode",
291
+ 1,
292
+ f"<!-- BEGIN context-guard:brief-mode level={level} version=1 -->".encode("ascii"),
293
+ BRIEF_MODE_MARKER_END,
294
+ level,
295
+ )
296
+ for level in BRIEF_MODE_LEVELS
297
+ ),
298
+ ManagedMarker(
299
+ "narration-mode",
300
+ 1,
301
+ NARRATION_MODE_MARKER_BEGIN,
302
+ NARRATION_MODE_MARKER_END,
303
+ "quiet",
304
+ ),
305
+ )
306
+ _MANAGED_BEGIN_MARKERS = {marker.begin: marker for marker in MANAGED_MARKERS}
307
+ _MANAGED_END_MARKERS: dict[bytes, tuple[ManagedMarker, ...]] = {}
308
+ for _marker in MANAGED_MARKERS:
309
+ _MANAGED_END_MARKERS[_marker.end] = (*_MANAGED_END_MARKERS.get(_marker.end, ()), _marker)
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class ManagedSpan:
314
+ kind: str
315
+ version: int
316
+ variant: str | None
317
+ start: int
318
+ end: int
319
+
320
+
321
+ @dataclass(frozen=True)
322
+ class ManagedParseResult:
323
+ status: str
324
+ spans: tuple[ManagedSpan, ...] = ()
325
+ reason: str | None = None
326
+
327
+
328
+ @dataclass(frozen=True)
329
+ class ManagedFileSnapshot:
330
+ data: bytes | None
331
+ metadata: tuple[int, int, int, int, int] | None
332
+
333
+
334
+ class ManagedFileConflictError(OSError):
335
+ """Raised when a managed target no longer matches its planned snapshot."""
336
+
337
+
338
+ def _iter_binary_lines(data: bytes):
339
+ offset = 0
340
+ while offset < len(data):
341
+ newline = data.find(b"\n", offset)
342
+ if newline < 0:
343
+ yield offset, len(data), data[offset:], b""
344
+ return
345
+ end = newline + 1
346
+ if newline > offset and data[newline - 1 : newline] == b"\r":
347
+ content, ending = data[offset : newline - 1], b"\r\n"
348
+ else:
349
+ content, ending = data[offset:newline], b"\n"
350
+ yield offset, end, content, ending
351
+ offset = end
352
+
353
+
354
+ def _fence_open(content: bytes) -> tuple[int, int] | None:
355
+ match = re.match(rb"^ {0,3}(`{3,}|~{3,}).*$", content)
356
+ if not match:
357
+ return None
358
+ run = match.group(1)
359
+ return run[0], len(run)
360
+
361
+
362
+ def _fence_close(content: bytes, fence: tuple[int, int]) -> bool:
363
+ char, minimum = fence
364
+ match = re.match(rb"^ {0,3}([`~]+) *$", content)
365
+ if not match:
366
+ return False
367
+ run = match.group(1)
368
+ return bool(run and run[0] == char and len(run) >= minimum and all(byte == char for byte in run))
369
+
370
+
371
+ def _looks_like_contextguard_marker(content: bytes) -> bool:
372
+ lowered = content.lstrip(b" \t").lower()
373
+ return (
374
+ lowered.startswith(b"<!--")
375
+ and b"-->" in lowered
376
+ and (b"contextguard:" in lowered or b"context-guard:" in lowered)
377
+ )
378
+
379
+
380
+ def _scan_managed_spans(data: bytes) -> ManagedParseResult:
381
+ spans: list[ManagedSpan] = []
382
+ open_marker: tuple[ManagedMarker, int] | None = None
383
+ fence: tuple[int, int] | None = None
384
+ unsupported = False
385
+ malformed = False
386
+ for start, end, content, ending in _iter_binary_lines(data):
387
+ if fence is not None:
388
+ if _fence_close(content, fence):
389
+ fence = None
390
+ continue
391
+ opener = _fence_open(content)
392
+ if opener is not None:
393
+ fence = opener
394
+ continue
395
+ marker = _MANAGED_BEGIN_MARKERS.get(content) if ending else None
396
+ end_markers = _MANAGED_END_MARKERS.get(content, ()) if ending else ()
397
+ if marker is not None:
398
+ if open_marker is not None:
399
+ malformed = True
400
+ else:
401
+ open_marker = (marker, start)
402
+ continue
403
+ if end_markers:
404
+ if open_marker is None:
405
+ malformed = True
406
+ continue
407
+ active, span_start = open_marker
408
+ if active.end != content:
409
+ malformed = True
410
+ open_marker = None
411
+ continue
412
+ spans.append(
413
+ ManagedSpan(
414
+ kind=active.kind,
415
+ version=active.version,
416
+ variant=active.variant,
417
+ start=span_start,
418
+ end=end,
419
+ )
420
+ )
421
+ open_marker = None
422
+ continue
423
+ if _looks_like_contextguard_marker(content):
424
+ unsupported = True
425
+ if open_marker is not None:
426
+ malformed = True
427
+ if malformed:
428
+ return ManagedParseResult("malformed", tuple(spans), "malformed managed marker structure")
429
+ if unsupported:
430
+ return ManagedParseResult("unsupported", tuple(spans), "unsupported managed marker literal")
431
+ return ManagedParseResult("valid" if spans else "absent", tuple(spans))
432
+
433
+
434
+ def parse_managed_bytes(data: bytes, *, kind: str | None = None) -> ManagedParseResult:
435
+ """Classify exact ContextGuard-managed raw-byte spans without decoding user bytes."""
436
+ scanned = _scan_managed_spans(data)
437
+ if scanned.status in {"malformed", "unsupported"}:
438
+ return scanned
439
+ spans = tuple(span for span in scanned.spans if kind is None or span.kind == kind)
440
+ by_kind: dict[str, int] = {}
441
+ for span in spans:
442
+ by_kind[span.kind] = by_kind.get(span.kind, 0) + 1
443
+ if any(count > 1 for count in by_kind.values()) or (kind is None and len(spans) > 1):
444
+ return ManagedParseResult("ambiguous", spans, "multiple managed spans")
445
+ return ManagedParseResult("valid" if spans else "absent", spans)
446
+
189
447
 
190
448
  class CapabilityClass:
191
449
  """How ContextGuard can integrate with a given agent."""
@@ -375,15 +633,14 @@ def render_repo_rule_block() -> str:
375
633
  ])
376
634
 
377
635
 
378
- def render_codex_skill() -> str:
379
- """Render the optional project-local Codex skill for ContextGuard."""
636
+ def _render_codex_skill_with_markers(begin: str, end: str) -> str:
380
637
  return "\n".join([
381
638
  "---",
382
639
  "name: context-guard",
383
640
  "description: Use ContextGuard helpers to keep Codex context focused with local-first setup, audit, trimming, and artifact commands.",
384
641
  "---",
385
642
  "",
386
- CODEX_SKILL_MARKER_BEGIN,
643
+ begin,
387
644
  "# ContextGuard for Codex",
388
645
  "",
389
646
  "Use this skill when a task would otherwise paste large files, long logs, or repeated setup context into Codex.",
@@ -400,11 +657,38 @@ def render_codex_skill() -> str:
400
657
  "- If `context-guard` is not on PATH, install it explicitly or run via `npx @ictechgy/context-guard`.",
401
658
  "",
402
659
  "Do not claim fixed token or cost savings from these helpers; treat byte reductions as local proxy evidence only.",
403
- CODEX_SKILL_MARKER_END,
660
+ end,
404
661
  "",
405
662
  ])
406
663
 
407
664
 
665
+ def render_codex_skill() -> str:
666
+ """Render the v1 project-local Codex skill."""
667
+ return _render_codex_skill_with_markers(CODEX_SKILL_MARKER_BEGIN, CODEX_SKILL_MARKER_END)
668
+
669
+
670
+ def render_legacy_codex_skill_v0() -> str:
671
+ """Render the exact legacy whole-file image released before managed-span v1."""
672
+ return _render_codex_skill_with_markers(
673
+ LEGACY_CODEX_SKILL_MARKER_BEGIN,
674
+ LEGACY_CODEX_SKILL_MARKER_END,
675
+ )
676
+
677
+
678
+ LEGACY_CODEX_SKILL_SHA256_ALLOWLIST = {
679
+ hashlib.sha256(render_legacy_codex_skill_v0().encode("utf-8")).hexdigest(): "legacy-v0-current",
680
+ }
681
+
682
+
683
+ def render_codex_skill_block_bytes() -> bytes:
684
+ rendered = render_codex_skill().encode("utf-8")
685
+ start = rendered.index(CODEX_SKILL_MARKER_V1_BEGIN)
686
+ end = rendered.index(CODEX_SKILL_MARKER_V1_END, start) + len(CODEX_SKILL_MARKER_V1_END)
687
+ if rendered[end : end + 1] == b"\n":
688
+ end += 1
689
+ return rendered[start:end]
690
+
691
+
408
692
  def _brief_mode_source_candidates(level: str) -> list[Path]:
409
693
  """Return deterministic source candidates for packaged/repo brief snippets."""
410
694
  filename = f"brief-mode.{level}.md"
@@ -476,6 +760,39 @@ def render_brief_mode_block(level: str) -> str:
476
760
  return render_fallback_brief_mode_block(level)
477
761
 
478
762
 
763
+ def render_quiet_narration_block() -> str:
764
+ """Render embedded canonical bytes without opening any non-target file."""
765
+ return "\n".join([
766
+ NARRATION_MODE_MARKER_BEGIN.decode("ascii"),
767
+ "## ContextGuard quiet narration (advisory)",
768
+ "",
769
+ "Best effort: reduce only discretionary intermediate narration. Skip routine preambles,",
770
+ "per-tool narration, filler, and repeated interim summaries when they add no useful",
771
+ "information.",
772
+ "",
773
+ "Always preserve required user-facing communication:",
774
+ "",
775
+ "- user approvals and decisions;",
776
+ "- blockers and failures;",
777
+ "- destructive-risk and security warnings;",
778
+ "- progress required by higher-priority instructions;",
779
+ "- the final result;",
780
+ "- changed files; and",
781
+ "- verification evidence.",
782
+ "",
783
+ "This mode does not require a shorter final answer and does not change reasoning effort.",
784
+ "It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,",
785
+ "and no numeric savings should be claimed without matched provider evidence.",
786
+ NARRATION_MODE_MARKER_END.decode("ascii"),
787
+ ])
788
+
789
+
790
+ def _append_narration_block_bytes(existing: bytes, block: bytes) -> bytes:
791
+ """Append one deterministic separator that default-mode removes with the span."""
792
+ block = block.rstrip(b"\r\n") + b"\n"
793
+ return block if not existing else existing + b"\n" + block
794
+
795
+
479
796
  def _brief_mode_levels_in_text(text: str) -> list[str]:
480
797
  return [match.group("level") for match in BRIEF_MODE_BLOCK_RE.finditer(text)]
481
798
 
@@ -494,37 +811,107 @@ def _append_managed_block(existing: str, block: str) -> str:
494
811
  return block + "\n"
495
812
 
496
813
 
497
- def compose_rule_file_text(
498
- existing: str | None,
814
+ def _managed_block_bytes(block: str) -> bytes:
815
+ return block.encode("utf-8").rstrip(b"\r\n") + b"\n"
816
+
817
+
818
+ def _append_managed_block_bytes(existing: bytes, block: bytes) -> bytes:
819
+ block = block.rstrip(b"\r\n") + b"\n"
820
+ if not existing:
821
+ return block
822
+ if existing.endswith(b"\n\n"):
823
+ separator = b""
824
+ elif existing.endswith(b"\n"):
825
+ separator = b"\n"
826
+ else:
827
+ separator = b"\n\n"
828
+ return existing + separator + block
829
+
830
+
831
+ def _managed_span_for_kind(data: bytes, kind: str) -> ManagedSpan | None:
832
+ parsed = parse_managed_bytes(data, kind=kind)
833
+ if parsed.status == "absent":
834
+ return None
835
+ if parsed.status != "valid":
836
+ raise ValueError(parsed.reason or f"{parsed.status} managed {kind} markers")
837
+ return parsed.spans[0]
838
+
839
+
840
+ def _replace_managed_span(data: bytes, span: ManagedSpan, block: bytes) -> bytes:
841
+ return data[: span.start] + block.rstrip(b"\r\n") + b"\n" + data[span.end :]
842
+
843
+
844
+ def _brief_mode_levels_in_bytes(data: bytes) -> list[str]:
845
+ parsed = _scan_managed_spans(data)
846
+ return [
847
+ str(span.variant)
848
+ for span in parsed.spans
849
+ if span.kind == "brief-mode" and span.variant in BRIEF_MODE_LEVELS
850
+ ]
851
+
852
+
853
+ def compose_rule_file_bytes(
854
+ existing: bytes | None,
499
855
  *,
500
856
  with_init: bool,
501
857
  brief_mode: str | None,
502
- ) -> tuple[str, dict[str, Any]]:
503
- """Compose final repo rule text for combined init and brief-mode mutations."""
504
- text = existing or ""
505
- original_text = text
506
- existing_brief_levels = _brief_mode_levels_in_text(text)
858
+ ) -> tuple[bytes, dict[str, Any]]:
859
+ """Compose rule-file mutations from exact owned spans, preserving all other bytes."""
860
+ data = existing or b""
861
+ original = data
862
+ before_brief = _brief_mode_levels_in_bytes(data)
507
863
  meta: dict[str, Any] = {
508
864
  "init_changed": False,
509
- "init_present_before": ADAPTER_RULE_BLOCK_BEGIN in text,
510
- "brief_levels_before": existing_brief_levels,
865
+ "init_present_before": False,
866
+ "init_migrated_legacy": False,
867
+ "brief_levels_before": before_brief,
511
868
  "brief_changed": False,
512
869
  }
513
- if with_init and ADAPTER_RULE_BLOCK_BEGIN not in text:
514
- text = _append_managed_block(text, render_repo_rule_block())
515
- meta["init_changed"] = True
870
+ repo_span = _managed_span_for_kind(data, "repo-rules")
871
+ meta["init_present_before"] = repo_span is not None
872
+ if with_init:
873
+ block = _managed_block_bytes(render_repo_rule_block())
874
+ if repo_span is None:
875
+ data = _append_managed_block_bytes(data, block)
876
+ meta["init_changed"] = True
877
+ elif data[repo_span.start : repo_span.end] != block:
878
+ data = _replace_managed_span(data, repo_span, block)
879
+ meta["init_changed"] = True
880
+ meta["init_migrated_legacy"] = repo_span.version == 0
881
+
516
882
  if brief_mode:
517
- stripped, removed_levels = _remove_brief_mode_blocks(text)
883
+ span = _managed_span_for_kind(data, "brief-mode")
884
+ removed = [str(span.variant)] if span is not None and span.variant else []
885
+ meta["brief_levels_removed"] = removed
518
886
  if brief_mode == BRIEF_MODE_OFF:
519
- text = stripped
520
- meta["brief_changed"] = bool(removed_levels)
887
+ if span is not None:
888
+ data = data[: span.start] + data[span.end :]
889
+ meta["brief_changed"] = True
521
890
  else:
522
- block = render_brief_mode_block(brief_mode)
523
- text = _append_managed_block(stripped, block)
524
- meta["brief_changed"] = removed_levels != [brief_mode] or text != original_text
525
- meta["brief_levels_removed"] = removed_levels
526
- meta["changed"] = text != original_text
527
- return text, meta
891
+ block = _managed_block_bytes(render_brief_mode_block(brief_mode))
892
+ if span is None:
893
+ data = _append_managed_block_bytes(data, block)
894
+ meta["brief_changed"] = True
895
+ elif data[span.start : span.end] != block:
896
+ data = _replace_managed_span(data, span, block)
897
+ meta["brief_changed"] = True
898
+ meta["changed"] = data != original
899
+ return data, meta
900
+
901
+
902
+ def compose_rule_file_text(
903
+ existing: str | None,
904
+ *,
905
+ with_init: bool,
906
+ brief_mode: str | None,
907
+ ) -> tuple[str, dict[str, Any]]:
908
+ """Compatibility text wrapper around the byte-exact managed composer."""
909
+ rendered, meta = compose_rule_file_bytes(
910
+ existing.encode("utf-8") if existing is not None else None,
911
+ with_init=with_init,
912
+ brief_mode=brief_mode,
913
+ )
914
+ return rendered.decode("utf-8"), meta
528
915
 
529
916
 
530
917
  def plan_or_write_rule_file_blocks(
@@ -534,7 +921,7 @@ def plan_or_write_rule_file_blocks(
534
921
  brief_mode: str | None,
535
922
  applied: bool,
536
923
  ) -> dict[str, Any]:
537
- """Plan or apply managed rule-file blocks with one original backup per changed existing write."""
924
+ """Plan/apply exact managed spans through the shared cooperative writer."""
538
925
  result: dict[str, Any] = {
539
926
  "status": None,
540
927
  "planned_actions": [],
@@ -551,27 +938,38 @@ def plan_or_write_rule_file_blocks(
551
938
  result["planned_actions"].append(reason)
552
939
  return result
553
940
 
554
- existing = state.get("text")
555
- existing_text = str(existing or "")
556
- result["brief_mode_existing_levels"] = _brief_mode_levels_in_text(existing_text)
557
- rule_present = existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing_text
558
- planned_meta: dict[str, Any] | None = None
559
- if brief_mode:
560
- _, planned_meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
941
+ existing = state.get("bytes")
942
+ snapshot = state["snapshot"]
943
+ existing_bytes = bytes(existing or b"")
944
+ result["brief_mode_existing_levels"] = _brief_mode_levels_in_bytes(existing_bytes)
945
+ repo_state = parse_managed_bytes(existing_bytes, kind="repo-rules")
946
+ rule_present = repo_state.status == "valid"
947
+ try:
948
+ final_bytes, planned_meta = compose_rule_file_bytes(
949
+ existing,
950
+ with_init=with_init,
951
+ brief_mode=brief_mode,
952
+ )
953
+ except ValueError as exc:
954
+ reason = f"refused unsafe managed rule state in {path.name}: {exc}"
955
+ result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
956
+ result["planned_actions"].append(reason)
957
+ return result
561
958
 
562
959
  if with_init:
563
- if rule_present:
960
+ if rule_present and not planned_meta["init_changed"]:
564
961
  result["status"] = "exists"
565
962
  result["planned_actions"].append("advisory ContextGuard rules already present")
566
963
  elif not applied:
567
964
  result["status"] = "planned"
568
- result["planned_actions"].append("would add advisory ContextGuard rules")
965
+ verb = "migrate" if planned_meta.get("init_migrated_legacy") else "add"
966
+ result["planned_actions"].append(f"would {verb} advisory ContextGuard rules")
569
967
  elif not brief_mode:
570
968
  result["status"] = "planned"
571
969
  result["planned_actions"].append("run with --with-init to add advisory ContextGuard rules")
572
970
 
573
971
  if brief_mode:
574
- brief_changed = bool(planned_meta and planned_meta.get("brief_changed"))
972
+ brief_changed = bool(planned_meta.get("brief_changed"))
575
973
  if brief_mode == BRIEF_MODE_OFF:
576
974
  if brief_changed:
577
975
  result["brief_mode_status"] = "planned" if not applied else None
@@ -595,7 +993,7 @@ def plan_or_write_rule_file_blocks(
595
993
  result["status"] = "planned" if result["planned_actions"] else "unchanged"
596
994
  return result
597
995
 
598
- final_text, meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
996
+ meta = planned_meta
599
997
  if not meta["changed"]:
600
998
  if result["status"] is None:
601
999
  result["status"] = "exists" if rule_present else "unchanged"
@@ -603,33 +1001,29 @@ def plan_or_write_rule_file_blocks(
603
1001
  result["brief_mode_status"] = "absent" if brief_mode == BRIEF_MODE_OFF else "exists"
604
1002
  return result
605
1003
 
606
- backup_path = None
607
- if existing is not None:
608
- try:
609
- backup_path = backup_existing(path)
610
- except OSError as exc:
611
- reason = f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"
612
- result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
613
- result["planned_actions"] = [reason]
614
- return result
615
- durability_warning = None
616
- try:
617
- atomic_write(
618
- path,
619
- final_text,
620
- existing_mode_or_default(path, 0o644) if existing is not None else 0o644,
621
- dir_mode=0o755,
622
- )
623
- except AtomicWriteDurabilityError as exc:
624
- durability_warning = str(exc)
625
- except OSError as exc:
626
- reason = f"could not write repo rule file {path.name}: {exc.__class__.__name__}"
627
- result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
1004
+ write_result = write_managed_file(
1005
+ path,
1006
+ expected=snapshot,
1007
+ desired=final_bytes,
1008
+ mode=0o644,
1009
+ dir_mode=0o755,
1010
+ )
1011
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
1012
+ reason = write_result.get("reason") or f"could not write repo rule file {path.name}"
1013
+ result.update({
1014
+ "status": write_result["status"],
1015
+ "brief_mode_status": write_result["status"],
1016
+ "reason": reason,
1017
+ })
628
1018
  result["planned_actions"] = [reason]
629
1019
  return result
630
-
631
- if backup_path:
632
- result["brief_mode_backup_path"] = str(backup_path)
1020
+ if write_result.get("backup_path"):
1021
+ result["brief_mode_backup_path"] = write_result["backup_path"]
1022
+ durability_warning = (
1023
+ write_result.get("reason")
1024
+ if write_result["status"] == "applied-durability-uncertain"
1025
+ else None
1026
+ )
633
1027
  if durability_warning:
634
1028
  result["status"] = "applied-durability-uncertain"
635
1029
  result["reason"] = durability_warning
@@ -641,7 +1035,7 @@ def plan_or_write_rule_file_blocks(
641
1035
  else:
642
1036
  result["planned_actions"].append("advisory ContextGuard rules already present")
643
1037
  elif result["status"] is None:
644
- result["status"] = "unchanged"
1038
+ result["status"] = "applied"
645
1039
  if brief_mode:
646
1040
  if brief_mode == BRIEF_MODE_OFF:
647
1041
  result["brief_mode_status"] = "removed" if meta["brief_changed"] else "absent"
@@ -686,6 +1080,7 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
686
1080
  because plan/apply must agree and must never follow an attacker-swapped rule
687
1081
  directory outside the project.
688
1082
  """
1083
+ path = _normalize_allowed_first_absolute_symlink(path)
689
1084
  parts = path.parts[1:-1] if path.is_absolute() else path.parts[:-1]
690
1085
  if not parts:
691
1086
  return None
@@ -706,14 +1101,20 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
706
1101
 
707
1102
 
708
1103
  def _rule_file_state(path: Path) -> dict[str, Any]:
709
- """Return a non-throwing state for project rule/skill files."""
1104
+ """Return a non-throwing exact-byte snapshot for project rule/skill files."""
710
1105
  parent_issue = _existing_rule_parent_issue(path)
711
1106
  if parent_issue:
712
1107
  return {"status": "unsafe", "text": None, "reason": parent_issue}
713
1108
  try:
714
1109
  st = os.lstat(path)
715
1110
  except FileNotFoundError:
716
- return {"status": "missing", "text": None, "reason": None}
1111
+ return {
1112
+ "status": "missing",
1113
+ "text": None,
1114
+ "bytes": None,
1115
+ "snapshot": ManagedFileSnapshot(None, None),
1116
+ "reason": None,
1117
+ }
717
1118
  except OSError as exc:
718
1119
  return {"status": "unsafe", "text": None, "reason": f"could not inspect rule file: {exc.__class__.__name__}"}
719
1120
  if stat.S_ISLNK(st.st_mode):
@@ -721,20 +1122,35 @@ def _rule_file_state(path: Path) -> dict[str, Any]:
721
1122
  if stat.S_ISDIR(st.st_mode):
722
1123
  return {"status": "directory", "text": None, "reason": f"refused to replace directory rule target: {path.name}"}
723
1124
  try:
724
- text = _read_text_no_follow(path)
1125
+ snapshot = read_managed_file_snapshot(path)
725
1126
  except OSError as exc:
726
1127
  return {
727
1128
  "status": "unsafe",
728
1129
  "text": None,
1130
+ "bytes": None,
729
1131
  "reason": f"could not read rule file without following symlinks: {exc.__class__.__name__}",
730
1132
  }
731
- return {"status": "file", "text": text, "reason": None}
1133
+ data = snapshot.data or b""
1134
+ try:
1135
+ text = data.decode("utf-8")
1136
+ except UnicodeDecodeError:
1137
+ text = None
1138
+ return {
1139
+ "status": "file",
1140
+ "text": text,
1141
+ "bytes": data,
1142
+ "snapshot": snapshot,
1143
+ "reason": None,
1144
+ }
732
1145
 
733
1146
 
734
1147
  def repo_rule_block_present(path: Path) -> bool:
735
1148
  """True when the advisory ContextGuard block already exists in the rule file."""
736
1149
  state = _rule_file_state(path)
737
- return state["status"] == "file" and ADAPTER_RULE_BLOCK_BEGIN in str(state.get("text") or "")
1150
+ return (
1151
+ state["status"] == "file"
1152
+ and parse_managed_bytes(bytes(state.get("bytes") or b""), kind="repo-rules").status == "valid"
1153
+ )
738
1154
 
739
1155
 
740
1156
  def write_repo_rule_init(path: Path) -> dict[str, Any]:
@@ -747,36 +1163,24 @@ def write_repo_rule_init(path: Path) -> dict[str, Any]:
747
1163
  state = _rule_file_state(path)
748
1164
  if state["status"] not in {"missing", "file"}:
749
1165
  return {"status": "skipped", "reason": state.get("reason") or f"refused unsafe rule target: {path.name}"}
750
- existing = state.get("text")
751
- if existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing:
752
- return {"status": "exists"}
753
- block = render_repo_rule_block()
754
- if existing:
755
- new_text = existing.rstrip("\n") + "\n\n" + block + "\n"
756
- else:
757
- new_text = block + "\n"
758
- mode = existing_mode_or_default(path, 0o644) if existing is not None else 0o644
759
- backup_path = None
760
- if existing is not None:
761
- try:
762
- backup_path = backup_existing(path)
763
- except OSError as exc:
764
- return {"status": "skipped", "reason": f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"}
765
- durability_warning = None
766
1166
  try:
767
- atomic_write(path, new_text, mode, dir_mode=0o755)
768
- except AtomicWriteDurabilityError as exc:
769
- durability_warning = str(exc)
770
- except OSError as exc:
771
- result = {"status": "skipped", "reason": f"could not write repo rule file {path.name}: {exc.__class__.__name__}"}
772
- if backup_path:
773
- result["backup_path"] = str(backup_path)
774
- return result
775
- result = {"status": "applied", "backup_path": str(backup_path) if backup_path else None}
776
- if durability_warning:
777
- result["status"] = "applied-durability-uncertain"
778
- result["reason"] = durability_warning
779
- return result
1167
+ final, meta = compose_rule_file_bytes(
1168
+ state.get("bytes"),
1169
+ with_init=True,
1170
+ brief_mode=None,
1171
+ )
1172
+ except ValueError as exc:
1173
+ return {"status": "skipped", "reason": f"refused unsafe managed rule state: {exc}"}
1174
+ if not meta["changed"]:
1175
+ return {"status": "exists"}
1176
+ write_result = write_managed_file(
1177
+ path,
1178
+ expected=state["snapshot"],
1179
+ desired=final,
1180
+ mode=0o644,
1181
+ dir_mode=0o755,
1182
+ )
1183
+ return write_result
780
1184
 
781
1185
 
782
1186
  def codex_skill_status(path: Path) -> str:
@@ -785,10 +1189,17 @@ def codex_skill_status(path: Path) -> str:
785
1189
  return "missing"
786
1190
  if state["status"] != "file":
787
1191
  return "unsafe"
788
- text = str(state.get("text") or "")
789
- if text == render_codex_skill():
1192
+ data = bytes(state.get("bytes") or b"")
1193
+ if data == render_codex_skill().encode("utf-8"):
790
1194
  return "exists"
791
- if CODEX_SKILL_MARKER_BEGIN in text and CODEX_SKILL_MARKER_END in text:
1195
+ parsed = parse_managed_bytes(data, kind="codex-skill")
1196
+ if parsed.status != "valid":
1197
+ return "foreign"
1198
+ span = parsed.spans[0]
1199
+ if span.version == 0:
1200
+ digest = hashlib.sha256(data).hexdigest()
1201
+ return "update-needed" if digest in LEGACY_CODEX_SKILL_SHA256_ALLOWLIST else "foreign"
1202
+ if span.version == 1:
792
1203
  return "update-needed"
793
1204
  return "foreign"
794
1205
 
@@ -806,11 +1217,29 @@ def write_codex_project_skill(path: Path) -> dict[str, Any]:
806
1217
  "status": "skipped",
807
1218
  "reason": f"refused to overwrite non-ContextGuard Codex skill file: {path}",
808
1219
  }
809
- try:
810
- atomic_write(path, render_codex_skill(), 0o644, dir_mode=0o755)
811
- except OSError as exc:
812
- return {"status": "skipped", "reason": f"could not write Codex skill file {path}: {exc.__class__.__name__}"}
813
- return {"status": "updated" if status == "update-needed" else "applied"}
1220
+ existing = state.get("bytes")
1221
+ if status == "missing":
1222
+ desired = render_codex_skill().encode("utf-8")
1223
+ else:
1224
+ data = bytes(existing or b"")
1225
+ parsed = parse_managed_bytes(data, kind="codex-skill")
1226
+ span = parsed.spans[0]
1227
+ if span.version == 0:
1228
+ desired = render_codex_skill().encode("utf-8")
1229
+ else:
1230
+ desired = _replace_managed_span(data, span, render_codex_skill_block_bytes())
1231
+ result = write_managed_file(
1232
+ path,
1233
+ expected=state["snapshot"],
1234
+ desired=desired,
1235
+ mode=0o644,
1236
+ dir_mode=0o755,
1237
+ )
1238
+ if result["status"] == "applied":
1239
+ result["status"] = "updated" if status == "update-needed" else "applied"
1240
+ elif result["status"] == "applied-durability-uncertain":
1241
+ result["change_kind"] = "updated" if status == "update-needed" else "applied"
1242
+ return result
814
1243
 
815
1244
 
816
1245
  def adapter_rule_path(root: Path, adapter: AgentAdapter) -> Path | None:
@@ -1005,14 +1434,23 @@ def build_adapter_plan(
1005
1434
  entry["planned_actions"].append(
1006
1435
  f"would generate project Codex skill at {adapter.project_skill_rel}"
1007
1436
  )
1437
+ elif entry["status"] == "applied-durability-uncertain":
1438
+ entry["project_skill_status"] = "blocked-durability-uncertain"
1439
+ entry["planned_actions"].append(
1440
+ "blocked project Codex skill write because the preceding rule-file "
1441
+ "commit has uncertain directory durability"
1442
+ )
1008
1443
  else:
1009
1444
  skill_result = write_codex_project_skill(skill_path)
1010
1445
  entry["project_skill_status"] = skill_result["status"]
1011
- if skill_result["status"] in {"applied", "updated"}:
1446
+ if skill_result["status"] in {"applied", "updated", "applied-durability-uncertain"}:
1012
1447
  action = f"wrote project Codex skill to {adapter.project_skill_rel}"
1013
1448
  entry["applied_actions"].append(action)
1014
1449
  entry["planned_actions"].append(action)
1015
- if entry["status"] in {"planned", "exists", "unchanged"}:
1450
+ if skill_result["status"] == "applied-durability-uncertain":
1451
+ entry["status"] = "applied-durability-uncertain"
1452
+ entry["reason"] = skill_result.get("reason")
1453
+ elif entry["status"] in {"planned", "exists", "unchanged"}:
1016
1454
  entry["status"] = "applied"
1017
1455
  elif skill_result["status"] == "exists":
1018
1456
  entry["planned_actions"].append(
@@ -1272,10 +1710,10 @@ def _ensure_directory_no_symlink(path: Path, mode: int | None = None, *, parents
1272
1710
  raise
1273
1711
 
1274
1712
 
1275
- def _read_text_no_follow(path: Path) -> str:
1713
+ def _read_bytes_no_follow(path: Path) -> bytes:
1276
1714
  fd = _open_regular_no_symlink(path)
1277
1715
  try:
1278
- with os.fdopen(fd, "r", encoding="utf-8") as handle:
1716
+ with os.fdopen(fd, "rb") as handle:
1279
1717
  fd = -1
1280
1718
  return handle.read()
1281
1719
  finally:
@@ -1283,6 +1721,47 @@ def _read_text_no_follow(path: Path) -> str:
1283
1721
  os.close(fd)
1284
1722
 
1285
1723
 
1724
+ def _read_text_no_follow(path: Path) -> str:
1725
+ return _read_bytes_no_follow(path).decode("utf-8")
1726
+
1727
+
1728
+ def _snapshot_metadata(st: os.stat_result) -> tuple[int, int, int, int, int]:
1729
+ return (
1730
+ int(st.st_dev),
1731
+ int(st.st_ino),
1732
+ int(st.st_mode),
1733
+ int(st.st_size),
1734
+ int(st.st_mtime_ns),
1735
+ )
1736
+
1737
+
1738
+ def read_managed_file_snapshot(path: Path) -> ManagedFileSnapshot:
1739
+ """Read an exact byte+metadata snapshot without following target/parent links."""
1740
+ try:
1741
+ fd = _open_regular_no_symlink(path)
1742
+ except FileNotFoundError:
1743
+ return ManagedFileSnapshot(None, None)
1744
+ try:
1745
+ before = os.fstat(fd)
1746
+ with os.fdopen(fd, "rb") as handle:
1747
+ fd = -1
1748
+ data = handle.read()
1749
+ after = os.fstat(handle.fileno())
1750
+ if _snapshot_metadata(before) != _snapshot_metadata(after) or len(data) != after.st_size:
1751
+ raise ManagedFileConflictError(f"managed target changed during read: {path}")
1752
+ return ManagedFileSnapshot(data, _snapshot_metadata(after))
1753
+ finally:
1754
+ if fd != -1:
1755
+ os.close(fd)
1756
+
1757
+
1758
+ def _verify_expected_snapshot(path: Path, expected: ManagedFileSnapshot) -> ManagedFileSnapshot:
1759
+ current = read_managed_file_snapshot(path)
1760
+ if current != expected:
1761
+ raise ManagedFileConflictError(f"managed target changed since planning: {path}")
1762
+ return current
1763
+
1764
+
1286
1765
  def _read_optional_text_no_follow(path: Path) -> str | None:
1287
1766
  try:
1288
1767
  return _read_text_no_follow(path)
@@ -1318,7 +1797,12 @@ def load_json_object(path: Path) -> dict[str, Any]:
1318
1797
  return _parse_json_object_text(_read_optional_text_no_follow(path), path)
1319
1798
 
1320
1799
 
1321
- def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
1800
+ def ensure_permissions(
1801
+ settings: dict[str, Any],
1802
+ actions: list[str],
1803
+ *,
1804
+ migrate_env_read_denies: bool = False,
1805
+ ) -> None:
1322
1806
  permissions = settings.get("permissions")
1323
1807
  if permissions is None:
1324
1808
  permissions = {}
@@ -1331,6 +1815,21 @@ def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
1331
1815
  permissions["deny"] = deny
1332
1816
  if not isinstance(deny, list):
1333
1817
  raise SystemExit("Refusing to replace non-list settings.permissions.deny; repair it manually first.")
1818
+ if migrate_env_read_denies:
1819
+ retained = [
1820
+ rule
1821
+ for rule in deny
1822
+ if not (
1823
+ isinstance(rule, str)
1824
+ and rule in PRODUCT_OWNED_ENV_READ_DENIES
1825
+ )
1826
+ ]
1827
+ removed = len(deny) - len(retained)
1828
+ if removed:
1829
+ deny[:] = retained
1830
+ actions.append(
1831
+ f"removed {removed} obsolete permissions.deny rules now enforced by the Claude Read hook"
1832
+ )
1334
1833
  added = 0
1335
1834
  for rule in RECOMMENDED_DENIES:
1336
1835
  if rule not in deny:
@@ -1378,9 +1877,11 @@ def _path_has_symlink_component(path: Path) -> bool:
1378
1877
 
1379
1878
 
1380
1879
  def _probe_path_helper_identity(path: Path, helper_name: str) -> None:
1381
- env = os.environ.copy()
1382
1880
  system_path = os.pathsep.join(part for part in ("/usr/bin", "/bin", "/usr/sbin", "/sbin") if Path(part).is_dir())
1383
- env["PATH"] = str(path.parent) + (os.pathsep + system_path if system_path else "")
1881
+ env = {
1882
+ "LC_ALL": "C",
1883
+ "PATH": str(path.parent) + (os.pathsep + system_path if system_path else ""),
1884
+ }
1384
1885
  try:
1385
1886
  proc = subprocess.Popen(
1386
1887
  [str(path), "--help"],
@@ -1517,89 +2018,765 @@ def helper_command(helper_name: str, kit_script: str, *, shell: str | None = Non
1517
2018
  return shlex.join(argv)
1518
2019
 
1519
2020
 
1520
- def statusline_setting(*, allow_path_fallback: bool = False) -> dict[str, str]:
1521
- return {"type": "command", "command": helper_command(HELPER_STATUSLINE, "statusline_merged.sh", shell="bash", allow_path_fallback=allow_path_fallback)}
1522
-
1523
-
1524
- def bash_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1525
- return {
1526
- "matcher": "Bash",
1527
- "hooks": [{"type": "command", "command": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", allow_path_fallback=allow_path_fallback)}],
1528
- }
2021
+ def _validated_runtime_executable(raw: str | Path, *, label: str) -> Path:
2022
+ """Bind a runtime/helper to the canonical executable seen during setup."""
2023
+ candidate = Path(raw)
2024
+ if not candidate.is_absolute():
2025
+ raise SystemExit(f"{label} did not resolve to an absolute path")
2026
+ try:
2027
+ canonical = candidate.resolve(strict=True)
2028
+ metadata = canonical.stat()
2029
+ except OSError as exc:
2030
+ raise SystemExit(
2031
+ f"{label} could not be canonicalized: {exc.strerror or exc.__class__.__name__}"
2032
+ ) from exc
2033
+ if not stat.S_ISREG(metadata.st_mode) or not os.access(canonical, os.X_OK):
2034
+ raise SystemExit(f"{label} must be an executable regular file")
2035
+ return canonical
1529
2036
 
1530
2037
 
1531
- def read_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1532
- return {
1533
- "matcher": "Read",
1534
- "hooks": [{"type": "command", "command": helper_command(HELPER_GUARD_READ, "guard_large_read.py", allow_path_fallback=allow_path_fallback)}],
1535
- }
2038
+ def _approved_python_runtime() -> Path:
2039
+ if not sys.executable:
2040
+ raise SystemExit("Python runtime identity is unavailable")
2041
+ return _validated_runtime_executable(sys.executable, label="Python runtime")
1536
2042
 
1537
2043
 
1538
- def failed_nudge_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
1539
- return {
1540
- "matcher": "Bash",
1541
- "hooks": [{"type": "command", "command": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
1542
- }
2044
+ def _approved_system_runtime(name: str) -> Path:
2045
+ found = shutil.which(name, path=ISOLATED_RUNTIME_PATH)
2046
+ if not found:
2047
+ raise SystemExit(f"Required {name!r} runtime was not found in the fixed system path")
2048
+ return _validated_runtime_executable(found, label=f"{name} runtime")
1543
2049
 
1544
2050
 
1545
- def command_matches(existing: str, desired: str) -> bool:
1546
- if existing == desired:
1547
- return True
1548
- try:
1549
- existing_parts = shlex.split(existing) if existing else []
1550
- desired_parts = shlex.split(desired) if desired else []
1551
- except ValueError:
1552
- return False
1553
- return bool(existing_parts and desired_parts and existing_parts == desired_parts)
2051
+ def _isolated_runtime_prefix(
2052
+ preserve_env_names: tuple[str, ...] = (),
2053
+ *,
2054
+ fixed_env: dict[str, str] | None = None,
2055
+ ) -> list[str]:
2056
+ prefix = [
2057
+ str(_approved_system_runtime("env")),
2058
+ "-i",
2059
+ f"PATH={ISOLATED_RUNTIME_PATH}",
2060
+ "LC_ALL=C",
2061
+ ]
2062
+ for name in preserve_env_names:
2063
+ value = os.environ.get(name)
2064
+ if value is not None and BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2065
+ prefix.append(f"{name}={value}")
2066
+ for name, value in (fixed_env or {}).items():
2067
+ if name in {"HOME"} and value and "\x00" not in value:
2068
+ prefix.append(f"{name}={value}")
2069
+ return prefix
1554
2070
 
1555
2071
 
1556
- def command_helper_basenames(command: str) -> set[str]:
1557
- try:
1558
- parts = shlex.split(command) if command else []
1559
- except ValueError:
1560
- return set()
1561
- if not parts:
1562
- return set()
1563
- index = 0
1564
- if os.path.basename(parts[index]) == "env":
1565
- index += 1
1566
- while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
1567
- index += 1
1568
- if index >= len(parts):
1569
- return set()
1570
- head = os.path.basename(parts[index])
1571
- interpreter_heads = {"bash", "sh"}
1572
- if re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head):
1573
- interpreter_heads.add(head)
1574
- if head in interpreter_heads:
1575
- for token_index in range(index + 1, len(parts)):
1576
- token = parts[token_index]
1577
- if token == "-c":
1578
- if token_index + 1 < len(parts):
1579
- return command_helper_basenames(parts[token_index + 1])
1580
- return set()
1581
- if token.startswith("-"):
1582
- continue
1583
- return {os.path.basename(token)}
1584
- return set()
1585
- return {head}
2072
+ def _helper_path_from_argv(argv: list[str], *, label: str) -> Path:
2073
+ if not argv:
2074
+ raise SystemExit(f"{label} helper argv is empty")
2075
+ return _validated_runtime_executable(argv[-1], label=label)
1586
2076
 
1587
2077
 
1588
- def equivalent_helper_basenames(command: str) -> set[str]:
1589
- bases = command_helper_basenames(command)
1590
- equivalents = set(bases)
1591
- for base in bases:
1592
- equivalents.update(HELPER_EQUIVALENT_BASENAMES.get(base, ()))
1593
- return equivalents
2078
+ def _bundled_helper_candidates(helper_name: str, kit_script: str) -> set[Path]:
2079
+ script_dir = Path(__file__).resolve().parent
2080
+ raw_candidates = (
2081
+ script_dir / helper_name,
2082
+ script_dir.parent / "plugins" / "context-guard" / "bin" / helper_name,
2083
+ script_dir / kit_script,
2084
+ )
2085
+ candidates: set[Path] = set()
2086
+ for candidate in raw_candidates:
2087
+ try:
2088
+ candidates.add(candidate.resolve(strict=True))
2089
+ except OSError:
2090
+ continue
2091
+ return candidates
1594
2092
 
1595
2093
 
1596
- def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
2094
+ def automatic_helper_argv(
2095
+ helper_name: str,
2096
+ kit_script: str,
2097
+ *,
2098
+ shell: str | None = None,
2099
+ allow_path_fallback: bool = False,
2100
+ preserve_env_names: tuple[str, ...] = (),
2101
+ fixed_env: dict[str, str] | None = None,
2102
+ ) -> list[str]:
2103
+ """Build installed hook argv with isolated, setup-pinned runtimes."""
2104
+ resolved = helper_argv(
2105
+ helper_name,
2106
+ kit_script,
2107
+ shell=shell,
2108
+ allow_path_fallback=allow_path_fallback,
2109
+ )
2110
+ helper_path = _helper_path_from_argv(resolved, label=helper_name)
2111
+ prefix = _isolated_runtime_prefix(
2112
+ preserve_env_names,
2113
+ fixed_env=fixed_env,
2114
+ )
2115
+ if helper_path not in _bundled_helper_candidates(helper_name, kit_script):
2116
+ # An explicit PATH fallback may be a native executable. Its absolute
2117
+ # identity was already validated by validate_path_helper_fallback().
2118
+ return [*prefix, str(helper_path)]
2119
+ if shell:
2120
+ shell_runtime = _approved_system_runtime(shell)
2121
+ shell_flags = ["--noprofile", "--norc"] if shell_runtime.name == "bash" else []
2122
+ return [*prefix, str(shell_runtime), *shell_flags, str(helper_path)]
2123
+ return [*prefix, str(_approved_python_runtime()), "-I", str(helper_path)]
2124
+
2125
+
2126
+ def automatic_helper_command(
2127
+ helper_name: str,
2128
+ kit_script: str,
2129
+ *,
2130
+ shell: str | None = None,
2131
+ allow_path_fallback: bool = False,
2132
+ preserve_env_names: tuple[str, ...] = (),
2133
+ fixed_env: dict[str, str] | None = None,
2134
+ ) -> str:
2135
+ return shlex.join(
2136
+ automatic_helper_argv(
2137
+ helper_name,
2138
+ kit_script,
2139
+ shell=shell,
2140
+ allow_path_fallback=allow_path_fallback,
2141
+ preserve_env_names=preserve_env_names,
2142
+ fixed_env=fixed_env,
2143
+ )
2144
+ )
2145
+
2146
+
2147
+ def _secure_owned_regular_path(path: Path, *, executable: bool) -> Path | None:
2148
+ """Validate an approval path and every parent without following symlinks."""
2149
+ try:
2150
+ canonical = path.resolve(strict=True)
2151
+ except OSError:
2152
+ return None
2153
+ if not path.is_absolute() or canonical != path:
2154
+ return None
2155
+ allowed_owners = {0, os.geteuid()}
2156
+ current = Path(path.anchor)
2157
+ components = path.parts[1:]
2158
+ if not components:
2159
+ return None
2160
+ try:
2161
+ root_metadata = os.lstat(current)
2162
+ if (
2163
+ stat.S_ISLNK(root_metadata.st_mode)
2164
+ or not stat.S_ISDIR(root_metadata.st_mode)
2165
+ or root_metadata.st_uid not in allowed_owners
2166
+ or stat.S_IMODE(root_metadata.st_mode) & 0o022
2167
+ ):
2168
+ return None
2169
+ for index, component in enumerate(components):
2170
+ current = current / component
2171
+ metadata = os.lstat(current)
2172
+ is_leaf = index == len(components) - 1
2173
+ if stat.S_ISLNK(metadata.st_mode):
2174
+ return None
2175
+ if metadata.st_uid not in allowed_owners or stat.S_IMODE(metadata.st_mode) & 0o022:
2176
+ return None
2177
+ if is_leaf:
2178
+ if not stat.S_ISREG(metadata.st_mode):
2179
+ return None
2180
+ if executable and not os.access(current, os.X_OK):
2181
+ return None
2182
+ elif not stat.S_ISDIR(metadata.st_mode):
2183
+ return None
2184
+ except OSError:
2185
+ return None
2186
+ return canonical
2187
+
2188
+
2189
+ def _secure_owned_directory_path(path: Path) -> Path | None:
2190
+ try:
2191
+ canonical = path.resolve(strict=True)
2192
+ except OSError:
2193
+ return None
2194
+ if not path.is_absolute() or canonical != path:
2195
+ return None
2196
+ allowed_owners = {0, os.geteuid()}
2197
+ current = Path(path.anchor)
2198
+ try:
2199
+ root_metadata = os.lstat(current)
2200
+ if (
2201
+ stat.S_ISLNK(root_metadata.st_mode)
2202
+ or not stat.S_ISDIR(root_metadata.st_mode)
2203
+ or root_metadata.st_uid not in allowed_owners
2204
+ or stat.S_IMODE(root_metadata.st_mode) & 0o022
2205
+ ):
2206
+ return None
2207
+ for component in path.parts[1:]:
2208
+ current = current / component
2209
+ metadata = os.lstat(current)
2210
+ if (
2211
+ stat.S_ISLNK(metadata.st_mode)
2212
+ or not stat.S_ISDIR(metadata.st_mode)
2213
+ or metadata.st_uid not in allowed_owners
2214
+ or stat.S_IMODE(metadata.st_mode) & 0o022
2215
+ ):
2216
+ return None
2217
+ except OSError:
2218
+ return None
2219
+ return canonical
2220
+
2221
+
2222
+ def _approved_node_runtime() -> Path | None:
2223
+ system_node = shutil.which("node", path=ISOLATED_RUNTIME_PATH)
2224
+ if system_node:
2225
+ approved = _secure_owned_regular_path(Path(system_node), executable=True)
2226
+ if approved is not None:
2227
+ return approved
2228
+
2229
+ allowed_owners = {0, os.geteuid()}
2230
+ for candidate in HOMEBREW_NODE_CANDIDATES:
2231
+ if len(candidate.parents) < 2:
2232
+ continue
2233
+ allowed_root = candidate.parents[1]
2234
+ if _secure_owned_directory_path(allowed_root) is None:
2235
+ continue
2236
+ if _secure_owned_directory_path(candidate.parent) is None:
2237
+ continue
2238
+ try:
2239
+ link_metadata = os.lstat(candidate)
2240
+ physical_target = candidate.resolve(strict=True)
2241
+ physical_target.relative_to(allowed_root)
2242
+ except (OSError, ValueError):
2243
+ continue
2244
+ if link_metadata.st_uid not in allowed_owners:
2245
+ continue
2246
+ approved = _secure_owned_regular_path(physical_target, executable=True)
2247
+ if approved is not None:
2248
+ return approved
2249
+ return None
2250
+
2251
+
2252
+ def _approved_default_omc_hud() -> tuple[Path, Path] | None:
2253
+ """Approve only the effective user's default OMC HUD and fixed-path Node."""
2254
+ try:
2255
+ passwd_home = Path(pwd.getpwuid(os.geteuid()).pw_dir)
2256
+ canonical_home = passwd_home.resolve(strict=True)
2257
+ except (KeyError, OSError, RuntimeError):
2258
+ return None
2259
+ if not passwd_home.is_absolute() or canonical_home != passwd_home:
2260
+ return None
2261
+ omc_script = _secure_owned_regular_path(
2262
+ canonical_home / ".claude" / "hud" / "omc-hud.mjs",
2263
+ executable=False,
2264
+ )
2265
+ if omc_script is None:
2266
+ return None
2267
+ node_runtime = _approved_node_runtime()
2268
+ if node_runtime is None:
2269
+ return None
2270
+ return node_runtime, omc_script
2271
+
2272
+
2273
+ def _statusline_setting(*, allow_path_fallback: bool = False) -> tuple[dict[str, str], bool]:
2274
+ approved_omc = _approved_default_omc_hud()
2275
+ fixed_env = (
2276
+ {"HOME": str(approved_omc[1].parents[2])}
2277
+ if approved_omc is not None
2278
+ else None
2279
+ )
2280
+ argv = automatic_helper_argv(
2281
+ HELPER_STATUSLINE,
2282
+ "statusline_merged.sh",
2283
+ shell="bash",
2284
+ allow_path_fallback=allow_path_fallback,
2285
+ preserve_env_names=STATUSLINE_BEHAVIOR_ENV,
2286
+ fixed_env=fixed_env,
2287
+ )
2288
+ token_path = _helper_path_from_argv(
2289
+ helper_argv(
2290
+ HELPER_STATUSLINE_PLAIN,
2291
+ "statusline.sh",
2292
+ shell="bash",
2293
+ allow_path_fallback=allow_path_fallback,
2294
+ ),
2295
+ label=HELPER_STATUSLINE_PLAIN,
2296
+ )
2297
+ argv.extend(
2298
+ [
2299
+ "--approved-bash",
2300
+ str(_approved_system_runtime("bash")),
2301
+ "--approved-python",
2302
+ str(_approved_python_runtime()),
2303
+ "--approved-token-statusline",
2304
+ str(token_path),
2305
+ ]
2306
+ )
2307
+ if approved_omc is not None:
2308
+ node_runtime, omc_script = approved_omc
2309
+ argv.extend(
2310
+ [
2311
+ "--approved-node",
2312
+ str(node_runtime),
2313
+ "--approved-omc-script",
2314
+ str(omc_script),
2315
+ ]
2316
+ )
2317
+ return {"type": "command", "command": shlex.join(argv)}, approved_omc is not None
2318
+
2319
+
2320
+ def statusline_setting(*, allow_path_fallback: bool = False) -> dict[str, str]:
2321
+ setting, _omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
2322
+ return setting
2323
+
2324
+
2325
+ def bash_hook_setting(*, allow_path_fallback: bool = False, bash_reference_v1: bool = False) -> dict[str, Any]:
2326
+ command = automatic_helper_command(
2327
+ HELPER_REWRITE_BASH,
2328
+ "rewrite_bash_for_token_budget.py",
2329
+ allow_path_fallback=allow_path_fallback,
2330
+ preserve_env_names=REWRITE_BEHAVIOR_ENV,
2331
+ )
2332
+ if bash_reference_v1:
2333
+ command = f"{command} --bash-reference-v1"
2334
+ return {
2335
+ "matcher": "Bash",
2336
+ "hooks": [{"type": "command", "command": command}],
2337
+ }
2338
+
2339
+
2340
+ def load_bash_reference_policy() -> object | None:
2341
+ """Load only the package-local runtime policy, never an import from PATH."""
2342
+ path = Path(__file__).resolve().parent / "bash_reference_policy.py"
2343
+ flags = (
2344
+ os.O_RDONLY
2345
+ | getattr(os, "O_CLOEXEC", 0)
2346
+ | getattr(os, "O_NONBLOCK", 0)
2347
+ | getattr(os, "O_NOCTTY", 0)
2348
+ )
2349
+ if not hasattr(os, "O_NOFOLLOW"):
2350
+ return None
2351
+ flags |= os.O_NOFOLLOW
2352
+ fd = -1
2353
+ try:
2354
+ fd = os.open(path, flags)
2355
+ metadata = os.fstat(fd)
2356
+ if (
2357
+ not stat.S_ISREG(metadata.st_mode)
2358
+ or metadata.st_nlink != 1
2359
+ or metadata.st_size > BASH_REFERENCE_POLICY_MAX_BYTES
2360
+ ):
2361
+ return None
2362
+ source = os.read(fd, BASH_REFERENCE_POLICY_MAX_BYTES + 1)
2363
+ if len(source) > BASH_REFERENCE_POLICY_MAX_BYTES:
2364
+ return None
2365
+ source_text = source.decode("utf-8", errors="strict")
2366
+ except (OSError, UnicodeDecodeError):
2367
+ return None
2368
+ finally:
2369
+ if fd >= 0:
2370
+ os.close(fd)
2371
+ module_name = f"_context_guard_setup_reference_policy_{os.getpid()}"
2372
+ module = types.ModuleType(module_name)
2373
+ module.__file__ = str(path)
2374
+ module.__package__ = ""
2375
+ sys.modules[module_name] = module
2376
+ try:
2377
+ exec(compile(source_text, str(path), "exec"), module.__dict__)
2378
+ except Exception:
2379
+ sys.modules.pop(module_name, None)
2380
+ return None
2381
+ return module
2382
+
2383
+
2384
+ def bash_reference_adapter_readiness(root: Path) -> tuple[bool, str]:
2385
+ """Return the runtime adapter verdict for the effective setup project."""
2386
+ policy = load_bash_reference_policy()
2387
+ discover = getattr(policy, "discover_adapter", None)
2388
+ if policy is None:
2389
+ return False, "receipt_policy_unavailable"
2390
+ if not callable(discover):
2391
+ return False, "receipt_policy_invalid"
2392
+ try:
2393
+ discovered = discover(root)
2394
+ except Exception:
2395
+ return False, "receipt_policy_load_failed"
2396
+ if not isinstance(discovered, tuple) or len(discovered) != 2:
2397
+ return False, "receipt_policy_invalid"
2398
+ adapter, reason = discovered
2399
+ adapter_methods = ("start_broker", "query_reference")
2400
+ if (
2401
+ adapter is not None
2402
+ and reason == "receipt_adapter_available"
2403
+ and all(callable(getattr(adapter, name, None)) for name in adapter_methods)
2404
+ ):
2405
+ return True, reason
2406
+ if adapter is not None and reason == "receipt_adapter_available":
2407
+ return False, "receipt_adapter_invalid"
2408
+ if not isinstance(reason, str) or re.fullmatch(r"[a-z0-9_]{1,96}", reason) is None:
2409
+ return False, "receipt_policy_invalid"
2410
+ return False, reason
2411
+
2412
+
2413
+ def bash_reference_unavailable_message(reason: str) -> str:
2414
+ return (
2415
+ f"{_BASH_REFERENCE_UNAVAILABLE}: reason={reason}; requires an exact paired npm install "
2416
+ f"of {ROOT_PACKAGE_NAME} and {RECEIPT_PACKAGE_NAME}; "
2417
+ f"recovery={_BASH_REFERENCE_RECOVERY}; ordinary Bash trimming remains enabled"
2418
+ )
2419
+
2420
+
2421
+ def disable_unavailable_bash_reference(
2422
+ choices: Choices,
2423
+ warnings: list[str],
2424
+ *,
2425
+ root: Path,
2426
+ ) -> list[str]:
2427
+ """Fail closed without removing the ordinary Bash trimming choice."""
2428
+ if not choices.bash_reference_v1:
2429
+ return []
2430
+ available, reason = bash_reference_adapter_readiness(root)
2431
+ if available:
2432
+ return []
2433
+ choices.bash_reference_v1 = False
2434
+ warning = bash_reference_unavailable_message(reason)
2435
+ warnings.append(warning)
2436
+ return [warning]
2437
+
2438
+
2439
+ def read_hook_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
2440
+ return {
2441
+ "matcher": "Read",
2442
+ "hooks": [{"type": "command", "command": automatic_helper_command(
2443
+ HELPER_GUARD_READ,
2444
+ "guard_large_read.py",
2445
+ allow_path_fallback=allow_path_fallback,
2446
+ preserve_env_names=READ_GUARD_BEHAVIOR_ENV,
2447
+ )}],
2448
+ }
2449
+
2450
+
2451
+ def failed_nudge_setting(*, allow_path_fallback: bool = False) -> dict[str, Any]:
2452
+ return {
2453
+ "matcher": "Bash",
2454
+ "hooks": [{"type": "command", "command": automatic_helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", allow_path_fallback=allow_path_fallback)}],
2455
+ }
2456
+
2457
+
2458
+ def command_matches(existing: str, desired: str) -> bool:
2459
+ if existing == desired:
2460
+ return True
2461
+ try:
2462
+ existing_parts = shlex.split(existing) if existing else []
2463
+ desired_parts = shlex.split(desired) if desired else []
2464
+ except ValueError:
2465
+ return False
2466
+ return bool(existing_parts and desired_parts and existing_parts == desired_parts)
2467
+
2468
+
2469
+ def command_helper_basenames(command: str) -> set[str]:
2470
+ try:
2471
+ parts = shlex.split(command) if command else []
2472
+ except ValueError:
2473
+ return set()
2474
+ if not parts:
2475
+ return set()
2476
+ index = 0
2477
+ if os.path.basename(parts[index]) == "env":
2478
+ index += 1
2479
+ while index < len(parts):
2480
+ token = parts[index]
2481
+ if token in {"-i", "--ignore-environment"}:
2482
+ index += 1
2483
+ continue
2484
+ if token in {"-u", "--unset"} and index + 1 < len(parts):
2485
+ index += 2
2486
+ continue
2487
+ if token.startswith("--unset="):
2488
+ index += 1
2489
+ continue
2490
+ break
2491
+ while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2492
+ index += 1
2493
+ if index >= len(parts):
2494
+ return set()
2495
+ head = os.path.basename(parts[index])
2496
+ interpreter_heads = {"bash", "sh"}
2497
+ if re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head):
2498
+ interpreter_heads.add(head)
2499
+ if head in interpreter_heads:
2500
+ for token_index in range(index + 1, len(parts)):
2501
+ token = parts[token_index]
2502
+ if token == "-c":
2503
+ if token_index + 1 < len(parts):
2504
+ return command_helper_basenames(parts[token_index + 1])
2505
+ return set()
2506
+ if token.startswith("-"):
2507
+ continue
2508
+ return {os.path.basename(token)}
2509
+ return set()
2510
+ return {head}
2511
+
2512
+
2513
+ def _statusline_candidate_paths(*, merged: bool) -> set[Path]:
2514
+ script_dir = Path(__file__).resolve().parent
2515
+ helper_key = HELPER_STATUSLINE if merged else HELPER_STATUSLINE_PLAIN
2516
+ kit_script = "statusline_merged.sh" if merged else "statusline.sh"
2517
+ names = HELPER_EQUIVALENT_BASENAMES[helper_key]
2518
+ raw_candidates = {script_dir / kit_script}
2519
+ for name in names:
2520
+ raw_candidates.add(script_dir / name)
2521
+ raw_candidates.add(
2522
+ script_dir.parent / "plugins" / "context-guard" / "bin" / name
2523
+ )
2524
+ candidates: set[Path] = set()
2525
+ for candidate in raw_candidates:
2526
+ try:
2527
+ candidates.add(candidate.resolve(strict=True))
2528
+ except OSError:
2529
+ continue
2530
+ return candidates
2531
+
2532
+
2533
+ def _authenticated_statusline_path(raw: str, *, merged: bool) -> Path | None:
2534
+ candidate = Path(raw)
2535
+ if not candidate.is_absolute():
2536
+ candidate = Path.cwd() / candidate
2537
+ try:
2538
+ canonical = candidate.resolve(strict=True)
2539
+ except OSError:
2540
+ return None
2541
+ return canonical if canonical in _statusline_candidate_paths(merged=merged) else None
2542
+
2543
+
2544
+ def exact_known_statusline_command(command: str) -> bool:
2545
+ """Match only authenticated complete historical merged-statusline commands."""
2546
+ try:
2547
+ parts = shlex.split(command) if command else []
2548
+ except ValueError:
2549
+ return False
2550
+ if not parts:
2551
+ return False
2552
+ known_helpers = HELPER_EQUIVALENT_BASENAMES[HELPER_STATUSLINE]
2553
+ direct_head = parts[0]
2554
+ if len(parts) == 1:
2555
+ if direct_head in known_helpers:
2556
+ return True
2557
+ return _authenticated_statusline_path(direct_head, merged=True) is not None
2558
+
2559
+ index = 0
2560
+ generated_shape = False
2561
+ assignments: dict[str, str] = {}
2562
+ approved_env = str(_approved_system_runtime("env"))
2563
+ if direct_head == approved_env:
2564
+ generated_shape = True
2565
+ index += 1
2566
+ if index >= len(parts) or parts[index] != "-i":
2567
+ return False
2568
+ index += 1
2569
+ allowed_assignments = {
2570
+ "PATH",
2571
+ "LC_ALL",
2572
+ "HOME",
2573
+ *STATUSLINE_BEHAVIOR_ENV,
2574
+ }
2575
+ while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2576
+ name, _separator, value = parts[index].partition("=")
2577
+ if name not in allowed_assignments or name in assignments:
2578
+ return False
2579
+ assignments[name] = value
2580
+ index += 1
2581
+ if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
2582
+ return False
2583
+ for name, value in assignments.items():
2584
+ if name in STATUSLINE_BEHAVIOR_ENV and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2585
+ return False
2586
+
2587
+ approved_bash = str(_approved_system_runtime("bash"))
2588
+ if index >= len(parts):
2589
+ return False
2590
+ shell = parts[index]
2591
+ if generated_shape:
2592
+ if shell != approved_bash:
2593
+ return False
2594
+ elif shell not in {"bash", "sh", approved_bash}:
2595
+ return False
2596
+ index += 1
2597
+ if generated_shape:
2598
+ if parts[index : index + 2] != ["--noprofile", "--norc"]:
2599
+ return False
2600
+ index += 2
2601
+ else:
2602
+ while index < len(parts) and parts[index] in {"--noprofile", "--norc"}:
2603
+ index += 1
2604
+ if index >= len(parts):
2605
+ return False
2606
+ if _authenticated_statusline_path(parts[index], merged=True) is None:
2607
+ return False
2608
+ index += 1
2609
+ if not generated_shape:
2610
+ return index == len(parts)
2611
+
2612
+ required_prefix = [
2613
+ "--approved-bash",
2614
+ approved_bash,
2615
+ "--approved-python",
2616
+ str(_approved_python_runtime()),
2617
+ "--approved-token-statusline",
2618
+ ]
2619
+ if parts[index : index + len(required_prefix)] != required_prefix:
2620
+ return False
2621
+ index += len(required_prefix)
2622
+ if index >= len(parts):
2623
+ return False
2624
+ if _authenticated_statusline_path(parts[index], merged=False) is None:
2625
+ return False
2626
+ index += 1
2627
+
2628
+ approved_omc = _approved_default_omc_hud()
2629
+ if index == len(parts):
2630
+ return "HOME" not in assignments
2631
+ if approved_omc is None or len(parts) - index != 4:
2632
+ return False
2633
+ node_runtime, omc_script = approved_omc
2634
+ expected_omc = [
2635
+ "--approved-node",
2636
+ str(node_runtime),
2637
+ "--approved-omc-script",
2638
+ str(omc_script),
2639
+ ]
2640
+ return (
2641
+ parts[index:] == expected_omc
2642
+ and assignments.get("HOME") == str(omc_script.parents[2])
2643
+ )
2644
+
2645
+
2646
+ def equivalent_helper_basenames(command: str) -> set[str]:
2647
+ bases = command_helper_basenames(command)
2648
+ equivalents = set(bases)
2649
+ for base in bases:
2650
+ equivalents.update(HELPER_EQUIVALENT_BASENAMES.get(base, ()))
2651
+ return equivalents
2652
+
2653
+
2654
+ def _generic_hook_spec(desired: str) -> tuple[str, str, tuple[str, ...]] | None:
2655
+ desired_bases = command_helper_basenames(desired)
2656
+ specs = (
2657
+ (HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py", REWRITE_BEHAVIOR_ENV),
2658
+ (HELPER_GUARD_READ, "guard_large_read.py", READ_GUARD_BEHAVIOR_ENV),
2659
+ (HELPER_FAILED_NUDGE, "failed_attempt_nudge.py", ()),
2660
+ )
2661
+ for helper_name, kit_script, behavior_env in specs:
2662
+ if desired_bases & HELPER_EQUIVALENT_BASENAMES[helper_name]:
2663
+ return helper_name, kit_script, behavior_env
2664
+ return None
2665
+
2666
+
2667
+ def _generic_hook_candidate_paths(helper_name: str, kit_script: str) -> set[Path]:
2668
+ script_dir = Path(__file__).resolve().parent
2669
+ raw_candidates = {script_dir / kit_script}
2670
+ for name in HELPER_EQUIVALENT_BASENAMES[helper_name]:
2671
+ raw_candidates.add(script_dir / name)
2672
+ raw_candidates.add(
2673
+ script_dir.parent / "plugins" / "context-guard" / "bin" / name
2674
+ )
2675
+ candidates: set[Path] = set()
2676
+ for candidate in raw_candidates:
2677
+ try:
2678
+ candidates.add(candidate.resolve(strict=True))
2679
+ except OSError:
2680
+ continue
2681
+ return candidates
2682
+
2683
+
2684
+ def _authenticated_generic_hook_path(
2685
+ raw: str,
2686
+ *,
2687
+ helper_name: str,
2688
+ kit_script: str,
2689
+ ) -> Path | None:
2690
+ candidate = Path(raw)
2691
+ if not candidate.is_absolute():
2692
+ candidate = Path.cwd() / candidate
2693
+ try:
2694
+ canonical = candidate.resolve(strict=True)
2695
+ except OSError:
2696
+ return None
2697
+ candidates = _generic_hook_candidate_paths(helper_name, kit_script)
2698
+ return canonical if canonical in candidates else None
2699
+
2700
+
2701
+ def exact_known_hook_command(existing: str, desired: str) -> bool:
1597
2702
  if command_matches(existing, desired):
1598
2703
  return True
1599
- desired_helpers = equivalent_helper_basenames(desired)
1600
- if not desired_helpers:
2704
+ spec = _generic_hook_spec(desired)
2705
+ if spec is None:
2706
+ return False
2707
+ helper_name, kit_script, behavior_env = spec
2708
+ known_names = HELPER_EQUIVALENT_BASENAMES[helper_name]
2709
+ try:
2710
+ parts = shlex.split(existing) if existing else []
2711
+ except ValueError:
2712
+ return False
2713
+ if not parts:
2714
+ return False
2715
+ if len(parts) == 1:
2716
+ if parts[0] in known_names:
2717
+ return True
2718
+ return _authenticated_generic_hook_path(
2719
+ parts[0],
2720
+ helper_name=helper_name,
2721
+ kit_script=kit_script,
2722
+ ) is not None
2723
+
2724
+ index = 0
2725
+ generated_shape = parts[0] == str(_approved_system_runtime("env"))
2726
+ if generated_shape:
2727
+ index = 1
2728
+ if index >= len(parts) or parts[index] != "-i":
2729
+ return False
2730
+ index += 1
2731
+ assignments: dict[str, str] = {}
2732
+ allowed_assignments = {"PATH", "LC_ALL", *behavior_env}
2733
+ while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
2734
+ name, _separator, value = parts[index].partition("=")
2735
+ if name not in allowed_assignments or name in assignments:
2736
+ return False
2737
+ assignments[name] = value
2738
+ index += 1
2739
+ if assignments.get("PATH") != ISOLATED_RUNTIME_PATH or assignments.get("LC_ALL") != "C":
2740
+ return False
2741
+ for name, value in assignments.items():
2742
+ if name in behavior_env and not BEHAVIOR_ENV_VALUE_RE.fullmatch(value):
2743
+ return False
2744
+
2745
+ if index >= len(parts):
2746
+ return False
2747
+ python_runtime = parts[index]
2748
+ if generated_shape:
2749
+ if python_runtime != str(_approved_python_runtime()):
2750
+ return False
2751
+ elif (
2752
+ "/" in python_runtime
2753
+ or re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", python_runtime) is None
2754
+ ):
2755
+ return False
2756
+ index += 1
2757
+ if index < len(parts) and parts[index] == "-I":
2758
+ index += 1
2759
+ elif generated_shape:
1601
2760
  return False
1602
- return bool(command_helper_basenames(existing) & desired_helpers)
2761
+ if index >= len(parts):
2762
+ return False
2763
+ if _authenticated_generic_hook_path(
2764
+ parts[index],
2765
+ helper_name=helper_name,
2766
+ kit_script=kit_script,
2767
+ ) is None:
2768
+ return False
2769
+ index += 1
2770
+ if index == len(parts):
2771
+ return True
2772
+ return (
2773
+ helper_name == HELPER_REWRITE_BASH
2774
+ and parts[index:] == ["--bash-reference-v1"]
2775
+ )
2776
+
2777
+
2778
+ def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
2779
+ return exact_known_hook_command(existing, desired)
1603
2780
 
1604
2781
 
1605
2782
  def canonicalize_equivalent_command(value: Any, desired: str) -> tuple[bool, bool]:
@@ -1649,6 +2826,16 @@ def ensure_post_tool_hook(settings: dict[str, Any], hook: dict[str, Any], comman
1649
2826
  _ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUse")
1650
2827
 
1651
2828
 
2829
+ def ensure_post_tool_failure_hook(
2830
+ settings: dict[str, Any],
2831
+ hook: dict[str, Any],
2832
+ command: str,
2833
+ label: str,
2834
+ actions: list[str],
2835
+ ) -> None:
2836
+ _ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUseFailure")
2837
+
2838
+
1652
2839
  def _ensure_tool_hook(
1653
2840
  settings: dict[str, Any],
1654
2841
  hook: dict[str, Any],
@@ -1973,7 +3160,28 @@ def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
1973
3160
  }
1974
3161
 
1975
3162
  choices = choices_from_args(args)
1976
- actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
3163
+ reference_actions = disable_unavailable_bash_reference(
3164
+ choices,
3165
+ warnings,
3166
+ root=root,
3167
+ )
3168
+ if reference_actions:
3169
+ checks.append(doctor_check(
3170
+ "bash-reference-distribution",
3171
+ "warning",
3172
+ "medium",
3173
+ reference_actions[0],
3174
+ next_action=_BASH_REFERENCE_RECOVERY + ".",
3175
+ ))
3176
+ actions = reference_actions + (
3177
+ apply_choices(
3178
+ settings,
3179
+ choices,
3180
+ allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
3181
+ )
3182
+ if claude_targeted
3183
+ else []
3184
+ )
1977
3185
  changed = (settings != original) if claude_targeted else False
1978
3186
  if changed:
1979
3187
  checks.append(doctor_check(
@@ -2129,18 +3337,46 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2129
3337
  settings["effortLevel"] = DEFAULT_EFFORT
2130
3338
  actions.append(f"set default effortLevel to {DEFAULT_EFFORT}")
2131
3339
  if choices.statusline:
2132
- statusline = statusline_setting(allow_path_fallback=allow_path_fallback)
3340
+ statusline, omc_included = _statusline_setting(allow_path_fallback=allow_path_fallback)
2133
3341
  if "statusLine" not in settings:
2134
3342
  settings["statusLine"] = statusline
2135
3343
  actions.append("enabled token statusline")
3344
+ if omc_included:
3345
+ actions.append("included setup-approved OMC HUD")
2136
3346
  elif settings.get("statusLine") != statusline:
2137
- actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
3347
+ existing_statusline = settings.get("statusLine")
3348
+ existing_command = (
3349
+ existing_statusline.get("command")
3350
+ if isinstance(existing_statusline, dict)
3351
+ else None
3352
+ )
3353
+ if (
3354
+ isinstance(existing_command, str)
3355
+ and exact_known_statusline_command(existing_command)
3356
+ ):
3357
+ settings["statusLine"] = statusline
3358
+ actions.append("migrated token statusline")
3359
+ if omc_included:
3360
+ actions.append("included setup-approved OMC HUD")
3361
+ else:
3362
+ actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
2138
3363
  if choices.denies:
2139
- ensure_permissions(settings, actions)
3364
+ ensure_permissions(
3365
+ settings,
3366
+ actions,
3367
+ migrate_env_read_denies=choices.read_guard,
3368
+ )
2140
3369
  if choices.bash_hook:
2141
- bash_hook = bash_hook_setting(allow_path_fallback=allow_path_fallback)
3370
+ bash_hook = bash_hook_setting(
3371
+ allow_path_fallback=allow_path_fallback,
3372
+ bash_reference_v1=choices.bash_reference_v1,
3373
+ )
2142
3374
  bash_command = bash_hook["hooks"][0]["command"]
2143
3375
  ensure_pre_tool_hook(settings, bash_hook, bash_command, "Bash trim/sanitize", actions)
3376
+ if choices.bash_reference_v1:
3377
+ actions.append(
3378
+ "enabled bash_reference_v1: a scoped 7-day bearer handle may appear in Claude/provider-visible transcripts"
3379
+ )
2144
3380
  if choices.read_guard:
2145
3381
  read_hook = read_hook_setting(allow_path_fallback=allow_path_fallback)
2146
3382
  read_command = read_hook["hooks"][0]["command"]
@@ -2149,10 +3385,24 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
2149
3385
  nudge_hook = failed_nudge_setting(allow_path_fallback=allow_path_fallback)
2150
3386
  nudge_command = nudge_hook["hooks"][0]["command"]
2151
3387
  ensure_post_tool_hook(settings, nudge_hook, nudge_command, "failed-attempt /clear nudge", actions)
3388
+ ensure_post_tool_failure_hook(
3389
+ settings,
3390
+ nudge_hook,
3391
+ nudge_command,
3392
+ "failed-attempt /clear nudge",
3393
+ actions,
3394
+ )
2152
3395
  return actions
2153
3396
 
2154
3397
 
2155
- def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PRIVATE_DIR_MODE) -> None:
3398
+ def atomic_write_bytes(
3399
+ path: Path,
3400
+ data: bytes,
3401
+ mode: int = 0o600,
3402
+ *,
3403
+ dir_mode: int = PRIVATE_DIR_MODE,
3404
+ expected: ManagedFileSnapshot | None = None,
3405
+ ) -> None:
2156
3406
  if os.rename not in os.supports_dir_fd or os.unlink not in os.supports_dir_fd:
2157
3407
  raise OSError("platform does not support directory-relative atomic writes")
2158
3408
  parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
@@ -2162,12 +3412,14 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
2162
3412
  try:
2163
3413
  if hasattr(os, "fchmod"):
2164
3414
  os.fchmod(fd, mode)
2165
- with os.fdopen(fd, "w", encoding="utf-8") as f:
3415
+ with os.fdopen(fd, "wb") as f:
2166
3416
  fd = -1
2167
- f.write(text)
3417
+ f.write(data)
2168
3418
  f.flush()
2169
3419
  os.fsync(f.fileno())
2170
3420
  os.fsync(parent_fd)
3421
+ if expected is not None:
3422
+ _verify_expected_snapshot(path, expected)
2171
3423
  os.rename(tmp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
2172
3424
  try:
2173
3425
  os.fsync(parent_fd)
@@ -2185,6 +3437,37 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
2185
3437
  os.close(parent_fd)
2186
3438
 
2187
3439
 
3440
+ def atomic_write(
3441
+ path: Path,
3442
+ content: str | bytes,
3443
+ mode: int = 0o600,
3444
+ *,
3445
+ dir_mode: int = PRIVATE_DIR_MODE,
3446
+ ) -> None:
3447
+ data = content if isinstance(content, bytes) else content.encode("utf-8")
3448
+ atomic_write_bytes(path, data, mode, dir_mode=dir_mode)
3449
+
3450
+
3451
+ def _atomic_remove_expected(
3452
+ path: Path,
3453
+ expected: ManagedFileSnapshot,
3454
+ *,
3455
+ dir_mode: int,
3456
+ ) -> None:
3457
+ parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
3458
+ try:
3459
+ _verify_expected_snapshot(path, expected)
3460
+ os.unlink(path.name, dir_fd=parent_fd)
3461
+ try:
3462
+ os.fsync(parent_fd)
3463
+ except OSError as exc:
3464
+ raise AtomicWriteDurabilityError(
3465
+ f"remove committed but parent directory durability is uncertain: {path}"
3466
+ ) from exc
3467
+ finally:
3468
+ os.close(parent_fd)
3469
+
3470
+
2188
3471
  def existing_mode_or_default(path: Path, default: int = 0o600) -> int:
2189
3472
  try:
2190
3473
  fd = _open_regular_no_symlink(path)
@@ -2210,6 +3493,175 @@ def backup_existing(path: Path) -> Path | None:
2210
3493
  return backup
2211
3494
 
2212
3495
 
3496
+ def managed_lock_path(path: Path) -> Path:
3497
+ return path.with_name(f".{path.name}.lock")
3498
+
3499
+
3500
+ def acquire_managed_file_lock(path: Path, *, dir_mode: int = PRIVATE_DIR_MODE) -> int:
3501
+ """Acquire the shared sibling lock used by all managed forward/rollback writers."""
3502
+ if fcntl is None:
3503
+ raise OSError("platform does not support advisory file locks")
3504
+ parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
3505
+ lock_name = managed_lock_path(path).name
3506
+ flags = os.O_CREAT | os.O_RDWR | _no_follow_flag()
3507
+ if hasattr(os, "O_CLOEXEC"):
3508
+ flags |= os.O_CLOEXEC
3509
+ fd: int | None = None
3510
+ try:
3511
+ for attempt in range(3):
3512
+ try:
3513
+ fd = os.open(lock_name, flags, 0o600, dir_fd=parent_fd)
3514
+ break
3515
+ except FileNotFoundError:
3516
+ if attempt == 2:
3517
+ raise
3518
+ time.sleep(0.001)
3519
+ except OSError as exc:
3520
+ raise OSError(f"could not open cooperative lock {managed_lock_path(path)}: {exc}") from exc
3521
+ finally:
3522
+ os.close(parent_fd)
3523
+ if fd is None:
3524
+ raise OSError(f"could not open cooperative lock {managed_lock_path(path)}")
3525
+ try:
3526
+ st = os.fstat(fd)
3527
+ if not stat.S_ISREG(st.st_mode):
3528
+ raise OSError(f"cooperative lock is not a regular file: {managed_lock_path(path)}")
3529
+ if hasattr(os, "fchmod"):
3530
+ os.fchmod(fd, 0o600)
3531
+ fcntl.flock(fd, fcntl.LOCK_EX)
3532
+ return fd
3533
+ except Exception:
3534
+ os.close(fd)
3535
+ raise
3536
+
3537
+
3538
+ def release_managed_file_lock(fd: int) -> None:
3539
+ try:
3540
+ if fcntl is not None:
3541
+ fcntl.flock(fd, fcntl.LOCK_UN)
3542
+ finally:
3543
+ os.close(fd)
3544
+
3545
+
3546
+ def _managed_backup(path: Path, data: bytes, *, dir_mode: int) -> Path:
3547
+ stamp = _dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
3548
+ backup = path.with_name(f"{path.name}.bak-{stamp}-{uuid.uuid4().hex[:8]}")
3549
+ atomic_write_bytes(backup, data, 0o600, dir_mode=dir_mode)
3550
+ return backup
3551
+
3552
+
3553
+ def write_managed_file(
3554
+ path: Path,
3555
+ *,
3556
+ expected: ManagedFileSnapshot,
3557
+ desired: bytes | None,
3558
+ mode: int = 0o644,
3559
+ dir_mode: int = 0o755,
3560
+ create_backup: bool = True,
3561
+ prepare_commit: Any = None,
3562
+ ) -> dict[str, Any]:
3563
+ """Apply one cooperative byte-exact transaction or return a fail-closed status."""
3564
+ if desired == expected.data:
3565
+ return {"status": "unchanged", "backup_path": None}
3566
+ try:
3567
+ lock_fd = acquire_managed_file_lock(path, dir_mode=dir_mode)
3568
+ except OSError as exc:
3569
+ return {"status": "skipped", "reason": f"could not acquire cooperative lock: {exc}"}
3570
+ backup_path: Path | None = None
3571
+ try:
3572
+ try:
3573
+ current = _verify_expected_snapshot(path, expected)
3574
+ except ManagedFileConflictError as exc:
3575
+ return {"status": "conflict", "reason": str(exc), "backup_path": None}
3576
+
3577
+ target_mode = mode
3578
+ if current.metadata is not None:
3579
+ target_mode = stat.S_IMODE(current.metadata[2])
3580
+ if current.data is not None and create_backup:
3581
+ try:
3582
+ backup_path = _managed_backup(path, current.data, dir_mode=dir_mode)
3583
+ except OSError as exc:
3584
+ return {
3585
+ "status": "skipped",
3586
+ "reason": f"could not create private managed-file backup: {exc}",
3587
+ "backup_path": None,
3588
+ }
3589
+ try:
3590
+ if desired is None:
3591
+ if prepare_commit is not None:
3592
+ prepare_commit(backup_path)
3593
+ _atomic_remove_expected(path, current, dir_mode=dir_mode)
3594
+ else:
3595
+ _verify_expected_snapshot(path, current)
3596
+ if prepare_commit is not None:
3597
+ prepare_commit(backup_path)
3598
+ atomic_write(
3599
+ path,
3600
+ desired,
3601
+ target_mode,
3602
+ dir_mode=dir_mode,
3603
+ )
3604
+ except ManagedFileConflictError as exc:
3605
+ return {
3606
+ "status": "conflict",
3607
+ "reason": str(exc),
3608
+ "backup_path": str(backup_path) if backup_path else None,
3609
+ }
3610
+ except AtomicWriteDurabilityError as exc:
3611
+ return {
3612
+ "status": "applied-durability-uncertain",
3613
+ "reason": str(exc),
3614
+ "backup_path": str(backup_path) if backup_path else None,
3615
+ "residual_risk": (
3616
+ "A non-cooperating editor can still race after the final comparison; "
3617
+ "automatic follow-on mutation is blocked."
3618
+ ),
3619
+ }
3620
+ except OSError as exc:
3621
+ return {
3622
+ "status": "skipped",
3623
+ "reason": f"could not commit managed file: {exc}",
3624
+ "backup_path": str(backup_path) if backup_path else None,
3625
+ }
3626
+ return {
3627
+ "status": "applied",
3628
+ "backup_path": str(backup_path) if backup_path else None,
3629
+ "residual_risk": (
3630
+ "Cooperating ContextGuard writers serialize; a non-cooperating editor can still race "
3631
+ "after the final comparison and before atomic replace."
3632
+ ),
3633
+ }
3634
+ finally:
3635
+ release_managed_file_lock(lock_fd)
3636
+
3637
+
3638
+ def rollback_managed_file(
3639
+ path: Path,
3640
+ *,
3641
+ expected_post: ManagedFileSnapshot,
3642
+ restore: bytes | None,
3643
+ kind: str,
3644
+ mode: int = 0o644,
3645
+ dir_mode: int = 0o755,
3646
+ ) -> dict[str, Any]:
3647
+ """Rollback only a still-matching post-image with the same parser/lock authority."""
3648
+ if expected_post.data is None:
3649
+ return {"status": "skipped", "reason": "rollback post-image is missing"}
3650
+ ownership = parse_managed_bytes(expected_post.data, kind=kind)
3651
+ if ownership.status != "valid":
3652
+ return {
3653
+ "status": "skipped",
3654
+ "reason": f"rollback lacks valid {kind} ownership: {ownership.status}",
3655
+ }
3656
+ return write_managed_file(
3657
+ path,
3658
+ expected=expected_post,
3659
+ desired=restore,
3660
+ mode=mode,
3661
+ dir_mode=dir_mode,
3662
+ )
3663
+
3664
+
2213
3665
  def rollback_restore_guidance(settings_path: Path, backup_path: Path | None, original_existed: bool) -> str:
2214
3666
  if backup_path is not None:
2215
3667
  return (
@@ -2317,10 +3769,14 @@ def interactive_choices(defaults: Choices) -> Choices:
2317
3769
  denies=prompt_bool("Add deny rules for bulky/sensitive paths?", defaults.denies),
2318
3770
  statusline=prompt_bool("Enable token/cost statusline?", defaults.statusline),
2319
3771
  bash_hook=prompt_bool("Enable Bash output trim + grep/diff sanitizer hook?", defaults.bash_hook),
3772
+ bash_reference_v1=prompt_bool(
3773
+ "Enable optional Bash receipt references? 7-day scoped bearer handles are visible to Claude/provider transcripts",
3774
+ defaults.bash_reference_v1,
3775
+ ),
2320
3776
  read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
2321
3777
  model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
2322
3778
  failed_attempt_nudge=prompt_bool(
2323
- "Enable failed-attempt /clear nudge? (PostToolUse hook on Bash; recommended default)",
3779
+ "Enable failed-attempt /clear nudge? (Bash terminal-event hooks; recommended default)",
2324
3780
  defaults.failed_attempt_nudge,
2325
3781
  ),
2326
3782
  )
@@ -2332,6 +3788,7 @@ def choices_from_args(args: argparse.Namespace) -> Choices:
2332
3788
  denies=not args.no_denies,
2333
3789
  statusline=not args.no_statusline,
2334
3790
  bash_hook=not args.no_bash_hook,
3791
+ bash_reference_v1=getattr(args, "bash_reference_v1", False),
2335
3792
  read_guard=not args.no_read_guard,
2336
3793
  model_defaults=not args.no_model_defaults,
2337
3794
  failed_attempt_nudge=(
@@ -2396,6 +3853,201 @@ def render_text(result: SetupResult) -> str:
2396
3853
  return "\n".join(lines) + "\n"
2397
3854
 
2398
3855
 
3856
+ def validate_rules_only_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> bool:
3857
+ """Validate and identify the isolated Claude quiet-narration CLI branch."""
3858
+ rules_only = bool(getattr(args, "rules_only", False))
3859
+ narration_mode = getattr(args, "narration_mode", None)
3860
+ if narration_mode and not rules_only:
3861
+ parser.error("--narration-mode requires --rules-only")
3862
+ if rules_only and not narration_mode:
3863
+ parser.error("--rules-only requires a rule operation such as --narration-mode")
3864
+ if not rules_only:
3865
+ return False
3866
+
3867
+ if getattr(args, "scope", "project") != "project":
3868
+ parser.error("quiet narration rules support only --scope project")
3869
+ selected = [item.lower() for item in (explicit_agent_selection(args) or [])]
3870
+ if selected != ["claude"] or not getattr(args, "agent", None) or getattr(args, "only", None):
3871
+ parser.error("quiet narration rules require exactly one explicit --agent claude")
3872
+ action_count = sum(
3873
+ bool(value)
3874
+ for value in (
3875
+ getattr(args, "yes", False),
3876
+ getattr(args, "plan", False),
3877
+ getattr(args, "dry_run", False),
3878
+ )
3879
+ )
3880
+ if action_count != 1:
3881
+ parser.error("quiet narration rules require exactly one of --plan, --dry-run, or --yes")
3882
+
3883
+ conflicting = [
3884
+ flag
3885
+ for attr, flag in (
3886
+ ("allow_home_settings", "--allow-home-settings"),
3887
+ ("verify", "--verify"),
3888
+ ("no_backup", "--no-backup"),
3889
+ ("no_denies", "--no-denies"),
3890
+ ("no_statusline", "--no-statusline"),
3891
+ ("no_bash_hook", "--no-bash-hook"),
3892
+ ("no_read_guard", "--no-read-guard"),
3893
+ ("no_model_defaults", "--no-model-defaults"),
3894
+ ("no_diet_scan", "--no-diet-scan"),
3895
+ ("allow_path_helper_fallback", "--allow-path-helper-fallback"),
3896
+ ("with_init", "--with-init"),
3897
+ ("with_skill", "--with-skill"),
3898
+ ("brief_mode", "--brief-mode"),
3899
+ ("list_adapters", "--list-adapters"),
3900
+ )
3901
+ if getattr(args, attr, False)
3902
+ ]
3903
+ if getattr(args, "failed_attempt_nudge", None) is not None:
3904
+ conflicting.append(
3905
+ "--failed-attempt-nudge"
3906
+ if args.failed_attempt_nudge
3907
+ else "--no-failed-attempt-nudge"
3908
+ )
3909
+ if conflicting:
3910
+ parser.error(
3911
+ "quiet narration rules cannot be combined with settings, hook, adapter, "
3912
+ f"or setup flags: {', '.join(conflicting)}"
3913
+ )
3914
+ return True
3915
+
3916
+
3917
+ def run_quiet_narration_rules(args: argparse.Namespace) -> dict[str, Any]:
3918
+ """Plan/apply the isolated Claude/project CLAUDE.md narration span."""
3919
+ require_no_follow_file_ops_supported()
3920
+ root = resolve_setup_root(args.root)
3921
+ rule_path = root / "CLAUDE.md"
3922
+ state = _rule_file_state(rule_path)
3923
+ if state["status"] not in {"missing", "file"}:
3924
+ raise SystemExit(
3925
+ state.get("reason")
3926
+ or f"refused unsafe quiet narration rule target: {rule_path}"
3927
+ )
3928
+ existing = bytes(state.get("bytes") or b"")
3929
+ parsed = parse_managed_bytes(existing, kind="narration-mode")
3930
+ if parsed.status not in {"absent", "valid"}:
3931
+ raise SystemExit(
3932
+ f"refused unsafe managed narration state in {rule_path.name}: "
3933
+ f"{parsed.reason or parsed.status}"
3934
+ )
3935
+
3936
+ mode = str(args.narration_mode)
3937
+ span = parsed.spans[0] if parsed.status == "valid" else None
3938
+ desired = existing
3939
+ if mode == "quiet":
3940
+ block = _managed_block_bytes(render_quiet_narration_block())
3941
+ if span is None:
3942
+ desired = _append_narration_block_bytes(existing, block)
3943
+ elif existing[span.start : span.end] != block:
3944
+ desired = _replace_managed_span(existing, span, block)
3945
+ elif span is not None:
3946
+ removal_start = span.start
3947
+ if removal_start > 0 and existing[removal_start - 1 : removal_start] == b"\n":
3948
+ removal_start -= 1
3949
+ desired = existing[:removal_start] + existing[span.end :]
3950
+
3951
+ changed = desired != existing
3952
+ apply_requested = bool(args.yes)
3953
+ if not changed:
3954
+ status = "exists" if mode == "quiet" else "absent"
3955
+ return {
3956
+ "schema_version": "contextguard.narration-rules.v1",
3957
+ "operation": "quiet-narration",
3958
+ "mode": mode,
3959
+ "root": str(root),
3960
+ "rule_file": str(rule_path),
3961
+ "marker_state_before": parsed.status,
3962
+ "status": status,
3963
+ "changed": False,
3964
+ "applied": False,
3965
+ "apply_requested": apply_requested,
3966
+ "backup_path": None,
3967
+ "actions": [
3968
+ "quiet narration rules already present"
3969
+ if mode == "quiet"
3970
+ else "quiet narration rules already absent"
3971
+ ],
3972
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
3973
+ }
3974
+
3975
+ planned_status = "planned"
3976
+ action = (
3977
+ ("add" if span is None else "refresh") + " quiet narration rules"
3978
+ if mode == "quiet"
3979
+ else "remove quiet narration rules"
3980
+ )
3981
+ if not apply_requested:
3982
+ return {
3983
+ "schema_version": "contextguard.narration-rules.v1",
3984
+ "operation": "quiet-narration",
3985
+ "mode": mode,
3986
+ "root": str(root),
3987
+ "rule_file": str(rule_path),
3988
+ "marker_state_before": parsed.status,
3989
+ "status": planned_status,
3990
+ "changed": True,
3991
+ "applied": False,
3992
+ "apply_requested": False,
3993
+ "backup_path": None,
3994
+ "actions": [f"would {action}"],
3995
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
3996
+ }
3997
+
3998
+ write_result = write_managed_file(
3999
+ rule_path,
4000
+ expected=state["snapshot"],
4001
+ desired=desired,
4002
+ mode=existing_mode_or_default(rule_path, 0o644),
4003
+ dir_mode=0o755,
4004
+ )
4005
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
4006
+ raise SystemExit(
4007
+ write_result.get("reason")
4008
+ or f"could not safely update quiet narration rules in {rule_path}"
4009
+ )
4010
+ status = (
4011
+ write_result["status"]
4012
+ if write_result["status"] == "applied-durability-uncertain"
4013
+ else ("removed" if mode == "default" else ("applied" if span is None else "updated"))
4014
+ )
4015
+ payload = {
4016
+ "schema_version": "contextguard.narration-rules.v1",
4017
+ "operation": "quiet-narration",
4018
+ "mode": mode,
4019
+ "root": str(root),
4020
+ "rule_file": str(rule_path),
4021
+ "marker_state_before": parsed.status,
4022
+ "status": status,
4023
+ "changed": True,
4024
+ "applied": True,
4025
+ "apply_requested": True,
4026
+ "backup_path": write_result.get("backup_path"),
4027
+ "actions": [action],
4028
+ "claim_boundary": "static setup result only; no model-compliance or savings claim",
4029
+ }
4030
+ if write_result.get("reason"):
4031
+ payload["warning"] = write_result["reason"]
4032
+ if write_result.get("residual_risk"):
4033
+ payload["residual_risk"] = write_result["residual_risk"]
4034
+ return payload
4035
+
4036
+
4037
+ def render_quiet_narration_text(result: dict[str, Any]) -> str:
4038
+ lines = [
4039
+ f"ContextGuard quiet narration ({result['status']})",
4040
+ f"root={result['root']}",
4041
+ f"rule_file={result['rule_file']}",
4042
+ f"mode={result['mode']}",
4043
+ ]
4044
+ if result.get("backup_path"):
4045
+ lines.append(f"backup={result['backup_path']}")
4046
+ lines.extend(f"- {action}" for action in result.get("actions", []))
4047
+ lines.append(str(result["claim_boundary"]))
4048
+ return "\n".join(lines) + "\n"
4049
+
4050
+
2399
4051
  def run(args: argparse.Namespace) -> SetupResult:
2400
4052
  require_no_follow_file_ops_supported()
2401
4053
  scope = normalize_scope(getattr(args, "scope", "project"))
@@ -2416,10 +4068,21 @@ def run(args: argparse.Namespace) -> SetupResult:
2416
4068
  if claude_targeted:
2417
4069
  validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
2418
4070
  original_text = _read_optional_text_no_follow(settings_path)
4071
+ settings_snapshot = read_managed_file_snapshot(settings_path)
4072
+ snapshot_text = (
4073
+ settings_snapshot.data.decode("utf-8")
4074
+ if settings_snapshot.data is not None
4075
+ else None
4076
+ )
4077
+ if snapshot_text != original_text:
4078
+ raise SystemExit(
4079
+ f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
4080
+ )
2419
4081
  original = _parse_json_object_text(original_text, settings_path)
2420
4082
  settings = json.loads(json.dumps(original))
2421
4083
  else:
2422
4084
  original_text = None
4085
+ settings_snapshot = ManagedFileSnapshot(None, None)
2423
4086
  original = {}
2424
4087
  settings = {}
2425
4088
 
@@ -2434,7 +4097,20 @@ def run(args: argparse.Namespace) -> SetupResult:
2434
4097
  if interactive:
2435
4098
  choices = interactive_choices(choices)
2436
4099
 
2437
- actions = apply_choices(settings, choices, allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False))) if claude_targeted else []
4100
+ reference_actions = disable_unavailable_bash_reference(
4101
+ choices,
4102
+ warnings,
4103
+ root=root,
4104
+ )
4105
+ actions = reference_actions + (
4106
+ apply_choices(
4107
+ settings,
4108
+ choices,
4109
+ allow_path_fallback=bool(getattr(args, "allow_path_helper_fallback", False)),
4110
+ )
4111
+ if claude_targeted
4112
+ else []
4113
+ )
2438
4114
  changed = (settings != original) if claude_targeted else False
2439
4115
 
2440
4116
  apply_requested = bool(args.yes and not args.dry_run and not args.plan)
@@ -2471,31 +4147,45 @@ def run(args: argparse.Namespace) -> SetupResult:
2471
4147
  if claude_targeted and apply_requested and changed:
2472
4148
  if scope == "user" and original_text is not None and args.no_backup:
2473
4149
  raise SystemExit("Refusing --no-backup for user-scope changes to existing Claude settings.")
2474
- lock_fd = acquire_settings_lock(settings_path)
2475
- try:
2476
- current_text = _read_optional_text_no_follow(settings_path)
2477
- if current_text != original_text:
2478
- raise SystemExit(
2479
- f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
2480
- )
2481
- if original_text is not None and not args.no_backup and settings != original:
2482
- backup_path = backup_existing(settings_path)
2483
- if settings != original:
2484
- rollback_id, rollback_path = write_rollback_record(
2485
- root=root,
2486
- scope=scope,
2487
- settings_path=settings_path,
2488
- backup_path=backup_path,
2489
- original_existed=(original_text is not None),
2490
- )
2491
- atomic_write(
2492
- settings_path,
2493
- json.dumps(settings, indent=2, sort_keys=True) + "\n",
2494
- existing_mode_or_default(settings_path, 0o600),
2495
- )
2496
- claude_settings_written = True
2497
- finally:
2498
- release_settings_lock(lock_fd)
4150
+ rollback_state: dict[str, Any] = {}
4151
+
4152
+ def prepare_settings_commit(managed_backup_path: Path | None) -> None:
4153
+ prepared_rollback_id, prepared_rollback_path = write_rollback_record(
4154
+ root=root,
4155
+ scope=scope,
4156
+ settings_path=settings_path,
4157
+ backup_path=managed_backup_path,
4158
+ original_existed=(original_text is not None),
4159
+ )
4160
+ rollback_state.update({
4161
+ "rollback_id": prepared_rollback_id,
4162
+ "rollback_path": prepared_rollback_path,
4163
+ })
4164
+
4165
+ desired_settings = (
4166
+ json.dumps(settings, indent=2, sort_keys=True) + "\n"
4167
+ ).encode("utf-8")
4168
+ write_result = write_managed_file(
4169
+ settings_path,
4170
+ expected=settings_snapshot,
4171
+ desired=desired_settings,
4172
+ mode=existing_mode_or_default(settings_path, 0o600),
4173
+ dir_mode=PRIVATE_DIR_MODE,
4174
+ create_backup=not args.no_backup,
4175
+ prepare_commit=prepare_settings_commit,
4176
+ )
4177
+ if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
4178
+ reason = write_result.get("reason") or "managed settings transaction was not applied"
4179
+ raise SystemExit(f"Could not safely update {settings_path}: {reason}")
4180
+ if write_result.get("backup_path"):
4181
+ backup_path = Path(write_result["backup_path"])
4182
+ rollback_id = rollback_state.get("rollback_id")
4183
+ rollback_path = rollback_state.get("rollback_path")
4184
+ if write_result["status"] == "applied-durability-uncertain" and write_result.get("reason"):
4185
+ warnings.append(str(write_result["reason"]))
4186
+ if write_result.get("residual_risk"):
4187
+ warnings.append(str(write_result["residual_risk"]))
4188
+ claude_settings_written = True
2499
4189
 
2500
4190
  # Build the per-adapter plan; repo-rule writes happen here when an applying
2501
4191
  # run (--yes) requested --with-init or project-scope --brief-mode.
@@ -2559,10 +4249,34 @@ def build_parser() -> argparse.ArgumentParser:
2559
4249
  parser.add_argument("--dry-run", action="store_true", help="alias for --plan")
2560
4250
  parser.add_argument("--verify", action="store_true", help="run a read-only setup health check; never writes or prompts")
2561
4251
  parser.add_argument("--json", action="store_true", help="print machine-readable result")
4252
+ parser.add_argument(
4253
+ "--rules-only",
4254
+ action="store_true",
4255
+ help="run an isolated rule-file operation without reading or changing settings/hooks",
4256
+ )
4257
+ parser.add_argument(
4258
+ "--narration-mode",
4259
+ choices=NARRATION_MODE_CHOICES,
4260
+ default=None,
4261
+ help="with --rules-only, add quiet Claude narration guidance or restore default behavior",
4262
+ )
2562
4263
  parser.add_argument("--no-backup", action="store_true", help="do not create .bak-* before modifying existing settings")
2563
4264
  parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
2564
4265
  parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
2565
4266
  parser.add_argument("--no-bash-hook", action="store_true", help="skip Bash trim/sanitize hook")
4267
+ reference_group = parser.add_mutually_exclusive_group()
4268
+ reference_group.add_argument(
4269
+ "--bash-reference-v1",
4270
+ action="store_true",
4271
+ help="opt in to 7-day scoped receipt references in the Bash hook; handles are provider-visible",
4272
+ )
4273
+ reference_group.add_argument(
4274
+ "--no-bash-reference-v1",
4275
+ dest="bash_reference_v1",
4276
+ action="store_false",
4277
+ help="disable/remove the optional Bash receipt-reference hook flag (default)",
4278
+ )
4279
+ parser.set_defaults(bash_reference_v1=False)
2566
4280
  parser.add_argument("--no-read-guard", action="store_true", help="skip large Read guard hook")
2567
4281
  parser.add_argument("--no-model-defaults", action="store_true", help="skip model/effort defaults")
2568
4282
  parser.add_argument("--no-diet-scan", action="store_true", help="skip the read-only diet scan summary after applying setup")
@@ -2613,7 +4327,7 @@ def build_parser() -> argparse.ArgumentParser:
2613
4327
  dest="failed_attempt_nudge",
2614
4328
  action="store_true",
2615
4329
  default=None,
2616
- help="enable PostToolUse Bash hook that suggests /clear when the same command fails twice in a row (recommended default)",
4330
+ help="enable Bash terminal-event hooks that suggest /clear when the same command fails twice in a row (recommended default)",
2617
4331
  )
2618
4332
  nudge_group.add_argument(
2619
4333
  "--no-failed-attempt-nudge",
@@ -2628,6 +4342,14 @@ def build_parser() -> argparse.ArgumentParser:
2628
4342
  def main() -> int:
2629
4343
  parser = build_parser()
2630
4344
  args = parser.parse_args()
4345
+ rules_only = validate_rules_only_args(parser, args)
4346
+ if rules_only:
4347
+ result = run_quiet_narration_rules(args)
4348
+ if args.json:
4349
+ print(json.dumps(result, indent=2, sort_keys=True))
4350
+ else:
4351
+ print(render_quiet_narration_text(result), end="")
4352
+ return 0
2631
4353
  if args.dry_run:
2632
4354
  args.plan = True
2633
4355
  if args.verify and args.yes: