@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.2
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 +36 -0
- package/docs/facts.json +3 -3
- package/index.js +1 -0
- package/install/_codex-agents.mjs +1 -1
- package/install/_common.mjs +486 -53
- package/install/_mcp-register.mjs +173 -117
- package/install/claude.mjs +281 -220
- package/install/codex.mjs +7 -7
- package/install/copilot.mjs +13 -11
- package/install/index.mjs +92 -27
- package/install/templates/claude-hooks.json +9 -9
- package/manifest.json +75 -78
- package/package.json +4 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
- package/pipeline/lib/confusables.json +79 -33
- package/pipeline/lib/extract-conventions.sh +3 -3
- package/pipeline/lib/json-file-lock.mjs +27 -7
- package/pipeline/lib/normalize-text.mjs +86 -17
- package/pipeline/lib/outbound-gate.mjs +13 -4
- package/pipeline/lib/redact.mjs +87 -14
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
- package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/scripts/agent-guard.py +150 -25
- package/pipeline/scripts/audit-log.sh +3 -4
- package/pipeline/scripts/autopilot-runner.mjs +14 -5
- package/pipeline/scripts/doctor.mjs +8 -2
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -31
- package/pipeline/scripts/scan-agent-config.sh +9 -9
- package/pipeline/scripts/unattended_policy.py +12 -3
- package/pipeline/scripts/uninstall.mjs +88 -1
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +11 -11
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/scripts/gen-ref-toc.mjs +0 -279
- package/pipeline/scripts/make-manifest.mjs +0 -199
- package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
|
@@ -51,12 +51,24 @@ ATTRIBUTION = re.compile(
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _strip_set(ranges):
|
|
55
|
+
out = set()
|
|
56
|
+
for spec in ranges:
|
|
57
|
+
first, _, last = str(spec).partition("-")
|
|
58
|
+
try:
|
|
59
|
+
lo, hi = int(first, 16), int(last or first, 16)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
out.update(chr(cp) for cp in range(lo, hi + 1))
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
54
66
|
def _load_confusables():
|
|
55
67
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
56
68
|
try:
|
|
57
69
|
with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
|
|
58
70
|
table = json.load(fh)
|
|
59
|
-
return
|
|
71
|
+
return _strip_set(table.get("stripRanges", [])), dict(table.get("map", {}))
|
|
60
72
|
except (OSError, ValueError):
|
|
61
73
|
return set(), {}
|
|
62
74
|
|
|
@@ -65,12 +77,14 @@ _STRIP, _MAP = _load_confusables()
|
|
|
65
77
|
|
|
66
78
|
|
|
67
79
|
def normalize_for_match(text: str) -> str:
|
|
68
|
-
"""NFKC,
|
|
80
|
+
"""NFKC, Default_Ignorable_Code_Point removed, look-alikes mapped to ASCII.
|
|
69
81
|
|
|
70
82
|
The same fold lib/normalize-text.mjs applies, from the same table. Rules
|
|
71
83
|
that match text content run on the raw command and on this copy, so a
|
|
72
84
|
trailer written with a zero-width space or a Cyrillic letter still matches.
|
|
73
|
-
|
|
85
|
+
The table's map also carries the compatibility letters newer than this
|
|
86
|
+
Python's unicodedata, which NFKC here would leave alone. Without the table
|
|
87
|
+
only NFKC applies.
|
|
74
88
|
"""
|
|
75
89
|
return "".join(
|
|
76
90
|
_MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
|
|
@@ -130,10 +144,23 @@ GIT_FLAG_OPTIONS = {
|
|
|
130
144
|
SEPARATORS = re.compile(r"&&|\|\||;|\||\n")
|
|
131
145
|
|
|
132
146
|
|
|
147
|
+
# shlex grows each token one character at a time, which is quadratic in the
|
|
148
|
+
# token's length: a megabyte argument takes longer than the deadline. A segment
|
|
149
|
+
# longer than this is split on whitespace with its quotes and backslashes
|
|
150
|
+
# removed instead, which keeps every word the segment names in order.
|
|
151
|
+
SHLEX_MAX_CHARS = 16384
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def coarse_tokens(cmd: str) -> list:
|
|
155
|
+
return [re.sub(r"[\"'\\]", "", t) for t in cmd.split()]
|
|
156
|
+
|
|
157
|
+
|
|
133
158
|
def tokenize(cmd: str) -> list:
|
|
134
159
|
"""shlex tokens, or whitespace tokens when shlex cannot parse (an unbalanced
|
|
135
|
-
quote). Never returns None: a guard has to
|
|
136
|
-
parse cleanly, not skip it."""
|
|
160
|
+
quote) or the segment is too long for it. Never returns None: a guard has to
|
|
161
|
+
reason about a segment it cannot parse cleanly, not skip it."""
|
|
162
|
+
if len(cmd) > SHLEX_MAX_CHARS:
|
|
163
|
+
return coarse_tokens(cmd)
|
|
137
164
|
try:
|
|
138
165
|
return shlex.split(cmd)
|
|
139
166
|
except Exception:
|
|
@@ -401,11 +428,12 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
|
|
|
401
428
|
force_flag = bool(FORCE.search(cmd))
|
|
402
429
|
if not force_flag and not FORCED_REFSPEC.search(cmd) and not PUSH_GATE.search(cmd):
|
|
403
430
|
return "OK"
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
431
|
+
if len(cmd) <= SHLEX_MAX_CHARS:
|
|
432
|
+
try:
|
|
433
|
+
shlex.split(cmd)
|
|
434
|
+
except Exception:
|
|
435
|
+
# A force-push we can't tokenize -> can't prove it's safe -> block.
|
|
436
|
+
return "BLOCK_FORCE"
|
|
409
437
|
git_dir, opts, positionals = _parse_push(toks)
|
|
410
438
|
keys = {k for k, _ in opts}
|
|
411
439
|
force_flag = any(
|
|
@@ -451,17 +479,97 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
|
|
|
451
479
|
return "OK"
|
|
452
480
|
|
|
453
481
|
|
|
482
|
+
FALLBACK_SPLIT = re.compile(r"&&|\|\||[;|\n()`]|\\n")
|
|
483
|
+
FALLBACK_FORCE_WORDS = {"-d", "--force", "--force-with-lease", "--force-if-includes", "--mirror", "--delete", "--prune"}
|
|
484
|
+
FALLBACK_TARGET_WORDS = {"head", "@", "--all", "--mirror", "--branches"}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _fallback_forced(tok: str) -> bool:
|
|
488
|
+
key = tok.split("=", 1)[0]
|
|
489
|
+
if key in FALLBACK_FORCE_WORDS or tok.startswith("+") or ":+" in tok or tok.startswith(":"):
|
|
490
|
+
return True
|
|
491
|
+
return tok.startswith("-") and not tok.startswith("--") and tok[1:].isalpha() and "f" in tok
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _fallback_protected(tok: str) -> bool:
|
|
495
|
+
name = tok.rsplit(":", 1)[-1].lstrip("+")
|
|
496
|
+
if name.startswith("refs/heads/"):
|
|
497
|
+
name = name[len("refs/heads/"):]
|
|
498
|
+
return name in PROTECTED or tok in FALLBACK_TARGET_WORDS
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def fallback_verdict(text: str) -> str:
|
|
502
|
+
"""The always-on rules as a linear scan, for input the tokenizer could not
|
|
503
|
+
finish in time. Each piece between shell separators is split on whitespace
|
|
504
|
+
with its quotes and backslashes removed, and no regex that can backtrack
|
|
505
|
+
runs over it. A push piece with a force indicator is blocked when it names a
|
|
506
|
+
protected branch, HEAD / @ or every ref, or when the checkout's own branch
|
|
507
|
+
is protected or unknown; a line with a git commit is blocked when the
|
|
508
|
+
attribution pattern matches the raw or the folded text. Stricter than
|
|
509
|
+
decide() by design: it only runs when decide() could not."""
|
|
510
|
+
try:
|
|
511
|
+
folded = normalize_for_match(text)
|
|
512
|
+
except Exception:
|
|
513
|
+
folded = text
|
|
514
|
+
current = os.environ.get("CUR_BRANCH", "")
|
|
515
|
+
commits = False
|
|
516
|
+
for t in (text, folded):
|
|
517
|
+
for piece in FALLBACK_SPLIT.split(t.lower()):
|
|
518
|
+
toks = coarse_tokens(piece)
|
|
519
|
+
if not any(os.path.basename(x) == "git" for x in toks):
|
|
520
|
+
continue
|
|
521
|
+
words = set(toks)
|
|
522
|
+
if "commit" in words:
|
|
523
|
+
commits = True
|
|
524
|
+
if "push" not in words or not any(_fallback_forced(x) for x in toks):
|
|
525
|
+
continue
|
|
526
|
+
if any(_fallback_protected(x) for x in toks) or current in PROTECTED or current in ("", "HEAD"):
|
|
527
|
+
return "BLOCK_FORCE"
|
|
528
|
+
if commits and (ATTRIBUTION.search(text) or ATTRIBUTION.search(folded)):
|
|
529
|
+
return "BLOCK_ATTRIB"
|
|
530
|
+
return "OK"
|
|
531
|
+
|
|
532
|
+
|
|
454
533
|
def unattended() -> bool:
|
|
455
534
|
"""MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
|
|
456
535
|
never anything the run wrote (lib/unattended.sh ma_unattended)."""
|
|
457
536
|
return os.environ.get("MULTI_AGENT_UNATTENDED", "") == "1"
|
|
458
537
|
|
|
459
538
|
|
|
539
|
+
def _arm_deadline(on_deadline) -> None:
|
|
540
|
+
"""Run on_deadline (which must end the process) once deadline_seconds()
|
|
541
|
+
have passed. Where SIGALRM is unavailable the call simply runs to the end."""
|
|
542
|
+
|
|
543
|
+
def _fire(_signum, _frame):
|
|
544
|
+
try:
|
|
545
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
546
|
+
except (ValueError, AttributeError, OSError):
|
|
547
|
+
pass
|
|
548
|
+
on_deadline()
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
signal.signal(signal.SIGALRM, _fire)
|
|
552
|
+
signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
|
|
553
|
+
except (ValueError, AttributeError, OSError):
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _exit_with(line: str) -> None:
|
|
558
|
+
# os._exit skips the stdio flush, so the line is flushed first or the
|
|
559
|
+
# caller reads nothing.
|
|
560
|
+
if line:
|
|
561
|
+
print(line, flush=True)
|
|
562
|
+
os._exit(0)
|
|
563
|
+
|
|
564
|
+
|
|
460
565
|
def git_subcommand_mode() -> None:
|
|
461
566
|
"""`agent-guard.py --git-subcommand`: read a Bash payload (or a raw command
|
|
462
567
|
on argv) and print each git subcommand it runs, one per line, using the same
|
|
463
568
|
tokenizer the guard uses. pre-commit-check.sh reads this to detect a commit
|
|
464
|
-
without a regex. Never executes anything.
|
|
569
|
+
without a regex. Never executes anything. At the deadline it prints
|
|
570
|
+
`commit`, so the caller scans rather than skipping a commit it could not
|
|
571
|
+
rule out."""
|
|
572
|
+
_arm_deadline(lambda: _exit_with("commit"))
|
|
465
573
|
if len(sys.argv) > 2:
|
|
466
574
|
cmd = sys.argv[2]
|
|
467
575
|
else:
|
|
@@ -490,8 +598,9 @@ def commit_dir_mode() -> None:
|
|
|
490
598
|
"""`agent-guard.py --commit-dir`: read a Bash payload and print the
|
|
491
599
|
directory the first commit-writing git command on the line runs in, after
|
|
492
600
|
any `cd` / `git -C`, resolved from the payload's cwd. Prints nothing when
|
|
493
|
-
there is no commit
|
|
494
|
-
keeps its own cwd. Never executes anything."""
|
|
601
|
+
there is no commit, the directory cannot be read literally, or the deadline
|
|
602
|
+
passes, so the caller keeps its own cwd. Never executes anything."""
|
|
603
|
+
_arm_deadline(lambda: _exit_with(""))
|
|
495
604
|
try:
|
|
496
605
|
data = json.load(sys.stdin)
|
|
497
606
|
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
@@ -519,21 +628,37 @@ def main() -> None:
|
|
|
519
628
|
commit_dir_mode()
|
|
520
629
|
return
|
|
521
630
|
strict = unattended()
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
# the
|
|
526
|
-
|
|
527
|
-
|
|
631
|
+
seen = {"text": "", "raw": True}
|
|
632
|
+
|
|
633
|
+
def _unjudged(default: str) -> str:
|
|
634
|
+
# The always-on rules still hold when the full decision could not be
|
|
635
|
+
# reached: a linear scan of whatever text is at hand decides them.
|
|
636
|
+
text = seen["text"]
|
|
637
|
+
if seen.get("raw"):
|
|
638
|
+
try:
|
|
639
|
+
cmd = (json.loads(text).get("tool_input") or {}).get("command", "")
|
|
640
|
+
text = cmd if isinstance(cmd, str) else text
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
try:
|
|
644
|
+
verdict = fallback_verdict(text)
|
|
645
|
+
except Exception:
|
|
646
|
+
verdict = "OK"
|
|
647
|
+
return verdict if verdict != "OK" else default
|
|
528
648
|
|
|
529
649
|
try:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
650
|
+
seen["text"] = sys.stdin.read()
|
|
651
|
+
except Exception:
|
|
652
|
+
seen["text"] = ""
|
|
653
|
+
# Armed once the payload is in hand, so the fallback always has the text.
|
|
654
|
+
_arm_deadline(
|
|
655
|
+
lambda: _exit_with(_unjudged("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK"))
|
|
656
|
+
)
|
|
534
657
|
try:
|
|
535
|
-
data = json.
|
|
658
|
+
data = json.loads(seen["text"])
|
|
536
659
|
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
660
|
+
if isinstance(cmd, str):
|
|
661
|
+
seen["text"], seen["raw"] = cmd, False
|
|
537
662
|
verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
|
|
538
663
|
if verdict == "OK" and strict:
|
|
539
664
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -547,7 +672,7 @@ def main() -> None:
|
|
|
547
672
|
# Attended: a guard bug must not break a legitimate tool call.
|
|
548
673
|
# Unattended: nobody is there to notice a gap, so an input the guard
|
|
549
674
|
# cannot judge is refused.
|
|
550
|
-
print("BLOCK_PARSE" if strict else "OK")
|
|
675
|
+
print(_unjudged("BLOCK_PARSE" if strict else "OK"))
|
|
551
676
|
|
|
552
677
|
|
|
553
678
|
if __name__ == "__main__":
|
|
@@ -56,10 +56,9 @@ hash_url() {
|
|
|
56
56
|
elif command -v shasum >/dev/null 2>&1; then
|
|
57
57
|
printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
|
|
58
58
|
else
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# leaking one.
|
|
59
|
+
# No hash tool: record a fixed marker, never the URL itself. The plaintext
|
|
60
|
+
# remote is what the hashing keeps out of the log, and a remote URL can
|
|
61
|
+
# carry embedded credentials.
|
|
63
62
|
echo "unhashable-no-sha256-tool"
|
|
64
63
|
fi
|
|
65
64
|
}
|
|
@@ -263,10 +263,11 @@ const GUARD_MATCHERS = [
|
|
|
263
263
|
],
|
|
264
264
|
],
|
|
265
265
|
];
|
|
266
|
-
// The hook command install/claude.mjs writes
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
266
|
+
// The hook command install/claude.mjs writes (the path quoted, so a HOME with a
|
|
267
|
+
// space still runs it). Only this command, quoted or not, with $HOME written
|
|
268
|
+
// literally or expanded, counts as the guard: a command that merely mentions
|
|
269
|
+
// the script path can run anything and still look registered.
|
|
270
|
+
const GUARD_COMMAND = 'bash "$HOME/.claude/scripts/agent-guard.sh"';
|
|
270
271
|
const MANAGED_SETTINGS = "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
271
272
|
// A phase record without a model is priced at the most expensive tier: an
|
|
272
273
|
// over-estimate stops the spend ceiling early, an under-estimate stops it late.
|
|
@@ -1334,7 +1335,15 @@ function matcherCovers(matcher, tool) {
|
|
|
1334
1335
|
/** The guard script, when the hook command is exactly the installer's and the script exists. */
|
|
1335
1336
|
function guardScriptOf(command, home) {
|
|
1336
1337
|
const script = join(home, ".claude", "scripts", "agent-guard.sh");
|
|
1337
|
-
const
|
|
1338
|
+
const unquoted = GUARD_COMMAND.replaceAll('"', "");
|
|
1339
|
+
const forms = [
|
|
1340
|
+
GUARD_COMMAND,
|
|
1341
|
+
GUARD_COMMAND.replace("$HOME", "${HOME}"),
|
|
1342
|
+
unquoted,
|
|
1343
|
+
unquoted.replace("$HOME", "${HOME}"),
|
|
1344
|
+
`bash "${script}"`,
|
|
1345
|
+
`bash ${script}`,
|
|
1346
|
+
];
|
|
1338
1347
|
return forms.includes(String(command || "").trim()) && existsSync(script) ? script : null;
|
|
1339
1348
|
}
|
|
1340
1349
|
|
|
@@ -465,8 +465,14 @@ function checkHookCoverage() {
|
|
|
465
465
|
for (const h of entry.hooks || []) if (h.command) wantedCmds.add(h.command);
|
|
466
466
|
}
|
|
467
467
|
}
|
|
468
|
-
const
|
|
469
|
-
const
|
|
468
|
+
const haveCmds = new Set();
|
|
469
|
+
for (const group of Object.values(have?.hooks || {})) {
|
|
470
|
+
for (const entry of Array.isArray(group) ? group : []) {
|
|
471
|
+
for (const h of entry?.hooks || [])
|
|
472
|
+
if (typeof h?.command === "string") haveCmds.add(h.command);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const absent = [...wantedCmds].filter((c) => !haveCmds.has(c));
|
|
470
476
|
if (absent.length) {
|
|
471
477
|
report(
|
|
472
478
|
"hook-coverage",
|
|
@@ -55,6 +55,12 @@ mkdir -p "$METRICS_DIR" 2>/dev/null || {
|
|
|
55
55
|
exit 0
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
# Same escaping as audit-log.sh: backslash, quote, tab and CR escaped, and a
|
|
59
|
+
# newline written as \n, so any caller text stays inside one JSON string.
|
|
60
|
+
json_escape() {
|
|
61
|
+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g; s/\r/\\r/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }'
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
# Build details object from key=value args. Keep it shell-portable: no jq required.
|
|
59
65
|
DETAILS="{"
|
|
60
66
|
SEP=""
|
|
@@ -65,10 +71,10 @@ for kv in "$@"; do
|
|
|
65
71
|
# int or true/false becomes a JSON string.
|
|
66
72
|
case "$VAL" in
|
|
67
73
|
true|false) JSON_VAL="$VAL" ;;
|
|
68
|
-
''|*[!0-9]*) JSON_VAL="\"$(
|
|
74
|
+
''|*[!0-9]*) JSON_VAL="\"$(json_escape "$VAL")\"" ;;
|
|
69
75
|
*) JSON_VAL="$VAL" ;;
|
|
70
76
|
esac
|
|
71
|
-
DETAILS="${DETAILS}${SEP}\"$
|
|
77
|
+
DETAILS="${DETAILS}${SEP}\"$(json_escape "$KEY")\":${JSON_VAL}"
|
|
72
78
|
SEP=","
|
|
73
79
|
done
|
|
74
80
|
DETAILS="${DETAILS}}"
|
|
@@ -79,7 +85,7 @@ PHASE_SCHEMA="$(node -e 'const c=require("'"$(cd "$(dirname "$0")" && pwd)"'/../
|
|
|
79
85
|
[ -n "$PHASE_SCHEMA" ] || PHASE_SCHEMA=2
|
|
80
86
|
|
|
81
87
|
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
82
|
-
LINE="{\"ts\":\"${TS}\",\"task_id\":\"$
|
|
88
|
+
LINE="{\"ts\":\"${TS}\",\"task_id\":\"$(json_escape "$TASK_ID")\",\"phase\":\"$(json_escape "$PHASE")\",\"event\":\"$(json_escape "$EVENT")\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
|
|
83
89
|
|
|
84
90
|
# Caller-supplied values can carry a credential copied out of an error.
|
|
85
91
|
# redact.mjs replaces it with a marker; without node the line is written as is.
|
|
@@ -522,10 +522,23 @@ function migrateReportChannels(out, changes) {
|
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
// Writes through a symlinked prefs file to its target, so a prefs file kept in
|
|
526
|
+
// a dotfiles repo stays a link and the repo copy receives the migration.
|
|
525
527
|
function atomicWrite(filePath, data) {
|
|
526
|
-
withFileLockSync(
|
|
527
|
-
|
|
528
|
-
|
|
528
|
+
withFileLockSync(
|
|
529
|
+
filePath,
|
|
530
|
+
() => writeJsonAtomicSync(filePath, data, { mode: 0o600, followSymlink: true }),
|
|
531
|
+
{ timeoutMs: lockTimeoutFrom(process.env) },
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The document as it was before this migration, beside the path given, so a
|
|
536
|
+
// migration that went wrong can be undone by hand.
|
|
537
|
+
function writeBackup(filePath, raw) {
|
|
538
|
+
const bak = `${filePath}.bak`;
|
|
539
|
+
fs.writeFileSync(bak, raw, { mode: 0o600 });
|
|
540
|
+
fs.chmodSync(bak, 0o600);
|
|
541
|
+
return bak;
|
|
529
542
|
}
|
|
530
543
|
|
|
531
544
|
function main() {
|
|
@@ -560,8 +573,9 @@ function main() {
|
|
|
560
573
|
process.exit(0);
|
|
561
574
|
}
|
|
562
575
|
|
|
576
|
+
const bak = writeBackup(args.file, raw);
|
|
563
577
|
atomicWrite(args.file, out);
|
|
564
|
-
console.log(`✓ wrote migrated prefs to ${args.file}`);
|
|
578
|
+
console.log(`✓ wrote migrated prefs to ${args.file} (previous version: ${bak})`);
|
|
565
579
|
}
|
|
566
580
|
|
|
567
581
|
runMain("migrate-prefs", main);
|
|
@@ -85,6 +85,13 @@ process.stdin.on("end", () => {
|
|
|
85
85
|
});
|
|
86
86
|
' 2>/dev/null || true)"
|
|
87
87
|
fi
|
|
88
|
+
# A command that never spells git, even with its quotes and backslashes
|
|
89
|
+
# removed (g'i't, "git", \git), cannot run it through any path the helper
|
|
90
|
+
# below would recognise, so it returns before paying for the parse.
|
|
91
|
+
if [ -n "$HOOK_COMMAND" ] \
|
|
92
|
+
&& ! printf '%s' "$HOOK_COMMAND" | tr -d "\"'\\\\" | grep -qi 'git'; then
|
|
93
|
+
exit 0
|
|
94
|
+
fi
|
|
88
95
|
if [ -n "$HOOK_COMMAND" ]; then
|
|
89
96
|
# Commit detection is a tokenizer, not a regex: agent-guard.py shares its
|
|
90
97
|
# shell parser through `--git-subcommand`, so an absolute git path, a
|
|
@@ -156,7 +163,7 @@ is_binary() {
|
|
|
156
163
|
# a diff, or the whole file for something not yet tracked at all - every line
|
|
157
164
|
# of a brand-new file is an addition).
|
|
158
165
|
scan_file() {
|
|
159
|
-
local file="$1" content="$2"
|
|
166
|
+
local file="$1" content="$2" folded="${3:-}"
|
|
160
167
|
|
|
161
168
|
# .env files
|
|
162
169
|
if echo "$file" | grep -qE '\.env($|\.)'; then
|
|
@@ -174,21 +181,11 @@ scan_file() {
|
|
|
174
181
|
|
|
175
182
|
[ -z "$content" ] && return 0
|
|
176
183
|
|
|
177
|
-
# A token written with an invisible character or a look-alike letter:
|
|
178
|
-
# folded copy
|
|
179
|
-
if
|
|
180
|
-
|
|
181
|
-
echo "pre-commit-check: node or lib/normalize-text.mjs unavailable - non-ASCII content scanned without look-alike folding" >&2
|
|
182
|
-
fi
|
|
183
|
-
if printf '%s' "$content" | LC_ALL=C grep -q '[^ -~[:space:]]' 2>/dev/null \
|
|
184
|
-
&& command -v node >/dev/null 2>&1 \
|
|
185
|
-
&& [ -f "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" ]; then
|
|
186
|
-
local folded
|
|
187
|
-
folded=$(printf '%s' "$content" | node "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" 2>/dev/null) || folded=""
|
|
188
|
-
if [ -n "$folded" ] && [ "$folded" != "$content" ]; then
|
|
189
|
-
content="$content
|
|
184
|
+
# A token written with an invisible character or a look-alike letter: the
|
|
185
|
+
# folded copy (see fold_and_scan) is scanned together with the raw text.
|
|
186
|
+
if [ -n "$folded" ] && [ "$folded" != "$content" ]; then
|
|
187
|
+
content="$content
|
|
190
188
|
$folded"
|
|
191
|
-
fi
|
|
192
189
|
fi
|
|
193
190
|
|
|
194
191
|
# API keys / tokens (key = "long_string" pattern)
|
|
@@ -285,15 +282,50 @@ $folded"
|
|
|
285
282
|
if [ -n "$candidates" ]; then
|
|
286
283
|
entropy_hit=$(printf '%s\n' "$candidates" | awk '
|
|
287
284
|
# Word-shaped identifier: letters and _ or - only, split into segments at
|
|
288
|
-
# separators and case boundaries (UIApplication -> UI, Application),
|
|
289
|
-
# the segments
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
|
|
285
|
+
# separators and case boundaries (UIApplication -> UI, Application),
|
|
286
|
+
# where the segments average 4+ letters, none is longer than 24, and the
|
|
287
|
+
# letter pairs inside them read like English. PAIR scores every pair as
|
|
288
|
+
# log2 of how often it occurs across a 235k-word English word list
|
|
289
|
+
# relative to a uniform draw, rounded and clipped to -4..4 (stored +4,
|
|
290
|
+
# one row per first letter). Identifiers made of words average above
|
|
291
|
+
# +0.5 per pair even with an abbreviation or two (Qr, Ffp, Otp, Kg);
|
|
292
|
+
# uniformly random letters average below +0.1, so the mean must reach
|
|
293
|
+
# +0.25. Clipping keeps one unusual pair from outweighing the rest. An
|
|
294
|
+
# all-caps segment of up to five letters inside a token that also has
|
|
295
|
+
# lowercase letters is an acronym (URL, HTTP, JSON) and is not scored.
|
|
296
|
+
BEGIN {
|
|
297
|
+
rows = "06655352403757150767433232 52015000500600400421400010 " \
|
|
298
|
+
"60306006504400600515500040 50026021600313500420301030 " \
|
|
299
|
+
"63564442401657452876433420 40004300400400400302400020 " \
|
|
300
|
+
"50005033500423400510400030 60006000600322600403301050 " \
|
|
301
|
+
"64764551103657651577350105 20002000000000200000200000 " \
|
|
302
|
+
"30005001400201200010100010 71237220701622630024520060 " \
|
|
303
|
+
"64006000600032650010400040 62667462613324631357422031 " \
|
|
304
|
+
"44553352403667560766644321 60006006500501630644400030 " \
|
|
305
|
+
"00000000000000000000400000 73447233703354740445432050 " \
|
|
306
|
+
"51506105602453552067502040 61307106700421600624502050 " \
|
|
307
|
+
"44443230401657240665000000 40006000400000300000100000 " \
|
|
308
|
+
"40004002400102400100000000 20102000300000220002000020 " \
|
|
309
|
+
"31332020200433340343000000 30004000200000300000000011";
|
|
310
|
+
split(rows, row, " ");
|
|
311
|
+
alpha = "abcdefghijklmnopqrstuvwxyz";
|
|
312
|
+
for (r = 1; r <= 26; r++)
|
|
313
|
+
for (k = 1; k <= 26; k++)
|
|
314
|
+
PAIR[substr(alpha, r, 1) substr(alpha, k, 1)] = substr(row[r], k, 1) - 4;
|
|
315
|
+
}
|
|
316
|
+
function score_segment(seg, has_lower, l, j) {
|
|
317
|
+
if (has_lower && seg ~ /^[A-Z]+$/ && length(seg) <= 5) return;
|
|
318
|
+
l = tolower(seg);
|
|
319
|
+
for (j = 2; j <= length(l); j++) {
|
|
320
|
+
pairs++;
|
|
321
|
+
pair_score += PAIR[substr(l, j - 1, 2)];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function wordlike(t, n, i, c, p, nx, seg, nseg, total, maxlen, split_here, has_lower) {
|
|
295
325
|
if (t !~ /^[A-Za-z_-]+$/) return 0;
|
|
296
|
-
|
|
326
|
+
has_lower = (t ~ /[a-z]/);
|
|
327
|
+
pairs = 0; pair_score = 0;
|
|
328
|
+
n = length(t); seg = ""; nseg = 0; total = 0; maxlen = 0;
|
|
297
329
|
for (i = 1; i <= n + 1; i++) {
|
|
298
330
|
c = (i <= n) ? substr(t, i, 1) : "_";
|
|
299
331
|
split_here = 0;
|
|
@@ -306,13 +338,13 @@ $folded"
|
|
|
306
338
|
if (split_here && seg != "") {
|
|
307
339
|
nseg++; total += length(seg);
|
|
308
340
|
if (length(seg) > maxlen) maxlen = length(seg);
|
|
309
|
-
|
|
341
|
+
score_segment(seg, has_lower);
|
|
310
342
|
seg = "";
|
|
311
343
|
}
|
|
312
344
|
if (c != "_" && c != "-") seg = seg c;
|
|
313
345
|
}
|
|
314
|
-
if (nseg == 0) return 0;
|
|
315
|
-
return (total / nseg >= 4 && maxlen <= 24 &&
|
|
346
|
+
if (nseg == 0 || pairs == 0) return 0;
|
|
347
|
+
return (total / nseg >= 4 && maxlen <= 24 && pair_score * 4 >= pairs);
|
|
316
348
|
}
|
|
317
349
|
{
|
|
318
350
|
s=$0;
|
|
@@ -361,9 +393,63 @@ $folded"
|
|
|
361
393
|
fi
|
|
362
394
|
}
|
|
363
395
|
|
|
396
|
+
# Files are queued and scanned together so that the look-alike fold runs in
|
|
397
|
+
# one node process for every non-ASCII file of the run rather than one per
|
|
398
|
+
# file. ASCII content folds to itself and is not sent.
|
|
399
|
+
Q_FILES=()
|
|
400
|
+
Q_CONTENTS=()
|
|
401
|
+
queue_file() {
|
|
402
|
+
Q_FILES+=("$1")
|
|
403
|
+
Q_CONTENTS+=("$2")
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
FOLD_WARNED=0
|
|
407
|
+
fold_warn() {
|
|
408
|
+
[ "$FOLD_WARNED" = "1" ] && return 0
|
|
409
|
+
FOLD_WARNED=1
|
|
410
|
+
echo "pre-commit-check: $1 - non-ASCII content scanned without look-alike folding" >&2
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fold_and_scan() {
|
|
414
|
+
local i n=${#Q_FILES[@]} lib="$PRE_COMMIT_DIR/../lib/normalize-text.mjs" out rec
|
|
415
|
+
local -a idx=() folded=()
|
|
416
|
+
for ((i = 0; i < n; i++)); do
|
|
417
|
+
folded[i]=""
|
|
418
|
+
if printf '%s' "${Q_CONTENTS[i]}" | LC_ALL=C grep -q '[^ -~[:space:]]' 2>/dev/null; then
|
|
419
|
+
idx+=("$i")
|
|
420
|
+
fi
|
|
421
|
+
done
|
|
422
|
+
if [ "${#idx[@]}" -gt 0 ]; then
|
|
423
|
+
if ! command -v node >/dev/null 2>&1 || [ ! -f "$lib" ]; then
|
|
424
|
+
fold_warn "node or lib/normalize-text.mjs unavailable"
|
|
425
|
+
else
|
|
426
|
+
out=$(mktemp "${TMPDIR:-/tmp}/pre-commit-fold.XXXXXX") || out=""
|
|
427
|
+
if [ -n "$out" ] && for i in "${idx[@]}"; do printf '%s\0' "${Q_CONTENTS[i]}"; done \
|
|
428
|
+
| node "$lib" --nul >"$out" 2>/dev/null; then
|
|
429
|
+
local -a recs=()
|
|
430
|
+
while IFS= read -r -d '' rec; do recs+=("$rec"); done <"$out"
|
|
431
|
+
if [ "${#recs[@]}" -eq "${#idx[@]}" ]; then
|
|
432
|
+
for ((i = 0; i < ${#idx[@]}; i++)); do folded[idx[i]]="${recs[i]}"; done
|
|
433
|
+
else
|
|
434
|
+
fold_warn "the look-alike fold returned ${#recs[@]} of ${#idx[@]} records"
|
|
435
|
+
fi
|
|
436
|
+
else
|
|
437
|
+
fold_warn "the look-alike fold (node lib/normalize-text.mjs) failed"
|
|
438
|
+
fi
|
|
439
|
+
[ -n "$out" ] && rm -f "$out"
|
|
440
|
+
fi
|
|
441
|
+
fi
|
|
442
|
+
for ((i = 0; i < n; i++)); do
|
|
443
|
+
scan_file "${Q_FILES[i]}" "${Q_CONTENTS[i]}" "${folded[i]}"
|
|
444
|
+
done
|
|
445
|
+
Q_FILES=()
|
|
446
|
+
Q_CONTENTS=()
|
|
447
|
+
}
|
|
448
|
+
|
|
364
449
|
if [ -n "$SCAN_FILE_TARGET" ]; then
|
|
365
450
|
is_binary "$SCAN_FILE_TARGET" && exit 0
|
|
366
|
-
|
|
451
|
+
queue_file "$SCAN_FILE_TARGET" "$(cat "$SCAN_FILE_TARGET" 2>/dev/null)"
|
|
452
|
+
fold_and_scan
|
|
367
453
|
if [ "$FOUND" -eq 1 ]; then
|
|
368
454
|
echo "" >&2
|
|
369
455
|
echo "Secret check failed on $SCAN_FILE_TARGET." >&2
|
|
@@ -406,10 +492,11 @@ if [ -n "$SCAN_RANGE" ]; then
|
|
|
406
492
|
# blob at this commit so a NUL-laden file cannot hide a key.
|
|
407
493
|
content=$(gitscan show --text --no-textconv "${commit}:${file}" 2>/dev/null || true)
|
|
408
494
|
fi
|
|
409
|
-
|
|
495
|
+
queue_file "$file" "$content"
|
|
410
496
|
done < <(gitscan diff-tree --no-commit-id -r --root -m --first-parent \
|
|
411
497
|
--name-only -z --diff-filter=d "$commit" 2>/dev/null)
|
|
412
498
|
done
|
|
499
|
+
fold_and_scan
|
|
413
500
|
if [ "$FOUND" -eq 1 ]; then
|
|
414
501
|
echo "" >&2
|
|
415
502
|
echo "Secret check failed on $SCAN_RANGE." >&2
|
|
@@ -428,7 +515,7 @@ while IFS= read -r -d '' file; do
|
|
|
428
515
|
[ ! -f "$file" ] && continue
|
|
429
516
|
is_binary "$file" && continue
|
|
430
517
|
content=$(git diff --cached -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
|
|
431
|
-
|
|
518
|
+
queue_file "$file" "$content"
|
|
432
519
|
done < <(git diff --cached --name-only -z 2>/dev/null)
|
|
433
520
|
|
|
434
521
|
if [ "$SCAN_UNSTAGED" = "1" ]; then
|
|
@@ -439,7 +526,7 @@ if [ "$SCAN_UNSTAGED" = "1" ]; then
|
|
|
439
526
|
[ ! -f "$file" ] && continue
|
|
440
527
|
is_binary "$file" && continue
|
|
441
528
|
content=$(git diff HEAD -- "$file" 2>/dev/null | grep "^+" | grep -v "^+++" || true)
|
|
442
|
-
|
|
529
|
+
queue_file "$file" "$content"
|
|
443
530
|
done < <(git diff HEAD --name-only -z 2>/dev/null)
|
|
444
531
|
|
|
445
532
|
# Untracked (brand-new) files - there is no diff to take yet, so every line
|
|
@@ -449,9 +536,10 @@ if [ "$SCAN_UNSTAGED" = "1" ]; then
|
|
|
449
536
|
[ ! -f "$file" ] && continue
|
|
450
537
|
is_binary "$file" && continue
|
|
451
538
|
content=$(sed 's/^/+/' "$file" 2>/dev/null || true)
|
|
452
|
-
|
|
539
|
+
queue_file "$file" "$content"
|
|
453
540
|
done < <(git ls-files --others --exclude-standard -z 2>/dev/null)
|
|
454
541
|
fi
|
|
542
|
+
fold_and_scan
|
|
455
543
|
|
|
456
544
|
if [ $FOUND -eq 1 ]; then
|
|
457
545
|
echo "" >&2
|
|
@@ -123,16 +123,16 @@ for _event, entries in (t.get("hooks") or {}).items():
|
|
|
123
123
|
for e in entries:
|
|
124
124
|
for h in e.get("hooks", []):
|
|
125
125
|
c = h.get("command", "")
|
|
126
|
-
# Allow only `bash $HOME/.claude/scripts/<name
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
# still runs on the whole string.
|
|
126
|
+
# Allow only `bash "$HOME/.claude/scripts/<name>"` (the path quoted
|
|
127
|
+
# so a HOME with a space still runs it, or unquoted) plus plain long
|
|
128
|
+
# flags. SessionEnd needs one: capture-flush takes --if-stale so a
|
|
129
|
+
# session end does not re-flush a run that already finished. What
|
|
130
|
+
# stays banned is everything that could turn a flag into a second
|
|
131
|
+
# command - no spaces inside a token, no quotes beyond the pair
|
|
132
|
+
# around the path, no metacharacters, and the eval/substitution
|
|
133
|
+
# check below still runs on the whole string.
|
|
134
134
|
if not re.fullmatch(
|
|
135
|
-
r'bash \$HOME/\.claude/scripts/[A-Za-z0-9._-]+(?: --[a-z0-9][a-z0-9-]*)*',
|
|
135
|
+
r'bash (?:"\$HOME/\.claude/scripts/[A-Za-z0-9._-]+"|\$HOME/\.claude/scripts/[A-Za-z0-9._-]+)(?: --[a-z0-9][a-z0-9-]*)*',
|
|
136
136
|
c.strip()):
|
|
137
137
|
bad.append(c)
|
|
138
138
|
if re.search(r'\beval\b|\$\(|`|[;&|><]', c):
|