@ictechgy/context-guard 0.4.14 → 0.4.16
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 +15 -0
- package/README.ko.md +72 -1
- package/README.md +85 -2
- package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
- package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
- package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
- package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
- package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +16 -0
- package/docs/experimental-benchmark-fixtures.md +52 -1
- package/package.json +2 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -0
- package/plugins/context-guard/README.md +43 -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 +7038 -307
- 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 +3085 -134
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-mcp +999 -0
- package/plugins/context-guard/bin/context-guard-pack +744 -20
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- 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 +14 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -10,6 +10,7 @@ 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
|
|
15
16
|
import re
|
|
@@ -46,8 +47,6 @@ RECOMMENDED_DENIES = [
|
|
|
46
47
|
"Read(./vendor/**)",
|
|
47
48
|
"Read(./.context-guard/**)",
|
|
48
49
|
"Read(./.claude-token-optimizer/**)",
|
|
49
|
-
"Read(./.env)",
|
|
50
|
-
"Read(./.env.*)",
|
|
51
50
|
"Read(./.npmrc)",
|
|
52
51
|
"Read(./.pypirc)",
|
|
53
52
|
"Read(./.netrc)",
|
|
@@ -57,6 +56,10 @@ RECOMMENDED_DENIES = [
|
|
|
57
56
|
"Read(~/.kube/**)",
|
|
58
57
|
"Read(~/.docker/**)",
|
|
59
58
|
]
|
|
59
|
+
PRODUCT_OWNED_ENV_READ_DENIES = frozenset({
|
|
60
|
+
"Read(./.env)",
|
|
61
|
+
"Read(./.env.*)",
|
|
62
|
+
})
|
|
60
63
|
HELPER_STATUSLINE = "context-guard-statusline-merged"
|
|
61
64
|
HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
|
|
62
65
|
HELPER_GUARD_READ = "context-guard-guard-read"
|
|
@@ -165,14 +168,19 @@ class SetupResult:
|
|
|
165
168
|
# - native-skill / report-only agents are never written to; they are reported.
|
|
166
169
|
# It never sends work to external providers and never promises token/cost savings.
|
|
167
170
|
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
LEGACY_ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
|
|
172
|
+
LEGACY_ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
|
|
173
|
+
ADAPTER_RULE_BLOCK_BEGIN = "<!-- BEGIN context-guard:repo-rules version=1 -->"
|
|
174
|
+
ADAPTER_RULE_BLOCK_END = "<!-- END context-guard:repo-rules -->"
|
|
170
175
|
CODEX_SKILL_REL = ".agents/skills/context-guard/SKILL.md"
|
|
171
|
-
|
|
172
|
-
|
|
176
|
+
LEGACY_CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
|
|
177
|
+
LEGACY_CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
|
|
178
|
+
CODEX_SKILL_MARKER_BEGIN = "<!-- BEGIN context-guard:codex-skill version=1 -->"
|
|
179
|
+
CODEX_SKILL_MARKER_END = "<!-- END context-guard:codex-skill -->"
|
|
173
180
|
BRIEF_MODE_LEVELS = ("lite", "standard", "ultra")
|
|
174
181
|
BRIEF_MODE_OFF = "off"
|
|
175
182
|
BRIEF_MODE_CHOICES = (*BRIEF_MODE_LEVELS, BRIEF_MODE_OFF)
|
|
183
|
+
NARRATION_MODE_CHOICES = ("quiet", "default")
|
|
176
184
|
BRIEF_MODE_BLOCK_END = "<!-- END context-guard:brief-mode -->"
|
|
177
185
|
BRIEF_MODE_BEGIN_RE = re.compile(
|
|
178
186
|
r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
|
|
@@ -186,6 +194,214 @@ BRIEF_MODE_BLOCK_RE = re.compile(
|
|
|
186
194
|
re.DOTALL,
|
|
187
195
|
)
|
|
188
196
|
|
|
197
|
+
LEGACY_REPO_RULE_MARKER_BEGIN = LEGACY_ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
|
|
198
|
+
LEGACY_REPO_RULE_MARKER_END = LEGACY_ADAPTER_RULE_BLOCK_END.encode("ascii")
|
|
199
|
+
REPO_RULE_MARKER_V1_BEGIN = ADAPTER_RULE_BLOCK_BEGIN.encode("ascii")
|
|
200
|
+
REPO_RULE_MARKER_V1_END = ADAPTER_RULE_BLOCK_END.encode("ascii")
|
|
201
|
+
LEGACY_CODEX_SKILL_MARKER_V0_BEGIN = LEGACY_CODEX_SKILL_MARKER_BEGIN.encode("ascii")
|
|
202
|
+
LEGACY_CODEX_SKILL_MARKER_V0_END = LEGACY_CODEX_SKILL_MARKER_END.encode("ascii")
|
|
203
|
+
CODEX_SKILL_MARKER_V1_BEGIN = CODEX_SKILL_MARKER_BEGIN.encode("ascii")
|
|
204
|
+
CODEX_SKILL_MARKER_V1_END = CODEX_SKILL_MARKER_END.encode("ascii")
|
|
205
|
+
BRIEF_MODE_MARKER_END = BRIEF_MODE_BLOCK_END.encode("ascii")
|
|
206
|
+
NARRATION_MODE_MARKER_BEGIN = b"<!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->"
|
|
207
|
+
NARRATION_MODE_MARKER_END = b"<!-- END context-guard:narration-mode -->"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class ManagedMarker:
|
|
212
|
+
kind: str
|
|
213
|
+
version: int
|
|
214
|
+
begin: bytes
|
|
215
|
+
end: bytes
|
|
216
|
+
variant: str | None = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
MANAGED_MARKERS = (
|
|
220
|
+
ManagedMarker(
|
|
221
|
+
"repo-rules",
|
|
222
|
+
0,
|
|
223
|
+
LEGACY_REPO_RULE_MARKER_BEGIN,
|
|
224
|
+
LEGACY_REPO_RULE_MARKER_END,
|
|
225
|
+
"legacy",
|
|
226
|
+
),
|
|
227
|
+
ManagedMarker(
|
|
228
|
+
"repo-rules",
|
|
229
|
+
1,
|
|
230
|
+
REPO_RULE_MARKER_V1_BEGIN,
|
|
231
|
+
REPO_RULE_MARKER_V1_END,
|
|
232
|
+
),
|
|
233
|
+
ManagedMarker(
|
|
234
|
+
"codex-skill",
|
|
235
|
+
0,
|
|
236
|
+
LEGACY_CODEX_SKILL_MARKER_V0_BEGIN,
|
|
237
|
+
LEGACY_CODEX_SKILL_MARKER_V0_END,
|
|
238
|
+
"legacy",
|
|
239
|
+
),
|
|
240
|
+
ManagedMarker(
|
|
241
|
+
"codex-skill",
|
|
242
|
+
1,
|
|
243
|
+
CODEX_SKILL_MARKER_V1_BEGIN,
|
|
244
|
+
CODEX_SKILL_MARKER_V1_END,
|
|
245
|
+
),
|
|
246
|
+
*(
|
|
247
|
+
ManagedMarker(
|
|
248
|
+
"brief-mode",
|
|
249
|
+
1,
|
|
250
|
+
f"<!-- BEGIN context-guard:brief-mode level={level} version=1 -->".encode("ascii"),
|
|
251
|
+
BRIEF_MODE_MARKER_END,
|
|
252
|
+
level,
|
|
253
|
+
)
|
|
254
|
+
for level in BRIEF_MODE_LEVELS
|
|
255
|
+
),
|
|
256
|
+
ManagedMarker(
|
|
257
|
+
"narration-mode",
|
|
258
|
+
1,
|
|
259
|
+
NARRATION_MODE_MARKER_BEGIN,
|
|
260
|
+
NARRATION_MODE_MARKER_END,
|
|
261
|
+
"quiet",
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
_MANAGED_BEGIN_MARKERS = {marker.begin: marker for marker in MANAGED_MARKERS}
|
|
265
|
+
_MANAGED_END_MARKERS: dict[bytes, tuple[ManagedMarker, ...]] = {}
|
|
266
|
+
for _marker in MANAGED_MARKERS:
|
|
267
|
+
_MANAGED_END_MARKERS[_marker.end] = (*_MANAGED_END_MARKERS.get(_marker.end, ()), _marker)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True)
|
|
271
|
+
class ManagedSpan:
|
|
272
|
+
kind: str
|
|
273
|
+
version: int
|
|
274
|
+
variant: str | None
|
|
275
|
+
start: int
|
|
276
|
+
end: int
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class ManagedParseResult:
|
|
281
|
+
status: str
|
|
282
|
+
spans: tuple[ManagedSpan, ...] = ()
|
|
283
|
+
reason: str | None = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class ManagedFileSnapshot:
|
|
288
|
+
data: bytes | None
|
|
289
|
+
metadata: tuple[int, int, int, int, int] | None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ManagedFileConflictError(OSError):
|
|
293
|
+
"""Raised when a managed target no longer matches its planned snapshot."""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _iter_binary_lines(data: bytes):
|
|
297
|
+
offset = 0
|
|
298
|
+
while offset < len(data):
|
|
299
|
+
newline = data.find(b"\n", offset)
|
|
300
|
+
if newline < 0:
|
|
301
|
+
yield offset, len(data), data[offset:], b""
|
|
302
|
+
return
|
|
303
|
+
end = newline + 1
|
|
304
|
+
if newline > offset and data[newline - 1 : newline] == b"\r":
|
|
305
|
+
content, ending = data[offset : newline - 1], b"\r\n"
|
|
306
|
+
else:
|
|
307
|
+
content, ending = data[offset:newline], b"\n"
|
|
308
|
+
yield offset, end, content, ending
|
|
309
|
+
offset = end
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _fence_open(content: bytes) -> tuple[int, int] | None:
|
|
313
|
+
match = re.match(rb"^ {0,3}(`{3,}|~{3,}).*$", content)
|
|
314
|
+
if not match:
|
|
315
|
+
return None
|
|
316
|
+
run = match.group(1)
|
|
317
|
+
return run[0], len(run)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fence_close(content: bytes, fence: tuple[int, int]) -> bool:
|
|
321
|
+
char, minimum = fence
|
|
322
|
+
match = re.match(rb"^ {0,3}([`~]+) *$", content)
|
|
323
|
+
if not match:
|
|
324
|
+
return False
|
|
325
|
+
run = match.group(1)
|
|
326
|
+
return bool(run and run[0] == char and len(run) >= minimum and all(byte == char for byte in run))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _looks_like_contextguard_marker(content: bytes) -> bool:
|
|
330
|
+
lowered = content.lstrip(b" \t").lower()
|
|
331
|
+
return (
|
|
332
|
+
lowered.startswith(b"<!--")
|
|
333
|
+
and b"-->" in lowered
|
|
334
|
+
and (b"contextguard:" in lowered or b"context-guard:" in lowered)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scan_managed_spans(data: bytes) -> ManagedParseResult:
|
|
339
|
+
spans: list[ManagedSpan] = []
|
|
340
|
+
open_marker: tuple[ManagedMarker, int] | None = None
|
|
341
|
+
fence: tuple[int, int] | None = None
|
|
342
|
+
unsupported = False
|
|
343
|
+
malformed = False
|
|
344
|
+
for start, end, content, ending in _iter_binary_lines(data):
|
|
345
|
+
if fence is not None:
|
|
346
|
+
if _fence_close(content, fence):
|
|
347
|
+
fence = None
|
|
348
|
+
continue
|
|
349
|
+
opener = _fence_open(content)
|
|
350
|
+
if opener is not None:
|
|
351
|
+
fence = opener
|
|
352
|
+
continue
|
|
353
|
+
marker = _MANAGED_BEGIN_MARKERS.get(content) if ending else None
|
|
354
|
+
end_markers = _MANAGED_END_MARKERS.get(content, ()) if ending else ()
|
|
355
|
+
if marker is not None:
|
|
356
|
+
if open_marker is not None:
|
|
357
|
+
malformed = True
|
|
358
|
+
else:
|
|
359
|
+
open_marker = (marker, start)
|
|
360
|
+
continue
|
|
361
|
+
if end_markers:
|
|
362
|
+
if open_marker is None:
|
|
363
|
+
malformed = True
|
|
364
|
+
continue
|
|
365
|
+
active, span_start = open_marker
|
|
366
|
+
if active.end != content:
|
|
367
|
+
malformed = True
|
|
368
|
+
open_marker = None
|
|
369
|
+
continue
|
|
370
|
+
spans.append(
|
|
371
|
+
ManagedSpan(
|
|
372
|
+
kind=active.kind,
|
|
373
|
+
version=active.version,
|
|
374
|
+
variant=active.variant,
|
|
375
|
+
start=span_start,
|
|
376
|
+
end=end,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
open_marker = None
|
|
380
|
+
continue
|
|
381
|
+
if _looks_like_contextguard_marker(content):
|
|
382
|
+
unsupported = True
|
|
383
|
+
if open_marker is not None:
|
|
384
|
+
malformed = True
|
|
385
|
+
if malformed:
|
|
386
|
+
return ManagedParseResult("malformed", tuple(spans), "malformed managed marker structure")
|
|
387
|
+
if unsupported:
|
|
388
|
+
return ManagedParseResult("unsupported", tuple(spans), "unsupported managed marker literal")
|
|
389
|
+
return ManagedParseResult("valid" if spans else "absent", tuple(spans))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_managed_bytes(data: bytes, *, kind: str | None = None) -> ManagedParseResult:
|
|
393
|
+
"""Classify exact ContextGuard-managed raw-byte spans without decoding user bytes."""
|
|
394
|
+
scanned = _scan_managed_spans(data)
|
|
395
|
+
if scanned.status in {"malformed", "unsupported"}:
|
|
396
|
+
return scanned
|
|
397
|
+
spans = tuple(span for span in scanned.spans if kind is None or span.kind == kind)
|
|
398
|
+
by_kind: dict[str, int] = {}
|
|
399
|
+
for span in spans:
|
|
400
|
+
by_kind[span.kind] = by_kind.get(span.kind, 0) + 1
|
|
401
|
+
if any(count > 1 for count in by_kind.values()) or (kind is None and len(spans) > 1):
|
|
402
|
+
return ManagedParseResult("ambiguous", spans, "multiple managed spans")
|
|
403
|
+
return ManagedParseResult("valid" if spans else "absent", spans)
|
|
404
|
+
|
|
189
405
|
|
|
190
406
|
class CapabilityClass:
|
|
191
407
|
"""How ContextGuard can integrate with a given agent."""
|
|
@@ -375,15 +591,14 @@ def render_repo_rule_block() -> str:
|
|
|
375
591
|
])
|
|
376
592
|
|
|
377
593
|
|
|
378
|
-
def
|
|
379
|
-
"""Render the optional project-local Codex skill for ContextGuard."""
|
|
594
|
+
def _render_codex_skill_with_markers(begin: str, end: str) -> str:
|
|
380
595
|
return "\n".join([
|
|
381
596
|
"---",
|
|
382
597
|
"name: context-guard",
|
|
383
598
|
"description: Use ContextGuard helpers to keep Codex context focused with local-first setup, audit, trimming, and artifact commands.",
|
|
384
599
|
"---",
|
|
385
600
|
"",
|
|
386
|
-
|
|
601
|
+
begin,
|
|
387
602
|
"# ContextGuard for Codex",
|
|
388
603
|
"",
|
|
389
604
|
"Use this skill when a task would otherwise paste large files, long logs, or repeated setup context into Codex.",
|
|
@@ -400,11 +615,38 @@ def render_codex_skill() -> str:
|
|
|
400
615
|
"- If `context-guard` is not on PATH, install it explicitly or run via `npx @ictechgy/context-guard`.",
|
|
401
616
|
"",
|
|
402
617
|
"Do not claim fixed token or cost savings from these helpers; treat byte reductions as local proxy evidence only.",
|
|
403
|
-
|
|
618
|
+
end,
|
|
404
619
|
"",
|
|
405
620
|
])
|
|
406
621
|
|
|
407
622
|
|
|
623
|
+
def render_codex_skill() -> str:
|
|
624
|
+
"""Render the v1 project-local Codex skill."""
|
|
625
|
+
return _render_codex_skill_with_markers(CODEX_SKILL_MARKER_BEGIN, CODEX_SKILL_MARKER_END)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def render_legacy_codex_skill_v0() -> str:
|
|
629
|
+
"""Render the exact legacy whole-file image released before managed-span v1."""
|
|
630
|
+
return _render_codex_skill_with_markers(
|
|
631
|
+
LEGACY_CODEX_SKILL_MARKER_BEGIN,
|
|
632
|
+
LEGACY_CODEX_SKILL_MARKER_END,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
LEGACY_CODEX_SKILL_SHA256_ALLOWLIST = {
|
|
637
|
+
hashlib.sha256(render_legacy_codex_skill_v0().encode("utf-8")).hexdigest(): "legacy-v0-current",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def render_codex_skill_block_bytes() -> bytes:
|
|
642
|
+
rendered = render_codex_skill().encode("utf-8")
|
|
643
|
+
start = rendered.index(CODEX_SKILL_MARKER_V1_BEGIN)
|
|
644
|
+
end = rendered.index(CODEX_SKILL_MARKER_V1_END, start) + len(CODEX_SKILL_MARKER_V1_END)
|
|
645
|
+
if rendered[end : end + 1] == b"\n":
|
|
646
|
+
end += 1
|
|
647
|
+
return rendered[start:end]
|
|
648
|
+
|
|
649
|
+
|
|
408
650
|
def _brief_mode_source_candidates(level: str) -> list[Path]:
|
|
409
651
|
"""Return deterministic source candidates for packaged/repo brief snippets."""
|
|
410
652
|
filename = f"brief-mode.{level}.md"
|
|
@@ -476,6 +718,39 @@ def render_brief_mode_block(level: str) -> str:
|
|
|
476
718
|
return render_fallback_brief_mode_block(level)
|
|
477
719
|
|
|
478
720
|
|
|
721
|
+
def render_quiet_narration_block() -> str:
|
|
722
|
+
"""Render embedded canonical bytes without opening any non-target file."""
|
|
723
|
+
return "\n".join([
|
|
724
|
+
NARRATION_MODE_MARKER_BEGIN.decode("ascii"),
|
|
725
|
+
"## ContextGuard quiet narration (advisory)",
|
|
726
|
+
"",
|
|
727
|
+
"Best effort: reduce only discretionary intermediate narration. Skip routine preambles,",
|
|
728
|
+
"per-tool narration, filler, and repeated interim summaries when they add no useful",
|
|
729
|
+
"information.",
|
|
730
|
+
"",
|
|
731
|
+
"Always preserve required user-facing communication:",
|
|
732
|
+
"",
|
|
733
|
+
"- user approvals and decisions;",
|
|
734
|
+
"- blockers and failures;",
|
|
735
|
+
"- destructive-risk and security warnings;",
|
|
736
|
+
"- progress required by higher-priority instructions;",
|
|
737
|
+
"- the final result;",
|
|
738
|
+
"- changed files; and",
|
|
739
|
+
"- verification evidence.",
|
|
740
|
+
"",
|
|
741
|
+
"This mode does not require a shorter final answer and does not change reasoning effort.",
|
|
742
|
+
"It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,",
|
|
743
|
+
"and no numeric savings should be claimed without matched provider evidence.",
|
|
744
|
+
NARRATION_MODE_MARKER_END.decode("ascii"),
|
|
745
|
+
])
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _append_narration_block_bytes(existing: bytes, block: bytes) -> bytes:
|
|
749
|
+
"""Append one deterministic separator that default-mode removes with the span."""
|
|
750
|
+
block = block.rstrip(b"\r\n") + b"\n"
|
|
751
|
+
return block if not existing else existing + b"\n" + block
|
|
752
|
+
|
|
753
|
+
|
|
479
754
|
def _brief_mode_levels_in_text(text: str) -> list[str]:
|
|
480
755
|
return [match.group("level") for match in BRIEF_MODE_BLOCK_RE.finditer(text)]
|
|
481
756
|
|
|
@@ -494,37 +769,107 @@ def _append_managed_block(existing: str, block: str) -> str:
|
|
|
494
769
|
return block + "\n"
|
|
495
770
|
|
|
496
771
|
|
|
497
|
-
def
|
|
498
|
-
|
|
772
|
+
def _managed_block_bytes(block: str) -> bytes:
|
|
773
|
+
return block.encode("utf-8").rstrip(b"\r\n") + b"\n"
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _append_managed_block_bytes(existing: bytes, block: bytes) -> bytes:
|
|
777
|
+
block = block.rstrip(b"\r\n") + b"\n"
|
|
778
|
+
if not existing:
|
|
779
|
+
return block
|
|
780
|
+
if existing.endswith(b"\n\n"):
|
|
781
|
+
separator = b""
|
|
782
|
+
elif existing.endswith(b"\n"):
|
|
783
|
+
separator = b"\n"
|
|
784
|
+
else:
|
|
785
|
+
separator = b"\n\n"
|
|
786
|
+
return existing + separator + block
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _managed_span_for_kind(data: bytes, kind: str) -> ManagedSpan | None:
|
|
790
|
+
parsed = parse_managed_bytes(data, kind=kind)
|
|
791
|
+
if parsed.status == "absent":
|
|
792
|
+
return None
|
|
793
|
+
if parsed.status != "valid":
|
|
794
|
+
raise ValueError(parsed.reason or f"{parsed.status} managed {kind} markers")
|
|
795
|
+
return parsed.spans[0]
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _replace_managed_span(data: bytes, span: ManagedSpan, block: bytes) -> bytes:
|
|
799
|
+
return data[: span.start] + block.rstrip(b"\r\n") + b"\n" + data[span.end :]
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _brief_mode_levels_in_bytes(data: bytes) -> list[str]:
|
|
803
|
+
parsed = _scan_managed_spans(data)
|
|
804
|
+
return [
|
|
805
|
+
str(span.variant)
|
|
806
|
+
for span in parsed.spans
|
|
807
|
+
if span.kind == "brief-mode" and span.variant in BRIEF_MODE_LEVELS
|
|
808
|
+
]
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def compose_rule_file_bytes(
|
|
812
|
+
existing: bytes | None,
|
|
499
813
|
*,
|
|
500
814
|
with_init: bool,
|
|
501
815
|
brief_mode: str | None,
|
|
502
|
-
) -> tuple[
|
|
503
|
-
"""Compose
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
816
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
817
|
+
"""Compose rule-file mutations from exact owned spans, preserving all other bytes."""
|
|
818
|
+
data = existing or b""
|
|
819
|
+
original = data
|
|
820
|
+
before_brief = _brief_mode_levels_in_bytes(data)
|
|
507
821
|
meta: dict[str, Any] = {
|
|
508
822
|
"init_changed": False,
|
|
509
|
-
"init_present_before":
|
|
510
|
-
"
|
|
823
|
+
"init_present_before": False,
|
|
824
|
+
"init_migrated_legacy": False,
|
|
825
|
+
"brief_levels_before": before_brief,
|
|
511
826
|
"brief_changed": False,
|
|
512
827
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
828
|
+
repo_span = _managed_span_for_kind(data, "repo-rules")
|
|
829
|
+
meta["init_present_before"] = repo_span is not None
|
|
830
|
+
if with_init:
|
|
831
|
+
block = _managed_block_bytes(render_repo_rule_block())
|
|
832
|
+
if repo_span is None:
|
|
833
|
+
data = _append_managed_block_bytes(data, block)
|
|
834
|
+
meta["init_changed"] = True
|
|
835
|
+
elif data[repo_span.start : repo_span.end] != block:
|
|
836
|
+
data = _replace_managed_span(data, repo_span, block)
|
|
837
|
+
meta["init_changed"] = True
|
|
838
|
+
meta["init_migrated_legacy"] = repo_span.version == 0
|
|
839
|
+
|
|
516
840
|
if brief_mode:
|
|
517
|
-
|
|
841
|
+
span = _managed_span_for_kind(data, "brief-mode")
|
|
842
|
+
removed = [str(span.variant)] if span is not None and span.variant else []
|
|
843
|
+
meta["brief_levels_removed"] = removed
|
|
518
844
|
if brief_mode == BRIEF_MODE_OFF:
|
|
519
|
-
|
|
520
|
-
|
|
845
|
+
if span is not None:
|
|
846
|
+
data = data[: span.start] + data[span.end :]
|
|
847
|
+
meta["brief_changed"] = True
|
|
521
848
|
else:
|
|
522
|
-
block = render_brief_mode_block(brief_mode)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
849
|
+
block = _managed_block_bytes(render_brief_mode_block(brief_mode))
|
|
850
|
+
if span is None:
|
|
851
|
+
data = _append_managed_block_bytes(data, block)
|
|
852
|
+
meta["brief_changed"] = True
|
|
853
|
+
elif data[span.start : span.end] != block:
|
|
854
|
+
data = _replace_managed_span(data, span, block)
|
|
855
|
+
meta["brief_changed"] = True
|
|
856
|
+
meta["changed"] = data != original
|
|
857
|
+
return data, meta
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def compose_rule_file_text(
|
|
861
|
+
existing: str | None,
|
|
862
|
+
*,
|
|
863
|
+
with_init: bool,
|
|
864
|
+
brief_mode: str | None,
|
|
865
|
+
) -> tuple[str, dict[str, Any]]:
|
|
866
|
+
"""Compatibility text wrapper around the byte-exact managed composer."""
|
|
867
|
+
rendered, meta = compose_rule_file_bytes(
|
|
868
|
+
existing.encode("utf-8") if existing is not None else None,
|
|
869
|
+
with_init=with_init,
|
|
870
|
+
brief_mode=brief_mode,
|
|
871
|
+
)
|
|
872
|
+
return rendered.decode("utf-8"), meta
|
|
528
873
|
|
|
529
874
|
|
|
530
875
|
def plan_or_write_rule_file_blocks(
|
|
@@ -534,7 +879,7 @@ def plan_or_write_rule_file_blocks(
|
|
|
534
879
|
brief_mode: str | None,
|
|
535
880
|
applied: bool,
|
|
536
881
|
) -> dict[str, Any]:
|
|
537
|
-
"""Plan
|
|
882
|
+
"""Plan/apply exact managed spans through the shared cooperative writer."""
|
|
538
883
|
result: dict[str, Any] = {
|
|
539
884
|
"status": None,
|
|
540
885
|
"planned_actions": [],
|
|
@@ -551,27 +896,38 @@ def plan_or_write_rule_file_blocks(
|
|
|
551
896
|
result["planned_actions"].append(reason)
|
|
552
897
|
return result
|
|
553
898
|
|
|
554
|
-
existing = state.get("
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
899
|
+
existing = state.get("bytes")
|
|
900
|
+
snapshot = state["snapshot"]
|
|
901
|
+
existing_bytes = bytes(existing or b"")
|
|
902
|
+
result["brief_mode_existing_levels"] = _brief_mode_levels_in_bytes(existing_bytes)
|
|
903
|
+
repo_state = parse_managed_bytes(existing_bytes, kind="repo-rules")
|
|
904
|
+
rule_present = repo_state.status == "valid"
|
|
905
|
+
try:
|
|
906
|
+
final_bytes, planned_meta = compose_rule_file_bytes(
|
|
907
|
+
existing,
|
|
908
|
+
with_init=with_init,
|
|
909
|
+
brief_mode=brief_mode,
|
|
910
|
+
)
|
|
911
|
+
except ValueError as exc:
|
|
912
|
+
reason = f"refused unsafe managed rule state in {path.name}: {exc}"
|
|
913
|
+
result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
|
|
914
|
+
result["planned_actions"].append(reason)
|
|
915
|
+
return result
|
|
561
916
|
|
|
562
917
|
if with_init:
|
|
563
|
-
if rule_present:
|
|
918
|
+
if rule_present and not planned_meta["init_changed"]:
|
|
564
919
|
result["status"] = "exists"
|
|
565
920
|
result["planned_actions"].append("advisory ContextGuard rules already present")
|
|
566
921
|
elif not applied:
|
|
567
922
|
result["status"] = "planned"
|
|
568
|
-
|
|
923
|
+
verb = "migrate" if planned_meta.get("init_migrated_legacy") else "add"
|
|
924
|
+
result["planned_actions"].append(f"would {verb} advisory ContextGuard rules")
|
|
569
925
|
elif not brief_mode:
|
|
570
926
|
result["status"] = "planned"
|
|
571
927
|
result["planned_actions"].append("run with --with-init to add advisory ContextGuard rules")
|
|
572
928
|
|
|
573
929
|
if brief_mode:
|
|
574
|
-
brief_changed = bool(planned_meta
|
|
930
|
+
brief_changed = bool(planned_meta.get("brief_changed"))
|
|
575
931
|
if brief_mode == BRIEF_MODE_OFF:
|
|
576
932
|
if brief_changed:
|
|
577
933
|
result["brief_mode_status"] = "planned" if not applied else None
|
|
@@ -595,7 +951,7 @@ def plan_or_write_rule_file_blocks(
|
|
|
595
951
|
result["status"] = "planned" if result["planned_actions"] else "unchanged"
|
|
596
952
|
return result
|
|
597
953
|
|
|
598
|
-
|
|
954
|
+
meta = planned_meta
|
|
599
955
|
if not meta["changed"]:
|
|
600
956
|
if result["status"] is None:
|
|
601
957
|
result["status"] = "exists" if rule_present else "unchanged"
|
|
@@ -603,33 +959,29 @@ def plan_or_write_rule_file_blocks(
|
|
|
603
959
|
result["brief_mode_status"] = "absent" if brief_mode == BRIEF_MODE_OFF else "exists"
|
|
604
960
|
return result
|
|
605
961
|
|
|
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})
|
|
962
|
+
write_result = write_managed_file(
|
|
963
|
+
path,
|
|
964
|
+
expected=snapshot,
|
|
965
|
+
desired=final_bytes,
|
|
966
|
+
mode=0o644,
|
|
967
|
+
dir_mode=0o755,
|
|
968
|
+
)
|
|
969
|
+
if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
|
|
970
|
+
reason = write_result.get("reason") or f"could not write repo rule file {path.name}"
|
|
971
|
+
result.update({
|
|
972
|
+
"status": write_result["status"],
|
|
973
|
+
"brief_mode_status": write_result["status"],
|
|
974
|
+
"reason": reason,
|
|
975
|
+
})
|
|
628
976
|
result["planned_actions"] = [reason]
|
|
629
977
|
return result
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
978
|
+
if write_result.get("backup_path"):
|
|
979
|
+
result["brief_mode_backup_path"] = write_result["backup_path"]
|
|
980
|
+
durability_warning = (
|
|
981
|
+
write_result.get("reason")
|
|
982
|
+
if write_result["status"] == "applied-durability-uncertain"
|
|
983
|
+
else None
|
|
984
|
+
)
|
|
633
985
|
if durability_warning:
|
|
634
986
|
result["status"] = "applied-durability-uncertain"
|
|
635
987
|
result["reason"] = durability_warning
|
|
@@ -641,7 +993,7 @@ def plan_or_write_rule_file_blocks(
|
|
|
641
993
|
else:
|
|
642
994
|
result["planned_actions"].append("advisory ContextGuard rules already present")
|
|
643
995
|
elif result["status"] is None:
|
|
644
|
-
result["status"] = "
|
|
996
|
+
result["status"] = "applied"
|
|
645
997
|
if brief_mode:
|
|
646
998
|
if brief_mode == BRIEF_MODE_OFF:
|
|
647
999
|
result["brief_mode_status"] = "removed" if meta["brief_changed"] else "absent"
|
|
@@ -686,6 +1038,7 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
|
|
|
686
1038
|
because plan/apply must agree and must never follow an attacker-swapped rule
|
|
687
1039
|
directory outside the project.
|
|
688
1040
|
"""
|
|
1041
|
+
path = _normalize_allowed_first_absolute_symlink(path)
|
|
689
1042
|
parts = path.parts[1:-1] if path.is_absolute() else path.parts[:-1]
|
|
690
1043
|
if not parts:
|
|
691
1044
|
return None
|
|
@@ -706,14 +1059,20 @@ def _existing_rule_parent_issue(path: Path) -> str | None:
|
|
|
706
1059
|
|
|
707
1060
|
|
|
708
1061
|
def _rule_file_state(path: Path) -> dict[str, Any]:
|
|
709
|
-
"""Return a non-throwing
|
|
1062
|
+
"""Return a non-throwing exact-byte snapshot for project rule/skill files."""
|
|
710
1063
|
parent_issue = _existing_rule_parent_issue(path)
|
|
711
1064
|
if parent_issue:
|
|
712
1065
|
return {"status": "unsafe", "text": None, "reason": parent_issue}
|
|
713
1066
|
try:
|
|
714
1067
|
st = os.lstat(path)
|
|
715
1068
|
except FileNotFoundError:
|
|
716
|
-
return {
|
|
1069
|
+
return {
|
|
1070
|
+
"status": "missing",
|
|
1071
|
+
"text": None,
|
|
1072
|
+
"bytes": None,
|
|
1073
|
+
"snapshot": ManagedFileSnapshot(None, None),
|
|
1074
|
+
"reason": None,
|
|
1075
|
+
}
|
|
717
1076
|
except OSError as exc:
|
|
718
1077
|
return {"status": "unsafe", "text": None, "reason": f"could not inspect rule file: {exc.__class__.__name__}"}
|
|
719
1078
|
if stat.S_ISLNK(st.st_mode):
|
|
@@ -721,20 +1080,35 @@ def _rule_file_state(path: Path) -> dict[str, Any]:
|
|
|
721
1080
|
if stat.S_ISDIR(st.st_mode):
|
|
722
1081
|
return {"status": "directory", "text": None, "reason": f"refused to replace directory rule target: {path.name}"}
|
|
723
1082
|
try:
|
|
724
|
-
|
|
1083
|
+
snapshot = read_managed_file_snapshot(path)
|
|
725
1084
|
except OSError as exc:
|
|
726
1085
|
return {
|
|
727
1086
|
"status": "unsafe",
|
|
728
1087
|
"text": None,
|
|
1088
|
+
"bytes": None,
|
|
729
1089
|
"reason": f"could not read rule file without following symlinks: {exc.__class__.__name__}",
|
|
730
1090
|
}
|
|
731
|
-
|
|
1091
|
+
data = snapshot.data or b""
|
|
1092
|
+
try:
|
|
1093
|
+
text = data.decode("utf-8")
|
|
1094
|
+
except UnicodeDecodeError:
|
|
1095
|
+
text = None
|
|
1096
|
+
return {
|
|
1097
|
+
"status": "file",
|
|
1098
|
+
"text": text,
|
|
1099
|
+
"bytes": data,
|
|
1100
|
+
"snapshot": snapshot,
|
|
1101
|
+
"reason": None,
|
|
1102
|
+
}
|
|
732
1103
|
|
|
733
1104
|
|
|
734
1105
|
def repo_rule_block_present(path: Path) -> bool:
|
|
735
1106
|
"""True when the advisory ContextGuard block already exists in the rule file."""
|
|
736
1107
|
state = _rule_file_state(path)
|
|
737
|
-
return
|
|
1108
|
+
return (
|
|
1109
|
+
state["status"] == "file"
|
|
1110
|
+
and parse_managed_bytes(bytes(state.get("bytes") or b""), kind="repo-rules").status == "valid"
|
|
1111
|
+
)
|
|
738
1112
|
|
|
739
1113
|
|
|
740
1114
|
def write_repo_rule_init(path: Path) -> dict[str, Any]:
|
|
@@ -747,36 +1121,24 @@ def write_repo_rule_init(path: Path) -> dict[str, Any]:
|
|
|
747
1121
|
state = _rule_file_state(path)
|
|
748
1122
|
if state["status"] not in {"missing", "file"}:
|
|
749
1123
|
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
1124
|
try:
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1125
|
+
final, meta = compose_rule_file_bytes(
|
|
1126
|
+
state.get("bytes"),
|
|
1127
|
+
with_init=True,
|
|
1128
|
+
brief_mode=None,
|
|
1129
|
+
)
|
|
1130
|
+
except ValueError as exc:
|
|
1131
|
+
return {"status": "skipped", "reason": f"refused unsafe managed rule state: {exc}"}
|
|
1132
|
+
if not meta["changed"]:
|
|
1133
|
+
return {"status": "exists"}
|
|
1134
|
+
write_result = write_managed_file(
|
|
1135
|
+
path,
|
|
1136
|
+
expected=state["snapshot"],
|
|
1137
|
+
desired=final,
|
|
1138
|
+
mode=0o644,
|
|
1139
|
+
dir_mode=0o755,
|
|
1140
|
+
)
|
|
1141
|
+
return write_result
|
|
780
1142
|
|
|
781
1143
|
|
|
782
1144
|
def codex_skill_status(path: Path) -> str:
|
|
@@ -785,10 +1147,17 @@ def codex_skill_status(path: Path) -> str:
|
|
|
785
1147
|
return "missing"
|
|
786
1148
|
if state["status"] != "file":
|
|
787
1149
|
return "unsafe"
|
|
788
|
-
|
|
789
|
-
if
|
|
1150
|
+
data = bytes(state.get("bytes") or b"")
|
|
1151
|
+
if data == render_codex_skill().encode("utf-8"):
|
|
790
1152
|
return "exists"
|
|
791
|
-
|
|
1153
|
+
parsed = parse_managed_bytes(data, kind="codex-skill")
|
|
1154
|
+
if parsed.status != "valid":
|
|
1155
|
+
return "foreign"
|
|
1156
|
+
span = parsed.spans[0]
|
|
1157
|
+
if span.version == 0:
|
|
1158
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
1159
|
+
return "update-needed" if digest in LEGACY_CODEX_SKILL_SHA256_ALLOWLIST else "foreign"
|
|
1160
|
+
if span.version == 1:
|
|
792
1161
|
return "update-needed"
|
|
793
1162
|
return "foreign"
|
|
794
1163
|
|
|
@@ -806,11 +1175,29 @@ def write_codex_project_skill(path: Path) -> dict[str, Any]:
|
|
|
806
1175
|
"status": "skipped",
|
|
807
1176
|
"reason": f"refused to overwrite non-ContextGuard Codex skill file: {path}",
|
|
808
1177
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1178
|
+
existing = state.get("bytes")
|
|
1179
|
+
if status == "missing":
|
|
1180
|
+
desired = render_codex_skill().encode("utf-8")
|
|
1181
|
+
else:
|
|
1182
|
+
data = bytes(existing or b"")
|
|
1183
|
+
parsed = parse_managed_bytes(data, kind="codex-skill")
|
|
1184
|
+
span = parsed.spans[0]
|
|
1185
|
+
if span.version == 0:
|
|
1186
|
+
desired = render_codex_skill().encode("utf-8")
|
|
1187
|
+
else:
|
|
1188
|
+
desired = _replace_managed_span(data, span, render_codex_skill_block_bytes())
|
|
1189
|
+
result = write_managed_file(
|
|
1190
|
+
path,
|
|
1191
|
+
expected=state["snapshot"],
|
|
1192
|
+
desired=desired,
|
|
1193
|
+
mode=0o644,
|
|
1194
|
+
dir_mode=0o755,
|
|
1195
|
+
)
|
|
1196
|
+
if result["status"] == "applied":
|
|
1197
|
+
result["status"] = "updated" if status == "update-needed" else "applied"
|
|
1198
|
+
elif result["status"] == "applied-durability-uncertain":
|
|
1199
|
+
result["change_kind"] = "updated" if status == "update-needed" else "applied"
|
|
1200
|
+
return result
|
|
814
1201
|
|
|
815
1202
|
|
|
816
1203
|
def adapter_rule_path(root: Path, adapter: AgentAdapter) -> Path | None:
|
|
@@ -1005,14 +1392,23 @@ def build_adapter_plan(
|
|
|
1005
1392
|
entry["planned_actions"].append(
|
|
1006
1393
|
f"would generate project Codex skill at {adapter.project_skill_rel}"
|
|
1007
1394
|
)
|
|
1395
|
+
elif entry["status"] == "applied-durability-uncertain":
|
|
1396
|
+
entry["project_skill_status"] = "blocked-durability-uncertain"
|
|
1397
|
+
entry["planned_actions"].append(
|
|
1398
|
+
"blocked project Codex skill write because the preceding rule-file "
|
|
1399
|
+
"commit has uncertain directory durability"
|
|
1400
|
+
)
|
|
1008
1401
|
else:
|
|
1009
1402
|
skill_result = write_codex_project_skill(skill_path)
|
|
1010
1403
|
entry["project_skill_status"] = skill_result["status"]
|
|
1011
|
-
if skill_result["status"] in {"applied", "updated"}:
|
|
1404
|
+
if skill_result["status"] in {"applied", "updated", "applied-durability-uncertain"}:
|
|
1012
1405
|
action = f"wrote project Codex skill to {adapter.project_skill_rel}"
|
|
1013
1406
|
entry["applied_actions"].append(action)
|
|
1014
1407
|
entry["planned_actions"].append(action)
|
|
1015
|
-
if
|
|
1408
|
+
if skill_result["status"] == "applied-durability-uncertain":
|
|
1409
|
+
entry["status"] = "applied-durability-uncertain"
|
|
1410
|
+
entry["reason"] = skill_result.get("reason")
|
|
1411
|
+
elif entry["status"] in {"planned", "exists", "unchanged"}:
|
|
1016
1412
|
entry["status"] = "applied"
|
|
1017
1413
|
elif skill_result["status"] == "exists":
|
|
1018
1414
|
entry["planned_actions"].append(
|
|
@@ -1272,10 +1668,10 @@ def _ensure_directory_no_symlink(path: Path, mode: int | None = None, *, parents
|
|
|
1272
1668
|
raise
|
|
1273
1669
|
|
|
1274
1670
|
|
|
1275
|
-
def
|
|
1671
|
+
def _read_bytes_no_follow(path: Path) -> bytes:
|
|
1276
1672
|
fd = _open_regular_no_symlink(path)
|
|
1277
1673
|
try:
|
|
1278
|
-
with os.fdopen(fd, "
|
|
1674
|
+
with os.fdopen(fd, "rb") as handle:
|
|
1279
1675
|
fd = -1
|
|
1280
1676
|
return handle.read()
|
|
1281
1677
|
finally:
|
|
@@ -1283,6 +1679,47 @@ def _read_text_no_follow(path: Path) -> str:
|
|
|
1283
1679
|
os.close(fd)
|
|
1284
1680
|
|
|
1285
1681
|
|
|
1682
|
+
def _read_text_no_follow(path: Path) -> str:
|
|
1683
|
+
return _read_bytes_no_follow(path).decode("utf-8")
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def _snapshot_metadata(st: os.stat_result) -> tuple[int, int, int, int, int]:
|
|
1687
|
+
return (
|
|
1688
|
+
int(st.st_dev),
|
|
1689
|
+
int(st.st_ino),
|
|
1690
|
+
int(st.st_mode),
|
|
1691
|
+
int(st.st_size),
|
|
1692
|
+
int(st.st_mtime_ns),
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def read_managed_file_snapshot(path: Path) -> ManagedFileSnapshot:
|
|
1697
|
+
"""Read an exact byte+metadata snapshot without following target/parent links."""
|
|
1698
|
+
try:
|
|
1699
|
+
fd = _open_regular_no_symlink(path)
|
|
1700
|
+
except FileNotFoundError:
|
|
1701
|
+
return ManagedFileSnapshot(None, None)
|
|
1702
|
+
try:
|
|
1703
|
+
before = os.fstat(fd)
|
|
1704
|
+
with os.fdopen(fd, "rb") as handle:
|
|
1705
|
+
fd = -1
|
|
1706
|
+
data = handle.read()
|
|
1707
|
+
after = os.fstat(handle.fileno())
|
|
1708
|
+
if _snapshot_metadata(before) != _snapshot_metadata(after) or len(data) != after.st_size:
|
|
1709
|
+
raise ManagedFileConflictError(f"managed target changed during read: {path}")
|
|
1710
|
+
return ManagedFileSnapshot(data, _snapshot_metadata(after))
|
|
1711
|
+
finally:
|
|
1712
|
+
if fd != -1:
|
|
1713
|
+
os.close(fd)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _verify_expected_snapshot(path: Path, expected: ManagedFileSnapshot) -> ManagedFileSnapshot:
|
|
1717
|
+
current = read_managed_file_snapshot(path)
|
|
1718
|
+
if current != expected:
|
|
1719
|
+
raise ManagedFileConflictError(f"managed target changed since planning: {path}")
|
|
1720
|
+
return current
|
|
1721
|
+
|
|
1722
|
+
|
|
1286
1723
|
def _read_optional_text_no_follow(path: Path) -> str | None:
|
|
1287
1724
|
try:
|
|
1288
1725
|
return _read_text_no_follow(path)
|
|
@@ -1318,7 +1755,12 @@ def load_json_object(path: Path) -> dict[str, Any]:
|
|
|
1318
1755
|
return _parse_json_object_text(_read_optional_text_no_follow(path), path)
|
|
1319
1756
|
|
|
1320
1757
|
|
|
1321
|
-
def ensure_permissions(
|
|
1758
|
+
def ensure_permissions(
|
|
1759
|
+
settings: dict[str, Any],
|
|
1760
|
+
actions: list[str],
|
|
1761
|
+
*,
|
|
1762
|
+
migrate_env_read_denies: bool = False,
|
|
1763
|
+
) -> None:
|
|
1322
1764
|
permissions = settings.get("permissions")
|
|
1323
1765
|
if permissions is None:
|
|
1324
1766
|
permissions = {}
|
|
@@ -1331,6 +1773,21 @@ def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
|
|
|
1331
1773
|
permissions["deny"] = deny
|
|
1332
1774
|
if not isinstance(deny, list):
|
|
1333
1775
|
raise SystemExit("Refusing to replace non-list settings.permissions.deny; repair it manually first.")
|
|
1776
|
+
if migrate_env_read_denies:
|
|
1777
|
+
retained = [
|
|
1778
|
+
rule
|
|
1779
|
+
for rule in deny
|
|
1780
|
+
if not (
|
|
1781
|
+
isinstance(rule, str)
|
|
1782
|
+
and rule in PRODUCT_OWNED_ENV_READ_DENIES
|
|
1783
|
+
)
|
|
1784
|
+
]
|
|
1785
|
+
removed = len(deny) - len(retained)
|
|
1786
|
+
if removed:
|
|
1787
|
+
deny[:] = retained
|
|
1788
|
+
actions.append(
|
|
1789
|
+
f"removed {removed} obsolete permissions.deny rules now enforced by the Claude Read hook"
|
|
1790
|
+
)
|
|
1334
1791
|
added = 0
|
|
1335
1792
|
for rule in RECOMMENDED_DENIES:
|
|
1336
1793
|
if rule not in deny:
|
|
@@ -1649,6 +2106,16 @@ def ensure_post_tool_hook(settings: dict[str, Any], hook: dict[str, Any], comman
|
|
|
1649
2106
|
_ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUse")
|
|
1650
2107
|
|
|
1651
2108
|
|
|
2109
|
+
def ensure_post_tool_failure_hook(
|
|
2110
|
+
settings: dict[str, Any],
|
|
2111
|
+
hook: dict[str, Any],
|
|
2112
|
+
command: str,
|
|
2113
|
+
label: str,
|
|
2114
|
+
actions: list[str],
|
|
2115
|
+
) -> None:
|
|
2116
|
+
_ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUseFailure")
|
|
2117
|
+
|
|
2118
|
+
|
|
1652
2119
|
def _ensure_tool_hook(
|
|
1653
2120
|
settings: dict[str, Any],
|
|
1654
2121
|
hook: dict[str, Any],
|
|
@@ -2136,7 +2603,11 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
|
|
|
2136
2603
|
elif settings.get("statusLine") != statusline:
|
|
2137
2604
|
actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
|
|
2138
2605
|
if choices.denies:
|
|
2139
|
-
ensure_permissions(
|
|
2606
|
+
ensure_permissions(
|
|
2607
|
+
settings,
|
|
2608
|
+
actions,
|
|
2609
|
+
migrate_env_read_denies=choices.read_guard,
|
|
2610
|
+
)
|
|
2140
2611
|
if choices.bash_hook:
|
|
2141
2612
|
bash_hook = bash_hook_setting(allow_path_fallback=allow_path_fallback)
|
|
2142
2613
|
bash_command = bash_hook["hooks"][0]["command"]
|
|
@@ -2149,10 +2620,24 @@ def apply_choices(settings: dict[str, Any], choices: Choices, *, allow_path_fall
|
|
|
2149
2620
|
nudge_hook = failed_nudge_setting(allow_path_fallback=allow_path_fallback)
|
|
2150
2621
|
nudge_command = nudge_hook["hooks"][0]["command"]
|
|
2151
2622
|
ensure_post_tool_hook(settings, nudge_hook, nudge_command, "failed-attempt /clear nudge", actions)
|
|
2623
|
+
ensure_post_tool_failure_hook(
|
|
2624
|
+
settings,
|
|
2625
|
+
nudge_hook,
|
|
2626
|
+
nudge_command,
|
|
2627
|
+
"failed-attempt /clear nudge",
|
|
2628
|
+
actions,
|
|
2629
|
+
)
|
|
2152
2630
|
return actions
|
|
2153
2631
|
|
|
2154
2632
|
|
|
2155
|
-
def
|
|
2633
|
+
def atomic_write_bytes(
|
|
2634
|
+
path: Path,
|
|
2635
|
+
data: bytes,
|
|
2636
|
+
mode: int = 0o600,
|
|
2637
|
+
*,
|
|
2638
|
+
dir_mode: int = PRIVATE_DIR_MODE,
|
|
2639
|
+
expected: ManagedFileSnapshot | None = None,
|
|
2640
|
+
) -> None:
|
|
2156
2641
|
if os.rename not in os.supports_dir_fd or os.unlink not in os.supports_dir_fd:
|
|
2157
2642
|
raise OSError("platform does not support directory-relative atomic writes")
|
|
2158
2643
|
parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
|
|
@@ -2162,12 +2647,14 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
|
|
|
2162
2647
|
try:
|
|
2163
2648
|
if hasattr(os, "fchmod"):
|
|
2164
2649
|
os.fchmod(fd, mode)
|
|
2165
|
-
with os.fdopen(fd, "
|
|
2650
|
+
with os.fdopen(fd, "wb") as f:
|
|
2166
2651
|
fd = -1
|
|
2167
|
-
f.write(
|
|
2652
|
+
f.write(data)
|
|
2168
2653
|
f.flush()
|
|
2169
2654
|
os.fsync(f.fileno())
|
|
2170
2655
|
os.fsync(parent_fd)
|
|
2656
|
+
if expected is not None:
|
|
2657
|
+
_verify_expected_snapshot(path, expected)
|
|
2171
2658
|
os.rename(tmp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
2172
2659
|
try:
|
|
2173
2660
|
os.fsync(parent_fd)
|
|
@@ -2185,6 +2672,37 @@ def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PR
|
|
|
2185
2672
|
os.close(parent_fd)
|
|
2186
2673
|
|
|
2187
2674
|
|
|
2675
|
+
def atomic_write(
|
|
2676
|
+
path: Path,
|
|
2677
|
+
content: str | bytes,
|
|
2678
|
+
mode: int = 0o600,
|
|
2679
|
+
*,
|
|
2680
|
+
dir_mode: int = PRIVATE_DIR_MODE,
|
|
2681
|
+
) -> None:
|
|
2682
|
+
data = content if isinstance(content, bytes) else content.encode("utf-8")
|
|
2683
|
+
atomic_write_bytes(path, data, mode, dir_mode=dir_mode)
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def _atomic_remove_expected(
|
|
2687
|
+
path: Path,
|
|
2688
|
+
expected: ManagedFileSnapshot,
|
|
2689
|
+
*,
|
|
2690
|
+
dir_mode: int,
|
|
2691
|
+
) -> None:
|
|
2692
|
+
parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
|
|
2693
|
+
try:
|
|
2694
|
+
_verify_expected_snapshot(path, expected)
|
|
2695
|
+
os.unlink(path.name, dir_fd=parent_fd)
|
|
2696
|
+
try:
|
|
2697
|
+
os.fsync(parent_fd)
|
|
2698
|
+
except OSError as exc:
|
|
2699
|
+
raise AtomicWriteDurabilityError(
|
|
2700
|
+
f"remove committed but parent directory durability is uncertain: {path}"
|
|
2701
|
+
) from exc
|
|
2702
|
+
finally:
|
|
2703
|
+
os.close(parent_fd)
|
|
2704
|
+
|
|
2705
|
+
|
|
2188
2706
|
def existing_mode_or_default(path: Path, default: int = 0o600) -> int:
|
|
2189
2707
|
try:
|
|
2190
2708
|
fd = _open_regular_no_symlink(path)
|
|
@@ -2210,6 +2728,175 @@ def backup_existing(path: Path) -> Path | None:
|
|
|
2210
2728
|
return backup
|
|
2211
2729
|
|
|
2212
2730
|
|
|
2731
|
+
def managed_lock_path(path: Path) -> Path:
|
|
2732
|
+
return path.with_name(f".{path.name}.lock")
|
|
2733
|
+
|
|
2734
|
+
|
|
2735
|
+
def acquire_managed_file_lock(path: Path, *, dir_mode: int = PRIVATE_DIR_MODE) -> int:
|
|
2736
|
+
"""Acquire the shared sibling lock used by all managed forward/rollback writers."""
|
|
2737
|
+
if fcntl is None:
|
|
2738
|
+
raise OSError("platform does not support advisory file locks")
|
|
2739
|
+
parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
|
|
2740
|
+
lock_name = managed_lock_path(path).name
|
|
2741
|
+
flags = os.O_CREAT | os.O_RDWR | _no_follow_flag()
|
|
2742
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
2743
|
+
flags |= os.O_CLOEXEC
|
|
2744
|
+
fd: int | None = None
|
|
2745
|
+
try:
|
|
2746
|
+
for attempt in range(3):
|
|
2747
|
+
try:
|
|
2748
|
+
fd = os.open(lock_name, flags, 0o600, dir_fd=parent_fd)
|
|
2749
|
+
break
|
|
2750
|
+
except FileNotFoundError:
|
|
2751
|
+
if attempt == 2:
|
|
2752
|
+
raise
|
|
2753
|
+
time.sleep(0.001)
|
|
2754
|
+
except OSError as exc:
|
|
2755
|
+
raise OSError(f"could not open cooperative lock {managed_lock_path(path)}: {exc}") from exc
|
|
2756
|
+
finally:
|
|
2757
|
+
os.close(parent_fd)
|
|
2758
|
+
if fd is None:
|
|
2759
|
+
raise OSError(f"could not open cooperative lock {managed_lock_path(path)}")
|
|
2760
|
+
try:
|
|
2761
|
+
st = os.fstat(fd)
|
|
2762
|
+
if not stat.S_ISREG(st.st_mode):
|
|
2763
|
+
raise OSError(f"cooperative lock is not a regular file: {managed_lock_path(path)}")
|
|
2764
|
+
if hasattr(os, "fchmod"):
|
|
2765
|
+
os.fchmod(fd, 0o600)
|
|
2766
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
2767
|
+
return fd
|
|
2768
|
+
except Exception:
|
|
2769
|
+
os.close(fd)
|
|
2770
|
+
raise
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def release_managed_file_lock(fd: int) -> None:
|
|
2774
|
+
try:
|
|
2775
|
+
if fcntl is not None:
|
|
2776
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
2777
|
+
finally:
|
|
2778
|
+
os.close(fd)
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def _managed_backup(path: Path, data: bytes, *, dir_mode: int) -> Path:
|
|
2782
|
+
stamp = _dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
2783
|
+
backup = path.with_name(f"{path.name}.bak-{stamp}-{uuid.uuid4().hex[:8]}")
|
|
2784
|
+
atomic_write_bytes(backup, data, 0o600, dir_mode=dir_mode)
|
|
2785
|
+
return backup
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def write_managed_file(
|
|
2789
|
+
path: Path,
|
|
2790
|
+
*,
|
|
2791
|
+
expected: ManagedFileSnapshot,
|
|
2792
|
+
desired: bytes | None,
|
|
2793
|
+
mode: int = 0o644,
|
|
2794
|
+
dir_mode: int = 0o755,
|
|
2795
|
+
create_backup: bool = True,
|
|
2796
|
+
prepare_commit: Any = None,
|
|
2797
|
+
) -> dict[str, Any]:
|
|
2798
|
+
"""Apply one cooperative byte-exact transaction or return a fail-closed status."""
|
|
2799
|
+
if desired == expected.data:
|
|
2800
|
+
return {"status": "unchanged", "backup_path": None}
|
|
2801
|
+
try:
|
|
2802
|
+
lock_fd = acquire_managed_file_lock(path, dir_mode=dir_mode)
|
|
2803
|
+
except OSError as exc:
|
|
2804
|
+
return {"status": "skipped", "reason": f"could not acquire cooperative lock: {exc}"}
|
|
2805
|
+
backup_path: Path | None = None
|
|
2806
|
+
try:
|
|
2807
|
+
try:
|
|
2808
|
+
current = _verify_expected_snapshot(path, expected)
|
|
2809
|
+
except ManagedFileConflictError as exc:
|
|
2810
|
+
return {"status": "conflict", "reason": str(exc), "backup_path": None}
|
|
2811
|
+
|
|
2812
|
+
target_mode = mode
|
|
2813
|
+
if current.metadata is not None:
|
|
2814
|
+
target_mode = stat.S_IMODE(current.metadata[2])
|
|
2815
|
+
if current.data is not None and create_backup:
|
|
2816
|
+
try:
|
|
2817
|
+
backup_path = _managed_backup(path, current.data, dir_mode=dir_mode)
|
|
2818
|
+
except OSError as exc:
|
|
2819
|
+
return {
|
|
2820
|
+
"status": "skipped",
|
|
2821
|
+
"reason": f"could not create private managed-file backup: {exc}",
|
|
2822
|
+
"backup_path": None,
|
|
2823
|
+
}
|
|
2824
|
+
try:
|
|
2825
|
+
if desired is None:
|
|
2826
|
+
if prepare_commit is not None:
|
|
2827
|
+
prepare_commit(backup_path)
|
|
2828
|
+
_atomic_remove_expected(path, current, dir_mode=dir_mode)
|
|
2829
|
+
else:
|
|
2830
|
+
_verify_expected_snapshot(path, current)
|
|
2831
|
+
if prepare_commit is not None:
|
|
2832
|
+
prepare_commit(backup_path)
|
|
2833
|
+
atomic_write(
|
|
2834
|
+
path,
|
|
2835
|
+
desired,
|
|
2836
|
+
target_mode,
|
|
2837
|
+
dir_mode=dir_mode,
|
|
2838
|
+
)
|
|
2839
|
+
except ManagedFileConflictError as exc:
|
|
2840
|
+
return {
|
|
2841
|
+
"status": "conflict",
|
|
2842
|
+
"reason": str(exc),
|
|
2843
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
2844
|
+
}
|
|
2845
|
+
except AtomicWriteDurabilityError as exc:
|
|
2846
|
+
return {
|
|
2847
|
+
"status": "applied-durability-uncertain",
|
|
2848
|
+
"reason": str(exc),
|
|
2849
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
2850
|
+
"residual_risk": (
|
|
2851
|
+
"A non-cooperating editor can still race after the final comparison; "
|
|
2852
|
+
"automatic follow-on mutation is blocked."
|
|
2853
|
+
),
|
|
2854
|
+
}
|
|
2855
|
+
except OSError as exc:
|
|
2856
|
+
return {
|
|
2857
|
+
"status": "skipped",
|
|
2858
|
+
"reason": f"could not commit managed file: {exc}",
|
|
2859
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
2860
|
+
}
|
|
2861
|
+
return {
|
|
2862
|
+
"status": "applied",
|
|
2863
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
2864
|
+
"residual_risk": (
|
|
2865
|
+
"Cooperating ContextGuard writers serialize; a non-cooperating editor can still race "
|
|
2866
|
+
"after the final comparison and before atomic replace."
|
|
2867
|
+
),
|
|
2868
|
+
}
|
|
2869
|
+
finally:
|
|
2870
|
+
release_managed_file_lock(lock_fd)
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
def rollback_managed_file(
|
|
2874
|
+
path: Path,
|
|
2875
|
+
*,
|
|
2876
|
+
expected_post: ManagedFileSnapshot,
|
|
2877
|
+
restore: bytes | None,
|
|
2878
|
+
kind: str,
|
|
2879
|
+
mode: int = 0o644,
|
|
2880
|
+
dir_mode: int = 0o755,
|
|
2881
|
+
) -> dict[str, Any]:
|
|
2882
|
+
"""Rollback only a still-matching post-image with the same parser/lock authority."""
|
|
2883
|
+
if expected_post.data is None:
|
|
2884
|
+
return {"status": "skipped", "reason": "rollback post-image is missing"}
|
|
2885
|
+
ownership = parse_managed_bytes(expected_post.data, kind=kind)
|
|
2886
|
+
if ownership.status != "valid":
|
|
2887
|
+
return {
|
|
2888
|
+
"status": "skipped",
|
|
2889
|
+
"reason": f"rollback lacks valid {kind} ownership: {ownership.status}",
|
|
2890
|
+
}
|
|
2891
|
+
return write_managed_file(
|
|
2892
|
+
path,
|
|
2893
|
+
expected=expected_post,
|
|
2894
|
+
desired=restore,
|
|
2895
|
+
mode=mode,
|
|
2896
|
+
dir_mode=dir_mode,
|
|
2897
|
+
)
|
|
2898
|
+
|
|
2899
|
+
|
|
2213
2900
|
def rollback_restore_guidance(settings_path: Path, backup_path: Path | None, original_existed: bool) -> str:
|
|
2214
2901
|
if backup_path is not None:
|
|
2215
2902
|
return (
|
|
@@ -2320,7 +3007,7 @@ def interactive_choices(defaults: Choices) -> Choices:
|
|
|
2320
3007
|
read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
|
|
2321
3008
|
model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
|
|
2322
3009
|
failed_attempt_nudge=prompt_bool(
|
|
2323
|
-
"Enable failed-attempt /clear nudge? (
|
|
3010
|
+
"Enable failed-attempt /clear nudge? (Bash terminal-event hooks; recommended default)",
|
|
2324
3011
|
defaults.failed_attempt_nudge,
|
|
2325
3012
|
),
|
|
2326
3013
|
)
|
|
@@ -2396,6 +3083,201 @@ def render_text(result: SetupResult) -> str:
|
|
|
2396
3083
|
return "\n".join(lines) + "\n"
|
|
2397
3084
|
|
|
2398
3085
|
|
|
3086
|
+
def validate_rules_only_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> bool:
|
|
3087
|
+
"""Validate and identify the isolated Claude quiet-narration CLI branch."""
|
|
3088
|
+
rules_only = bool(getattr(args, "rules_only", False))
|
|
3089
|
+
narration_mode = getattr(args, "narration_mode", None)
|
|
3090
|
+
if narration_mode and not rules_only:
|
|
3091
|
+
parser.error("--narration-mode requires --rules-only")
|
|
3092
|
+
if rules_only and not narration_mode:
|
|
3093
|
+
parser.error("--rules-only requires a rule operation such as --narration-mode")
|
|
3094
|
+
if not rules_only:
|
|
3095
|
+
return False
|
|
3096
|
+
|
|
3097
|
+
if getattr(args, "scope", "project") != "project":
|
|
3098
|
+
parser.error("quiet narration rules support only --scope project")
|
|
3099
|
+
selected = [item.lower() for item in (explicit_agent_selection(args) or [])]
|
|
3100
|
+
if selected != ["claude"] or not getattr(args, "agent", None) or getattr(args, "only", None):
|
|
3101
|
+
parser.error("quiet narration rules require exactly one explicit --agent claude")
|
|
3102
|
+
action_count = sum(
|
|
3103
|
+
bool(value)
|
|
3104
|
+
for value in (
|
|
3105
|
+
getattr(args, "yes", False),
|
|
3106
|
+
getattr(args, "plan", False),
|
|
3107
|
+
getattr(args, "dry_run", False),
|
|
3108
|
+
)
|
|
3109
|
+
)
|
|
3110
|
+
if action_count != 1:
|
|
3111
|
+
parser.error("quiet narration rules require exactly one of --plan, --dry-run, or --yes")
|
|
3112
|
+
|
|
3113
|
+
conflicting = [
|
|
3114
|
+
flag
|
|
3115
|
+
for attr, flag in (
|
|
3116
|
+
("allow_home_settings", "--allow-home-settings"),
|
|
3117
|
+
("verify", "--verify"),
|
|
3118
|
+
("no_backup", "--no-backup"),
|
|
3119
|
+
("no_denies", "--no-denies"),
|
|
3120
|
+
("no_statusline", "--no-statusline"),
|
|
3121
|
+
("no_bash_hook", "--no-bash-hook"),
|
|
3122
|
+
("no_read_guard", "--no-read-guard"),
|
|
3123
|
+
("no_model_defaults", "--no-model-defaults"),
|
|
3124
|
+
("no_diet_scan", "--no-diet-scan"),
|
|
3125
|
+
("allow_path_helper_fallback", "--allow-path-helper-fallback"),
|
|
3126
|
+
("with_init", "--with-init"),
|
|
3127
|
+
("with_skill", "--with-skill"),
|
|
3128
|
+
("brief_mode", "--brief-mode"),
|
|
3129
|
+
("list_adapters", "--list-adapters"),
|
|
3130
|
+
)
|
|
3131
|
+
if getattr(args, attr, False)
|
|
3132
|
+
]
|
|
3133
|
+
if getattr(args, "failed_attempt_nudge", None) is not None:
|
|
3134
|
+
conflicting.append(
|
|
3135
|
+
"--failed-attempt-nudge"
|
|
3136
|
+
if args.failed_attempt_nudge
|
|
3137
|
+
else "--no-failed-attempt-nudge"
|
|
3138
|
+
)
|
|
3139
|
+
if conflicting:
|
|
3140
|
+
parser.error(
|
|
3141
|
+
"quiet narration rules cannot be combined with settings, hook, adapter, "
|
|
3142
|
+
f"or setup flags: {', '.join(conflicting)}"
|
|
3143
|
+
)
|
|
3144
|
+
return True
|
|
3145
|
+
|
|
3146
|
+
|
|
3147
|
+
def run_quiet_narration_rules(args: argparse.Namespace) -> dict[str, Any]:
|
|
3148
|
+
"""Plan/apply the isolated Claude/project CLAUDE.md narration span."""
|
|
3149
|
+
require_no_follow_file_ops_supported()
|
|
3150
|
+
root = resolve_setup_root(args.root)
|
|
3151
|
+
rule_path = root / "CLAUDE.md"
|
|
3152
|
+
state = _rule_file_state(rule_path)
|
|
3153
|
+
if state["status"] not in {"missing", "file"}:
|
|
3154
|
+
raise SystemExit(
|
|
3155
|
+
state.get("reason")
|
|
3156
|
+
or f"refused unsafe quiet narration rule target: {rule_path}"
|
|
3157
|
+
)
|
|
3158
|
+
existing = bytes(state.get("bytes") or b"")
|
|
3159
|
+
parsed = parse_managed_bytes(existing, kind="narration-mode")
|
|
3160
|
+
if parsed.status not in {"absent", "valid"}:
|
|
3161
|
+
raise SystemExit(
|
|
3162
|
+
f"refused unsafe managed narration state in {rule_path.name}: "
|
|
3163
|
+
f"{parsed.reason or parsed.status}"
|
|
3164
|
+
)
|
|
3165
|
+
|
|
3166
|
+
mode = str(args.narration_mode)
|
|
3167
|
+
span = parsed.spans[0] if parsed.status == "valid" else None
|
|
3168
|
+
desired = existing
|
|
3169
|
+
if mode == "quiet":
|
|
3170
|
+
block = _managed_block_bytes(render_quiet_narration_block())
|
|
3171
|
+
if span is None:
|
|
3172
|
+
desired = _append_narration_block_bytes(existing, block)
|
|
3173
|
+
elif existing[span.start : span.end] != block:
|
|
3174
|
+
desired = _replace_managed_span(existing, span, block)
|
|
3175
|
+
elif span is not None:
|
|
3176
|
+
removal_start = span.start
|
|
3177
|
+
if removal_start > 0 and existing[removal_start - 1 : removal_start] == b"\n":
|
|
3178
|
+
removal_start -= 1
|
|
3179
|
+
desired = existing[:removal_start] + existing[span.end :]
|
|
3180
|
+
|
|
3181
|
+
changed = desired != existing
|
|
3182
|
+
apply_requested = bool(args.yes)
|
|
3183
|
+
if not changed:
|
|
3184
|
+
status = "exists" if mode == "quiet" else "absent"
|
|
3185
|
+
return {
|
|
3186
|
+
"schema_version": "contextguard.narration-rules.v1",
|
|
3187
|
+
"operation": "quiet-narration",
|
|
3188
|
+
"mode": mode,
|
|
3189
|
+
"root": str(root),
|
|
3190
|
+
"rule_file": str(rule_path),
|
|
3191
|
+
"marker_state_before": parsed.status,
|
|
3192
|
+
"status": status,
|
|
3193
|
+
"changed": False,
|
|
3194
|
+
"applied": False,
|
|
3195
|
+
"apply_requested": apply_requested,
|
|
3196
|
+
"backup_path": None,
|
|
3197
|
+
"actions": [
|
|
3198
|
+
"quiet narration rules already present"
|
|
3199
|
+
if mode == "quiet"
|
|
3200
|
+
else "quiet narration rules already absent"
|
|
3201
|
+
],
|
|
3202
|
+
"claim_boundary": "static setup result only; no model-compliance or savings claim",
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
planned_status = "planned"
|
|
3206
|
+
action = (
|
|
3207
|
+
("add" if span is None else "refresh") + " quiet narration rules"
|
|
3208
|
+
if mode == "quiet"
|
|
3209
|
+
else "remove quiet narration rules"
|
|
3210
|
+
)
|
|
3211
|
+
if not apply_requested:
|
|
3212
|
+
return {
|
|
3213
|
+
"schema_version": "contextguard.narration-rules.v1",
|
|
3214
|
+
"operation": "quiet-narration",
|
|
3215
|
+
"mode": mode,
|
|
3216
|
+
"root": str(root),
|
|
3217
|
+
"rule_file": str(rule_path),
|
|
3218
|
+
"marker_state_before": parsed.status,
|
|
3219
|
+
"status": planned_status,
|
|
3220
|
+
"changed": True,
|
|
3221
|
+
"applied": False,
|
|
3222
|
+
"apply_requested": False,
|
|
3223
|
+
"backup_path": None,
|
|
3224
|
+
"actions": [f"would {action}"],
|
|
3225
|
+
"claim_boundary": "static setup result only; no model-compliance or savings claim",
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
write_result = write_managed_file(
|
|
3229
|
+
rule_path,
|
|
3230
|
+
expected=state["snapshot"],
|
|
3231
|
+
desired=desired,
|
|
3232
|
+
mode=existing_mode_or_default(rule_path, 0o644),
|
|
3233
|
+
dir_mode=0o755,
|
|
3234
|
+
)
|
|
3235
|
+
if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
|
|
3236
|
+
raise SystemExit(
|
|
3237
|
+
write_result.get("reason")
|
|
3238
|
+
or f"could not safely update quiet narration rules in {rule_path}"
|
|
3239
|
+
)
|
|
3240
|
+
status = (
|
|
3241
|
+
write_result["status"]
|
|
3242
|
+
if write_result["status"] == "applied-durability-uncertain"
|
|
3243
|
+
else ("removed" if mode == "default" else ("applied" if span is None else "updated"))
|
|
3244
|
+
)
|
|
3245
|
+
payload = {
|
|
3246
|
+
"schema_version": "contextguard.narration-rules.v1",
|
|
3247
|
+
"operation": "quiet-narration",
|
|
3248
|
+
"mode": mode,
|
|
3249
|
+
"root": str(root),
|
|
3250
|
+
"rule_file": str(rule_path),
|
|
3251
|
+
"marker_state_before": parsed.status,
|
|
3252
|
+
"status": status,
|
|
3253
|
+
"changed": True,
|
|
3254
|
+
"applied": True,
|
|
3255
|
+
"apply_requested": True,
|
|
3256
|
+
"backup_path": write_result.get("backup_path"),
|
|
3257
|
+
"actions": [action],
|
|
3258
|
+
"claim_boundary": "static setup result only; no model-compliance or savings claim",
|
|
3259
|
+
}
|
|
3260
|
+
if write_result.get("reason"):
|
|
3261
|
+
payload["warning"] = write_result["reason"]
|
|
3262
|
+
if write_result.get("residual_risk"):
|
|
3263
|
+
payload["residual_risk"] = write_result["residual_risk"]
|
|
3264
|
+
return payload
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
def render_quiet_narration_text(result: dict[str, Any]) -> str:
|
|
3268
|
+
lines = [
|
|
3269
|
+
f"ContextGuard quiet narration ({result['status']})",
|
|
3270
|
+
f"root={result['root']}",
|
|
3271
|
+
f"rule_file={result['rule_file']}",
|
|
3272
|
+
f"mode={result['mode']}",
|
|
3273
|
+
]
|
|
3274
|
+
if result.get("backup_path"):
|
|
3275
|
+
lines.append(f"backup={result['backup_path']}")
|
|
3276
|
+
lines.extend(f"- {action}" for action in result.get("actions", []))
|
|
3277
|
+
lines.append(str(result["claim_boundary"]))
|
|
3278
|
+
return "\n".join(lines) + "\n"
|
|
3279
|
+
|
|
3280
|
+
|
|
2399
3281
|
def run(args: argparse.Namespace) -> SetupResult:
|
|
2400
3282
|
require_no_follow_file_ops_supported()
|
|
2401
3283
|
scope = normalize_scope(getattr(args, "scope", "project"))
|
|
@@ -2416,10 +3298,21 @@ def run(args: argparse.Namespace) -> SetupResult:
|
|
|
2416
3298
|
if claude_targeted:
|
|
2417
3299
|
validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
|
|
2418
3300
|
original_text = _read_optional_text_no_follow(settings_path)
|
|
3301
|
+
settings_snapshot = read_managed_file_snapshot(settings_path)
|
|
3302
|
+
snapshot_text = (
|
|
3303
|
+
settings_snapshot.data.decode("utf-8")
|
|
3304
|
+
if settings_snapshot.data is not None
|
|
3305
|
+
else None
|
|
3306
|
+
)
|
|
3307
|
+
if snapshot_text != original_text:
|
|
3308
|
+
raise SystemExit(
|
|
3309
|
+
f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
|
|
3310
|
+
)
|
|
2419
3311
|
original = _parse_json_object_text(original_text, settings_path)
|
|
2420
3312
|
settings = json.loads(json.dumps(original))
|
|
2421
3313
|
else:
|
|
2422
3314
|
original_text = None
|
|
3315
|
+
settings_snapshot = ManagedFileSnapshot(None, None)
|
|
2423
3316
|
original = {}
|
|
2424
3317
|
settings = {}
|
|
2425
3318
|
|
|
@@ -2471,31 +3364,45 @@ def run(args: argparse.Namespace) -> SetupResult:
|
|
|
2471
3364
|
if claude_targeted and apply_requested and changed:
|
|
2472
3365
|
if scope == "user" and original_text is not None and args.no_backup:
|
|
2473
3366
|
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
|
-
|
|
3367
|
+
rollback_state: dict[str, Any] = {}
|
|
3368
|
+
|
|
3369
|
+
def prepare_settings_commit(managed_backup_path: Path | None) -> None:
|
|
3370
|
+
prepared_rollback_id, prepared_rollback_path = write_rollback_record(
|
|
3371
|
+
root=root,
|
|
3372
|
+
scope=scope,
|
|
3373
|
+
settings_path=settings_path,
|
|
3374
|
+
backup_path=managed_backup_path,
|
|
3375
|
+
original_existed=(original_text is not None),
|
|
3376
|
+
)
|
|
3377
|
+
rollback_state.update({
|
|
3378
|
+
"rollback_id": prepared_rollback_id,
|
|
3379
|
+
"rollback_path": prepared_rollback_path,
|
|
3380
|
+
})
|
|
3381
|
+
|
|
3382
|
+
desired_settings = (
|
|
3383
|
+
json.dumps(settings, indent=2, sort_keys=True) + "\n"
|
|
3384
|
+
).encode("utf-8")
|
|
3385
|
+
write_result = write_managed_file(
|
|
3386
|
+
settings_path,
|
|
3387
|
+
expected=settings_snapshot,
|
|
3388
|
+
desired=desired_settings,
|
|
3389
|
+
mode=existing_mode_or_default(settings_path, 0o600),
|
|
3390
|
+
dir_mode=PRIVATE_DIR_MODE,
|
|
3391
|
+
create_backup=not args.no_backup,
|
|
3392
|
+
prepare_commit=prepare_settings_commit,
|
|
3393
|
+
)
|
|
3394
|
+
if write_result["status"] not in {"applied", "applied-durability-uncertain"}:
|
|
3395
|
+
reason = write_result.get("reason") or "managed settings transaction was not applied"
|
|
3396
|
+
raise SystemExit(f"Could not safely update {settings_path}: {reason}")
|
|
3397
|
+
if write_result.get("backup_path"):
|
|
3398
|
+
backup_path = Path(write_result["backup_path"])
|
|
3399
|
+
rollback_id = rollback_state.get("rollback_id")
|
|
3400
|
+
rollback_path = rollback_state.get("rollback_path")
|
|
3401
|
+
if write_result["status"] == "applied-durability-uncertain" and write_result.get("reason"):
|
|
3402
|
+
warnings.append(str(write_result["reason"]))
|
|
3403
|
+
if write_result.get("residual_risk"):
|
|
3404
|
+
warnings.append(str(write_result["residual_risk"]))
|
|
3405
|
+
claude_settings_written = True
|
|
2499
3406
|
|
|
2500
3407
|
# Build the per-adapter plan; repo-rule writes happen here when an applying
|
|
2501
3408
|
# run (--yes) requested --with-init or project-scope --brief-mode.
|
|
@@ -2559,6 +3466,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2559
3466
|
parser.add_argument("--dry-run", action="store_true", help="alias for --plan")
|
|
2560
3467
|
parser.add_argument("--verify", action="store_true", help="run a read-only setup health check; never writes or prompts")
|
|
2561
3468
|
parser.add_argument("--json", action="store_true", help="print machine-readable result")
|
|
3469
|
+
parser.add_argument(
|
|
3470
|
+
"--rules-only",
|
|
3471
|
+
action="store_true",
|
|
3472
|
+
help="run an isolated rule-file operation without reading or changing settings/hooks",
|
|
3473
|
+
)
|
|
3474
|
+
parser.add_argument(
|
|
3475
|
+
"--narration-mode",
|
|
3476
|
+
choices=NARRATION_MODE_CHOICES,
|
|
3477
|
+
default=None,
|
|
3478
|
+
help="with --rules-only, add quiet Claude narration guidance or restore default behavior",
|
|
3479
|
+
)
|
|
2562
3480
|
parser.add_argument("--no-backup", action="store_true", help="do not create .bak-* before modifying existing settings")
|
|
2563
3481
|
parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
|
|
2564
3482
|
parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
|
|
@@ -2613,7 +3531,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2613
3531
|
dest="failed_attempt_nudge",
|
|
2614
3532
|
action="store_true",
|
|
2615
3533
|
default=None,
|
|
2616
|
-
help="enable
|
|
3534
|
+
help="enable Bash terminal-event hooks that suggest /clear when the same command fails twice in a row (recommended default)",
|
|
2617
3535
|
)
|
|
2618
3536
|
nudge_group.add_argument(
|
|
2619
3537
|
"--no-failed-attempt-nudge",
|
|
@@ -2628,6 +3546,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2628
3546
|
def main() -> int:
|
|
2629
3547
|
parser = build_parser()
|
|
2630
3548
|
args = parser.parse_args()
|
|
3549
|
+
rules_only = validate_rules_only_args(parser, args)
|
|
3550
|
+
if rules_only:
|
|
3551
|
+
result = run_quiet_narration_rules(args)
|
|
3552
|
+
if args.json:
|
|
3553
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
3554
|
+
else:
|
|
3555
|
+
print(render_quiet_narration_text(result), end="")
|
|
3556
|
+
return 0
|
|
2631
3557
|
if args.dry_run:
|
|
2632
3558
|
args.plan = True
|
|
2633
3559
|
if args.verify and args.yes:
|