@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +75 -78
  13. package/package.json +4 -1
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -14
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/log-metric.sh +9 -3
  31. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  32. package/pipeline/scripts/pre-commit-check.sh +119 -31
  33. package/pipeline/scripts/scan-agent-config.sh +9 -9
  34. package/pipeline/scripts/unattended_policy.py +12 -3
  35. package/pipeline/scripts/uninstall.mjs +88 -1
  36. package/pipeline/scripts/usage-identity.mjs +1 -1
  37. package/pipeline/scripts/usage-register.mjs +1 -1
  38. package/pipeline/skills/.skill-manifest.json +11 -11
  39. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  40. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  41. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  42. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  43. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  44. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  47. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  60. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  61. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  62. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  63. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  64. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  65. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  66. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  67. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  68. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  69. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  70. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  71. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  72. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  73. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  74. package/pipeline/scripts/make-manifest.mjs +0 -199
  75. 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 set(table.get("strip", [])), dict(table.get("map", {}))
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, invisible characters removed, look-alike letters mapped to ASCII.
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
- Without the table only NFKC applies.
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 reason about a segment it cannot
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
- try:
405
- shlex.split(cmd)
406
- except Exception:
407
- # A force-push we can't tokenize -> can't prove it's safe -> block.
408
- return "BLOCK_FORCE"
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 or the directory cannot be read literally, so the caller
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
- def _deadline(_signum, _frame):
524
- # os._exit skips the stdio flush, so the verdict is flushed first or
525
- # the wrapper reads nothing.
526
- print("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK", flush=True)
527
- os._exit(0)
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
- signal.signal(signal.SIGALRM, _deadline)
531
- signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
532
- except (ValueError, AttributeError, OSError):
533
- pass
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.load(sys.stdin)
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
- # The previous fallback printed `unhashed:<url>`, writing the plaintext remote into
60
- # the audit log - the exact thing this hashing exists to prevent, and it would have
61
- # carried any credentials embedded in the remote URL with it. Losing one field beats
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. Only this command, with $HOME
267
- // written literally or expanded, counts as the guard: a command that merely
268
- // mentions the script path can run anything and still look registered.
269
- const GUARD_COMMAND = "bash $HOME/.claude/scripts/agent-guard.sh";
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 forms = [GUARD_COMMAND, GUARD_COMMAND.replace("$HOME", "${HOME}"), `bash ${script}`];
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 haveStr = JSON.stringify(have?.hooks || {});
469
- const absent = [...wantedCmds].filter((c) => !haveStr.includes(c));
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="\"$(printf '%s' "$VAL" | tr -d '\n\r' | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g')\"" ;;
74
+ ''|*[!0-9]*) JSON_VAL="\"$(json_escape "$VAL")\"" ;;
69
75
  *) JSON_VAL="$VAL" ;;
70
76
  esac
71
- DETAILS="${DETAILS}${SEP}\"${KEY}\":${JSON_VAL}"
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\":\"${TASK_ID}\",\"phase\":\"${PHASE}\",\"event\":\"${EVENT}\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
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(filePath, () => writeJsonAtomicSync(filePath, data, { mode: 0o600 }), {
527
- timeoutMs: lockTimeoutFrom(process.env),
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: scan a
178
- # folded copy too. Only non-ASCII content pays for the node call.
179
- if printf '%s' "$content" | LC_ALL=C grep -q '[^ -~[:space:]]' 2>/dev/null \
180
- && { ! command -v node >/dev/null 2>&1 || [ ! -f "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" ]; }; then
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), where
289
- # the segments read like words: average length 4+, none longer than 24,
290
- # and at least 70 percent carrying a vowel. Random letters-only tokens
291
- # flip case every one or two characters, so their segments average about
292
- # two characters with few vowels; a long lowercase run is one oversized
293
- # segment. Neither shape passes.
294
- function wordlike(t, n, i, c, p, nx, seg, nseg, total, maxlen, voweled, split_here) {
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
- n = length(t); seg = ""; nseg = 0; total = 0; maxlen = 0; voweled = 0;
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
- if (tolower(seg) ~ /[aeiouy]/) voweled++;
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 && voweled * 10 >= nseg * 7);
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
- scan_file "$SCAN_FILE_TARGET" "$(cat "$SCAN_FILE_TARGET" 2>/dev/null)"
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
- scan_file "$file" "$content"
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
- scan_file "$file" "$content"
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
- scan_file "$file" "$content"
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
- scan_file "$file" "$content"
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>` plus plain long
127
- # flags. Flags had to be admitted because a hook that cannot take
128
- # one cannot be correct: SessionEnd runs capture-flush with
129
- # --if-stale, and without it every session end would re-flush a run
130
- # that already finished. What stays banned is everything that could
131
- # turn a flag into a second command - no spaces inside a token, no
132
- # quotes, no metacharacters, and the eval/substitution check below
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):