@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.
- package/CHANGELOG.md +80 -0
- package/README.ko.md +128 -2
- package/README.md +144 -3
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -1
- package/plugins/context-guard/README.md +44 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +9865 -211
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +777 -83
- package/plugins/context-guard/bin/context-guard-guard-read +496 -57
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1570 -150
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
- package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
- package/plugins/context-guard/bin/context-guard-setup +1944 -222
- package/plugins/context-guard/bin/context-guard-statusline +163 -55
- package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +795 -48
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +10 -2
- package/plugins/context-guard/lib/credential_policy.py +185 -0
- 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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
498
|
-
|
|
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[
|
|
503
|
-
"""Compose
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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":
|
|
510
|
-
"
|
|
865
|
+
"init_present_before": False,
|
|
866
|
+
"init_migrated_legacy": False,
|
|
867
|
+
"brief_levels_before": before_brief,
|
|
511
868
|
"brief_changed": False,
|
|
512
869
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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("
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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"] = "
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
789
|
-
if
|
|
1192
|
+
data = bytes(state.get("bytes") or b"")
|
|
1193
|
+
if data == render_codex_skill().encode("utf-8"):
|
|
790
1194
|
return "exists"
|
|
791
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
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
|
|
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, "
|
|
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(
|
|
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
|
|
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
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
1532
|
-
|
|
1533
|
-
"
|
|
1534
|
-
|
|
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
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
"
|
|
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
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
|
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
|
-
|
|
1600
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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, "
|
|
3415
|
+
with os.fdopen(fd, "wb") as f:
|
|
2166
3416
|
fd = -1
|
|
2167
|
-
f.write(
|
|
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? (
|
|
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
|
-
|
|
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
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
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
|
|
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:
|