@mmerterden/multi-agent-pipeline 20.4.0 → 20.6.0

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 (41) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +10 -0
  3. package/docs/facts.json +5 -4
  4. package/install/_dev-only-files.mjs +1 -0
  5. package/manifest.json +44 -41
  6. package/package.json +4 -3
  7. package/pipeline/agents/android-architect.md +1 -0
  8. package/pipeline/agents/backend-architect.md +1 -0
  9. package/pipeline/agents/bulk-reader.md +1 -0
  10. package/pipeline/agents/code-reviewer.md +2 -1
  11. package/pipeline/agents/dev-critic.md +1 -0
  12. package/pipeline/agents/explorer.md +1 -0
  13. package/pipeline/agents/ios-architect.md +1 -0
  14. package/pipeline/agents/plan-critic.md +1 -0
  15. package/pipeline/agents/security-auditor.md +1 -0
  16. package/pipeline/agents/task-clarifier.md +1 -0
  17. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/update/SKILL.md +3 -1
  19. package/pipeline/lib/confusables.json +100 -0
  20. package/pipeline/lib/normalize-text.mjs +48 -0
  21. package/pipeline/lib/outbound-gate.mjs +10 -1
  22. package/pipeline/lib/redact.mjs +53 -0
  23. package/pipeline/lib/vercel-deploy.sh +5 -7
  24. package/pipeline/multi-agent-refs/features/unattended-security.md +5 -0
  25. package/pipeline/schemas/autopilot-config.schema.json +5 -0
  26. package/pipeline/scripts/_autopilot-outcomes.mjs +2 -0
  27. package/pipeline/scripts/agent-guard.py +30 -1
  28. package/pipeline/scripts/audit-log-rotate.sh +3 -0
  29. package/pipeline/scripts/audit-log.sh +10 -2
  30. package/pipeline/scripts/autopilot-intake.mjs +17 -15
  31. package/pipeline/scripts/autopilot-publish.mjs +16 -1
  32. package/pipeline/scripts/autopilot-runner.mjs +20 -0
  33. package/pipeline/scripts/log-metric.sh +5 -0
  34. package/pipeline/scripts/pr-request.mjs +9 -3
  35. package/pipeline/scripts/pre-commit-check.sh +59 -16
  36. package/pipeline/scripts/pre-push-check.sh +3 -3
  37. package/pipeline/scripts/run-ui-tests.sh +5 -5
  38. package/pipeline/scripts/update-check.sh +73 -21
  39. package/pipeline/scripts/website-deploy-commit.sh +3 -2
  40. package/pipeline/skills/.skill-manifest.json +2 -2
  41. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +1 -1
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * normalize-text.mjs - fold look-alike text to the form a guard matches on.
4
+ *
5
+ * NFKC, then the invisible characters in confusables.json removed and its
6
+ * look-alike letters and punctuation mapped to ASCII. A guard matches on the
7
+ * raw text and on this folded copy, so a trailer or a token written with a
8
+ * zero-width space or a Cyrillic letter is still recognised. The folded copy is
9
+ * only ever matched against; nothing written or shown uses it.
10
+ *
11
+ * As a CLI it reads stdin and writes the folded text to stdout.
12
+ *
13
+ * @module pipeline/lib/normalize-text
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { invokedDirectly } from "./invoked-directly.mjs";
19
+
20
+ const TABLE = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
21
+ const STRIP = new Set(TABLE.strip);
22
+ const MAP = new Map(Object.entries(TABLE.map));
23
+
24
+ /** @param {string} text */
25
+ export function normalizeForMatch(text) {
26
+ if (typeof text !== "string" || !text) return text;
27
+ let out = "";
28
+ for (const ch of text.normalize("NFKC")) {
29
+ if (STRIP.has(ch)) continue;
30
+ out += MAP.get(ch) ?? ch;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** The text with its invisible characters removed and nothing else changed. */
36
+ export function stripInvisible(text) {
37
+ if (typeof text !== "string" || !text) return text;
38
+ let out = "";
39
+ for (const ch of text) if (!STRIP.has(ch)) out += ch;
40
+ return out;
41
+ }
42
+
43
+ if (invokedDirectly(import.meta.url)) {
44
+ let input = "";
45
+ process.stdin.setEncoding("utf8");
46
+ process.stdin.on("data", (d) => (input += d));
47
+ process.stdin.on("end", () => process.stdout.write(normalizeForMatch(input)));
48
+ }
@@ -49,6 +49,7 @@
49
49
 
50
50
  import { readFileSync } from "node:fs";
51
51
  import { invokedDirectly } from "./invoked-directly.mjs";
52
+ import { normalizeForMatch } from "./normalize-text.mjs";
52
53
 
53
54
  /**
54
55
  * Each rule is a SHAPE, not a dictionary. A list of known token prefixes goes
@@ -155,11 +156,19 @@ export function scan(text) {
155
156
  const findings = [];
156
157
  const lines = String(text).split("\n");
157
158
  for (let i = 0; i < lines.length; i++) {
159
+ // A credential written with an invisible character or a look-alike letter
160
+ // still matches: the certain rules also run on the folded line.
161
+ const folded = normalizeForMatch(lines[i]);
158
162
  for (const rule of RULES) {
159
163
  // `lastIndex` survives between calls on a /g regex and would make every
160
164
  // second line skip its start. Reset per line.
161
165
  rule.re.lastIndex = 0;
162
- if (rule.re.test(lines[i])) {
166
+ let hit = rule.re.test(lines[i]);
167
+ if (!hit && rule.certain && folded !== lines[i]) {
168
+ rule.re.lastIndex = 0;
169
+ hit = rule.re.test(folded);
170
+ }
171
+ if (hit) {
163
172
  findings.push({ rule: rule.name, certain: rule.certain, line: i + 1 });
164
173
  }
165
174
  }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redact.mjs - replace every credential in a text with a visible marker.
4
+ *
5
+ * Uses the certain rules of outbound-gate.mjs, so the log writers, the commit
6
+ * hook and the outbound gate recognise one provider set. A match becomes
7
+ * `[redacted:<rule>]`; the text around it is kept, because a log line is read
8
+ * to debug a failure and the message is what makes it useful.
9
+ *
10
+ * As a CLI it reads stdin and writes the redacted text to stdout. It never
11
+ * fails its caller: on an internal error it writes the input unchanged.
12
+ *
13
+ * @module pipeline/lib/redact
14
+ */
15
+
16
+ import { RULES } from "./outbound-gate.mjs";
17
+ import { invokedDirectly } from "./invoked-directly.mjs";
18
+ import { stripInvisible } from "./normalize-text.mjs";
19
+
20
+ const CERTAIN = RULES.filter((r) => r.certain);
21
+
22
+ /** @param {string} text */
23
+ export function redact(text) {
24
+ if (typeof text !== "string" || !text) return text;
25
+ // Invisible characters are removed first: they change nothing a reader sees,
26
+ // and a token split by one would otherwise slip past every rule.
27
+ let out = stripInvisible(text);
28
+ for (const rule of CERTAIN) {
29
+ const re = new RegExp(
30
+ rule.re.source,
31
+ rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`,
32
+ );
33
+ out = out.replace(re, () =>
34
+ rule.name === "url-with-credentials" ? `[redacted:${rule.name}]@` : `[redacted:${rule.name}]`,
35
+ );
36
+ }
37
+ return out;
38
+ }
39
+
40
+ if (invokedDirectly(import.meta.url)) {
41
+ let input = "";
42
+ process.stdin.setEncoding("utf8");
43
+ process.stdin.on("data", (d) => (input += d));
44
+ process.stdin.on("end", () => {
45
+ let out;
46
+ try {
47
+ out = redact(input);
48
+ } catch {
49
+ out = input;
50
+ }
51
+ process.stdout.write(out);
52
+ });
53
+ }
@@ -2,11 +2,9 @@
2
2
  #
3
3
  # vercel-deploy.sh - safe wrapper around `vercel` CLI.
4
4
  #
5
- # Why this exists (v8.0.0): on 2026-04-27 a `vercel deploy --token=...`
6
- # invocation failed during the v7.9.1 release flow. The CLI's error retry
7
- # hint printed the failed argv verbatim, including `--token=vcp_...`. That
8
- # leaked the deploy token into the conversation transcript and forced a
9
- # token rotation. See `feedback_vercel_cli_token_leak.md`.
5
+ # Why this exists: when a `vercel` call fails, the CLI's retry hint prints the
6
+ # failed argv verbatim. A token passed as `--token=vcp_...` therefore reaches the
7
+ # terminal, any log, and the conversation transcript, and has to be rotated.
10
8
  #
11
9
  # This wrapper guarantees three properties for every Vercel call:
12
10
  # 1. Tokens are passed via VERCEL_TOKEN env var, NEVER via --token=... argv.
@@ -186,8 +184,8 @@ cmd_deploy() {
186
184
  done
187
185
 
188
186
  # Run the CLI with token via env. Pipe stdout AND stderr through the redact
189
- # filter so even if the CLI dumps argv on failure (it does - that was the
190
- # original v7.9.1 leak), tokens never reach the user's screen or transcript.
187
+ # filter so even if the CLI dumps argv on failure (its retry hint does),
188
+ # tokens never reach the user's screen or transcript.
191
189
  set +e
192
190
  ( cd "$cwd" && VERCEL_TOKEN="$VERCEL_TOKEN" vercel "${cmd_args[@]}" 2>&1 ) \
193
191
  | redact_filter
@@ -589,6 +589,11 @@ hashes `~/.claude/{scripts,lib}`, the settings files, the profile and
589
589
  `~/Library/LaunchAgents` before and after publish verification (`hostFingerprint`
590
590
  in `autopilot-publish.mjs`) and refuses to push if any changed.
591
591
 
592
+ It also refuses to launch (`blocked-version-floor`) while the installed version
593
+ is below the published `required` floor (`require-supported-version.sh` exits
594
+ 3): the child's Phase 0 would halt on the same check, and the update it needs is
595
+ refused unattended. Like every `blocked-*` outcome it is not an attempt.
596
+
592
597
  The profile's `env` reaches the session and the commands it runs. The Claude
593
598
  Code docs pass environment to a stdio MCP server through the server entry, so
594
599
  pin the two toolkit variables there as well: `claude mcp add --env
@@ -50,6 +50,11 @@
50
50
  "default": "agent-queue",
51
51
  "description": "Only issues carrying this label are picked up. `gh issue list --label` does the filtering server-side."
52
52
  },
53
+ "githubAssignee": {
54
+ "type": "string",
55
+ "default": "@me",
56
+ "description": "Only issues assigned to this login are picked up (`gh issue list --assignee`), matching the Jira search's assignee = currentUser(). An issue form can apply the queue label for anyone who opens an issue, and an unattended run acts on the issue's text. \"*\" picks up every labelled issue."
57
+ },
53
58
  "jiraJql": {
54
59
  "type": "string",
55
60
  "description": "Full JQL, so an instance that cannot use labels is a config change rather than a redesign. The label form is sugar for: assignee = currentUser() AND labels = \"<label>\" AND resolution = EMPTY AND status not in (Done, Closed, Cancelled) ORDER BY priority DESC, created ASC. Jira labels are a single global namespace anyone can write to, so on a shared instance prefer a qualified name, and note that the Labels field must be on the edit screen for the issue types in play. An issue that more than one repo's search returns is queued once, for the first repo in config order, so repos sharing a Jira instance should each carry a jiraJql that matches only their own issues."
@@ -41,6 +41,7 @@ export const OUTCOME = Object.freeze({
41
41
  BLOCKED_NO_PATH: "blocked-no-path",
42
42
  BLOCKED_GUARD_MISSING: "blocked-guard-missing",
43
43
  BLOCKED_SANDBOX_UNAVAILABLE: "blocked-sandbox-unavailable",
44
+ BLOCKED_VERSION_FLOOR: "blocked-version-floor",
44
45
  });
45
46
 
46
47
  /**
@@ -107,6 +108,7 @@ export const BLOCKED = new Set([
107
108
  OUTCOME.BLOCKED_NO_PATH,
108
109
  OUTCOME.BLOCKED_GUARD_MISSING,
109
110
  OUTCOME.BLOCKED_SANDBOX_UNAVAILABLE,
111
+ OUTCOME.BLOCKED_VERSION_FLOOR,
110
112
  ]);
111
113
 
112
114
  /** Prefix families. `stopped-<status>` is a run that ended mid-way. */
@@ -20,6 +20,7 @@ import re
20
20
  import shlex
21
21
  import signal
22
22
  import sys
23
+ import unicodedata
23
24
 
24
25
  # An internal deadline well under the hook timeout: if the guard cannot decide
25
26
  # in this long, it prints the blocking verdict when unattended (fail-closed) and
@@ -48,6 +49,34 @@ ATTRIBUTION = re.compile(
48
49
  r"|claude code <",
49
50
  re.IGNORECASE,
50
51
  )
52
+
53
+
54
+ def _load_confusables():
55
+ here = os.path.dirname(os.path.abspath(__file__))
56
+ try:
57
+ with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
58
+ table = json.load(fh)
59
+ return set(table.get("strip", [])), dict(table.get("map", {}))
60
+ except (OSError, ValueError):
61
+ return set(), {}
62
+
63
+
64
+ _STRIP, _MAP = _load_confusables()
65
+
66
+
67
+ def normalize_for_match(text: str) -> str:
68
+ """NFKC, invisible characters removed, look-alike letters mapped to ASCII.
69
+
70
+ The same fold lib/normalize-text.mjs applies, from the same table. Rules
71
+ that match text content run on the raw command and on this copy, so a
72
+ trailer written with a zero-width space or a Cyrillic letter still matches.
73
+ Without the table only NFKC applies.
74
+ """
75
+ return "".join(
76
+ _MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
77
+ )
78
+
79
+
51
80
  # Force flags, and ONLY force flags.
52
81
  #
53
82
  # The previous pattern ended in `-\S*f\S*`, which matches any flag containing an
@@ -228,7 +257,7 @@ def decide(cmd: str, depth: int = 0) -> str:
228
257
  commits = re.search(r"git\s+commit", low) or any(
229
258
  git_subcommand(toks) == "commit" for _, toks in segs
230
259
  )
231
- if commits and ATTRIBUTION.search(cmd):
260
+ if commits and (ATTRIBUTION.search(cmd) or ATTRIBUTION.search(normalize_for_match(cmd))):
232
261
  return "BLOCK_ATTRIB"
233
262
 
234
263
  # Rule 2: force-push to a protected branch. Evaluate each push segment on its
@@ -89,6 +89,9 @@ if [ -s "$AUDIT_FILE" ]; then
89
89
  # of the content it archived. gzip -f removes the plain file on success, so
90
90
  # only touch whichever of the two actually exists.
91
91
  [ -f "$ROTATED.gz" ] && touch "$ROTATED.gz"
92
+ # An archive holds the same events as the live file, so it gets the same mode.
93
+ [ -f "$ROTATED.gz" ] && chmod 600 "$ROTATED.gz" 2>/dev/null
94
+ [ -f "$ROTATED" ] && chmod 600 "$ROTATED" 2>/dev/null
92
95
  [ -f "$ROTATED" ] && touch "$ROTATED"
93
96
  fi
94
97
 
@@ -39,9 +39,10 @@ esac
39
39
  AUDIT_FILE="${AUDIT_FILE:-${LOGS_ROOT:-$HOME/.claude/logs/multi-agent}/audit.jsonl}"
40
40
  mkdir -p "$(dirname "$AUDIT_FILE")" 2>/dev/null || exit 0
41
41
 
42
- # Escape a string for embedding inside a JSON double-quoted value.
42
+ # Escape a string for embedding inside a JSON double-quoted value. Newlines,
43
+ # carriage returns and tabs are escaped too: a raw one splits the JSONL line.
43
44
  json_escape() {
44
- printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
45
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g; s/\r/\\r/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }'
45
46
  }
46
47
 
47
48
  # SHA-256 hash the repo URL (never log plaintext per section18.2.2)
@@ -85,6 +86,13 @@ LINE="{\"ts\":\"${TS}\",\"event\":\"$(json_escape "$EVENT")\",\"service\":\"$(js
85
86
  [ -n "$EXTRAS" ] && LINE="${LINE},${EXTRAS}"
86
87
  LINE="${LINE}}"
87
88
 
89
+ # Caller-supplied values can carry a credential copied out of an error.
90
+ # redact.mjs replaces it with a marker; without node the line is written as is.
91
+ REDACT="$(dirname "${BASH_SOURCE[0]:-$0}")/../lib/redact.mjs"
92
+ [ -f "$REDACT" ] && LINE=$(printf '%s' "$LINE" | node "$REDACT" 2>/dev/null || printf '%s' "$LINE")
93
+
94
+ [ -f "$AUDIT_FILE" ] || (umask 077 && : > "$AUDIT_FILE") 2>/dev/null || true
95
+ chmod 600 "$AUDIT_FILE" 2>/dev/null || true
88
96
  echo "$LINE" >> "$AUDIT_FILE" 2>/dev/null || true
89
97
 
90
98
  # Opportunistic rotation. audit-log-rotate.sh documents a launchd/cron install,
@@ -245,22 +245,24 @@ function sh(cmd, args, opts = {}) {
245
245
  }
246
246
  }
247
247
 
248
- function fetchGitHub(repo) {
248
+ /**
249
+ * The `gh issue list` arguments for one repo. Only issues assigned to the
250
+ * operator are listed by default, as the Jira search does with
251
+ * `assignee = currentUser()`: an issue form can apply the queue label for
252
+ * anyone who opens an issue, and an unattended run acts on the issue's text.
253
+ * `githubAssignee: "*"` lists every labelled issue.
254
+ */
255
+ export function githubListArgs(repo) {
249
256
  const label = repo.githubLabel || "agent-queue";
250
- const out = sh("gh", [
251
- "issue",
252
- "list",
253
- "--repo",
254
- repo.nameWithOwner,
255
- "--label",
256
- label,
257
- "--state",
258
- "open",
259
- "--json",
260
- "number,title,labels,url,createdAt",
261
- "--limit",
262
- "100",
263
- ]);
257
+ const assignee = repo.githubAssignee || "@me";
258
+ const args = ["issue", "list", "--repo", repo.nameWithOwner, "--label", label];
259
+ if (assignee !== "*") args.push("--assignee", assignee);
260
+ args.push("--state", "open", "--json", "number,title,labels,url,createdAt", "--limit", "100");
261
+ return args;
262
+ }
263
+
264
+ function fetchGitHub(repo) {
265
+ const out = sh("gh", githubListArgs(repo));
264
266
  let rows;
265
267
  try {
266
268
  rows = JSON.parse(out || "[]");
@@ -112,7 +112,7 @@ import { OUTCOME } from "./_autopilot-outcomes.mjs";
112
112
  import { commandFor, getAdapter, loadAdapters, resolveStack } from "./_stack-adapter.mjs";
113
113
  import { checkCommit, park, updateState } from "./gate-ledger.mjs";
114
114
  import { LOCKFILES } from "./package-manager.mjs";
115
- import { PROTECTED_BRANCHES, validatePrRequest } from "./pr-request.mjs";
115
+ import { PROTECTED_BRANCHES, hasAutoClose, validatePrRequest } from "./pr-request.mjs";
116
116
  import { summarize } from "./test-summary.mjs";
117
117
 
118
118
  const HERE = import.meta.dirname;
@@ -576,6 +576,20 @@ function checkRemoteBranch(stage, url, branch, headSha, prior) {
576
576
  }
577
577
 
578
578
  /** Gate ledger: the commit hook's requirement, re-checked for every commit in the range. */
579
+ // A closing keyword in a commit message closes the issue when the PR merges,
580
+ // whatever the PR text says, and issues are closed by people, not by a run.
581
+ function checkCommitMessages(stage, commits) {
582
+ for (const sha of commits) {
583
+ const message = stage.out(["log", "-1", "--format=%B", sha]) || "";
584
+ if (hasAutoClose(message)) {
585
+ throw new Refusal(
586
+ "auto-close",
587
+ `commit ${sha.slice(0, 12)} carries an auto-close keyword; use Ref: #N`,
588
+ );
589
+ }
590
+ }
591
+ }
592
+
579
593
  function checkLedger(state, commits, wt, env) {
580
594
  for (const c of commits) {
581
595
  const parent = gitOut(wt, ["rev-parse", "--verify", `${c}^1`], env);
@@ -1319,6 +1333,7 @@ export async function publish({ sessionId, statePath, item, config, env = proces
1319
1333
  const mergeBase = stage.out(["merge-base", baseSha, headSha]);
1320
1334
  if (!mergeBase) throw new Refusal("branch", `${req.branch} shares no history with ${req.base}`);
1321
1335
 
1336
+ checkCommitMessages(stage, commits);
1322
1337
  checkLedger(state, commits, wt, localEnv);
1323
1338
  checkTestLog(state, wt, statePath);
1324
1339
  checkSecrets(wt, `${mergeBase}..${headSha}`, env);
@@ -2086,6 +2086,26 @@ async function main() {
2086
2086
  return 0;
2087
2087
  }
2088
2088
 
2089
+ // ---- 3d. VERSION --------------------------------------------------------
2090
+ // Phase 0 of the child halts below the published floor, and the update it
2091
+ // would need is refused unattended. Checked here so a floor costs no session.
2092
+ const floorGate = join(SCRIPTS, "require-supported-version.sh");
2093
+ if (existsSync(floorGate)) {
2094
+ const floor = spawnSync("bash", [floorGate], { encoding: "utf-8", timeout: 30_000 });
2095
+ if (floor.status === 3) {
2096
+ const [, local, , required] = (floor.stdout || "").trim().split("|");
2097
+ const reason = `installed v${local || "?"} is below the required v${required || "?"}; run the update`;
2098
+ log(`not launching: ${reason}`);
2099
+ if (!DRY) {
2100
+ record({ source: next.source, id: next.id, outcome: OUTCOME.BLOCKED_VERSION_FLOOR });
2101
+ writeState("queue.json", { ...queue, blockedReason: reason });
2102
+ refreshStatus();
2103
+ }
2104
+ tick({ action: "version-floor" });
2105
+ return 0;
2106
+ }
2107
+ }
2108
+
2089
2109
  // ---- 4. TAKE -----------------------------------------------------------
2090
2110
  const sessionId = randomUUID();
2091
2111
  const taskId = `ap-${Date.now()}`;
@@ -81,6 +81,11 @@ PHASE_SCHEMA="$(node -e 'const c=require("'"$(cd "$(dirname "$0")" && pwd)"'/../
81
81
  TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
82
82
  LINE="{\"ts\":\"${TS}\",\"task_id\":\"${TASK_ID}\",\"phase\":\"${PHASE}\",\"event\":\"${EVENT}\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
83
83
 
84
+ # Caller-supplied values can carry a credential copied out of an error.
85
+ # redact.mjs replaces it with a marker; without node the line is written as is.
86
+ REDACT="$(cd "$(dirname "$0")" && pwd)/../lib/redact.mjs"
87
+ [ -f "$REDACT" ] && LINE=$(printf '%s' "$LINE" | node "$REDACT" 2>/dev/null || printf '%s' "$LINE")
88
+
84
89
  # Atomic append (one line, well under PIPE_BUF).
85
90
  echo "$LINE" >> "$METRICS_FILE" 2>/dev/null || {
86
91
  echo "warn: log-metric: cannot write to $METRICS_FILE - telemetry skipped" >&2
@@ -46,7 +46,14 @@ export const FIELDS = ["branch", "base", "title", "body", "draft", "repo"];
46
46
  export const PROTECTED_BRANCHES = ["main", "master", "develop"];
47
47
  const REF = /^[A-Za-z0-9._/-]+$/;
48
48
  const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
49
- const AUTO_CLOSE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:[\w.-]+\/[\w.-]+)?#\d+/i;
49
+ // GitHub closes an issue from `#N`, `owner/repo#N` and the issue's full URL.
50
+ const AUTO_CLOSE =
51
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+(?:(?:[\w.-]+\/[\w.-]+)?#\d+|https?:\/\/[^\s/]+\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i;
52
+
53
+ /** True when the text would close an issue once the PR or commit merges. */
54
+ export function hasAutoClose(text) {
55
+ return typeof text === "string" && AUTO_CLOSE.test(text);
56
+ }
50
57
 
51
58
  /**
52
59
  * The request object `write` produces. draft is always true.
@@ -92,8 +99,7 @@ export function validatePrRequest(req) {
92
99
  if (typeof req.branch === "string" && req.branch === req.base)
93
100
  errors.push("branch and base are the same");
94
101
  for (const k of ["title", "body"]) {
95
- if (typeof req[k] === "string" && AUTO_CLOSE.test(req[k]))
96
- errors.push(`${k} carries an auto-close keyword; use Ref: #N`);
102
+ if (hasAutoClose(req[k])) errors.push(`${k} carries an auto-close keyword; use Ref: #N`);
97
103
  }
98
104
  const secrets = [];
99
105
  for (const k of ["title", "body"]) {
@@ -174,6 +174,19 @@ scan_file() {
174
174
 
175
175
  [ -z "$content" ] && return 0
176
176
 
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 \
181
+ && [ -f "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" ]; then
182
+ local folded
183
+ folded=$(printf '%s' "$content" | node "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" 2>/dev/null) || folded=""
184
+ if [ -n "$folded" ] && [ "$folded" != "$content" ]; then
185
+ content="$content
186
+ $folded"
187
+ fi
188
+ fi
189
+
177
190
  # API keys / tokens (key = "long_string" pattern)
178
191
  if echo "$content" | grep -qiE '(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[:=]\s*["'"'"'][A-Za-z0-9+/=_-]{20,}'; then
179
192
  echo "BLOCKED: Possible API key/token in $file" >&2
@@ -261,28 +274,58 @@ scan_file() {
261
274
  # repo-root-relative, so the full path can be anchored.
262
275
  if ! echo "$file" | grep -qiE '(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Podfile\.lock|Cartfile\.resolved|Package\.resolved|\.lock$|\.map$|__snapshots__|\.snap$|\.min\.(js|css)$)' \
263
276
  && ! echo "$file" | grep -qE '^pipeline/scripts/smoke-pre-commit\.sh$'; then
264
- candidates=$(echo "$content" | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
277
+ # Staged and working-tree content arrives as diff lines, so the leading "+"
278
+ # is stripped first: it is in the candidate class and would otherwise glue
279
+ # onto a token that starts at column 0 and defeat every shape check below.
280
+ candidates=$(echo "$content" | sed 's/^+//' | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
265
281
  if [ -n "$candidates" ]; then
266
282
  entropy_hit=$(printf '%s\n' "$candidates" | awk '
283
+ # Word-shaped identifier: letters and _ or - only, split into segments at
284
+ # separators and case boundaries (UIApplication -> UI, Application), where
285
+ # the segments read like words: average length 4+, none longer than 24,
286
+ # and at least 70 percent carrying a vowel. Random letters-only tokens
287
+ # flip case every one or two characters, so their segments average about
288
+ # two characters with few vowels; a long lowercase run is one oversized
289
+ # segment. Neither shape passes.
290
+ function wordlike(t, n, i, c, p, nx, seg, nseg, total, maxlen, voweled, split_here) {
291
+ if (t !~ /^[A-Za-z_-]+$/) return 0;
292
+ n = length(t); seg = ""; nseg = 0; total = 0; maxlen = 0; voweled = 0;
293
+ for (i = 1; i <= n + 1; i++) {
294
+ c = (i <= n) ? substr(t, i, 1) : "_";
295
+ split_here = 0;
296
+ if (c == "_" || c == "-") split_here = 1;
297
+ else if (seg != "" && c ~ /[A-Z]/) {
298
+ p = substr(seg, length(seg), 1);
299
+ nx = (i < n) ? substr(t, i + 1, 1) : "";
300
+ if (p ~ /[a-z]/ || (nx ~ /[a-z]/ && length(seg) > 1)) split_here = 1;
301
+ }
302
+ if (split_here && seg != "") {
303
+ nseg++; total += length(seg);
304
+ if (length(seg) > maxlen) maxlen = length(seg);
305
+ if (tolower(seg) ~ /[aeiouy]/) voweled++;
306
+ seg = "";
307
+ }
308
+ if (c != "_" && c != "-") seg = seg c;
309
+ }
310
+ if (nseg == 0) return 0;
311
+ return (total / nseg >= 4 && maxlen <= 24 && voweled * 10 >= nseg * 7);
312
+ }
267
313
  {
268
314
  s=$0;
269
315
  # Scoring the joined string measures the variety of a whole SENTENCE
270
316
  # rather than of any token in it, so an ordinary doc path like
271
- # "claude/commands/multi-agent/review-jira/SKILL" scores 4.35 and was
272
- # blocked as a possible credential. 47 of the 400 committed markdown
273
- # files in this repo carried such a path, so each was un-editable - and
274
- # the gate only fired once such a file was staged, which is why it
275
- # stayed invisible.
317
+ # "claude/commands/multi-agent/review-jira/SKILL" scores 4.35 and would
318
+ # be blocked as a possible credential, making every doc that cites
319
+ # such a path un-editable.
276
320
  #
277
- # The reduction is applied ONLY to path-shaped candidates. An earlier
278
- # version of this fix scored the longest slash-free segment for every
279
- # candidate containing a slash, which structurally exempted any secret
280
- # with a slash in it: the canonical 40-char AWS documentation-example
281
- # secret access key (two slashes, full-match H=4.663, blocked before)
282
- # reduced to an 18-char segment and sailed through, and roughly half
283
- # of `openssl rand -base64 32` outputs contain a "/". It is not
284
- # reproduced here on purpose - this gate flags credential-shaped
285
- # strings in comments too, and it is right to.
321
+ # The reduction is applied ONLY to path-shaped candidates. Scoring the
322
+ # longest slash-free segment for every candidate containing a slash
323
+ # would structurally exempt any secret with a slash in it: the
324
+ # canonical 40-char AWS documentation-example secret access key (two
325
+ # slashes, full-match H=4.663) reduces to an 18-char segment, and
326
+ # roughly half of `openssl rand -base64 32` outputs contain a "/". The
327
+ # key is not reproduced here on purpose - this gate flags
328
+ # credential-shaped strings in comments too, and it is right to.
286
329
  # No other check here catches that key: the key-name regex above does
287
330
  # not match "secret_access_key", and the AKIA pattern only covers the
288
331
  # key ID, not the secret.
@@ -299,7 +342,7 @@ scan_file() {
299
342
  if (s ~ /^[0-9]+$/) next; # pure digits
300
343
  if (s ~ /^[0-9a-f]+$/) next; # lowercase hex (git sha / md5 / sha-*)
301
344
  if (s ~ /^[0-9A-F]+$/) next; # uppercase hex
302
- if (s ~ /^([A-Z][a-z]+)+[A-Z]*$/) next; # CamelCase words only (label key / type name), never a credential
345
+ if (wordlike(s)) next; # identifier made of words (plist key / type name), never a credential
303
346
  delete freq;
304
347
  for (i = 1; i <= n; i++) { c = substr(s, i, 1); freq[c]++ }
305
348
  H = 0;
@@ -21,9 +21,9 @@
21
21
  #
22
22
  # Two modes, because a gate that runs INSIDE the push is a gate that breaks
23
23
  # pushes. Git opens the connection to the remote before this hook fires, so a
24
- # six-minute run idles that connection until the server drops it: the first two
25
- # attempts to push the v15.12.0 rename died on a broken pipe with every gate
26
- # green. The work has to happen outside the network window.
24
+ # six-minute run idles that connection until the server drops it, and the push
25
+ # dies on a broken pipe with every gate green. The work has to happen outside
26
+ # the network window.
27
27
  #
28
28
  # pre-push-check.sh --run run every gate and stamp the tree as green
29
29
  # pre-push-check.sh verify the stamp only; refuse the push if absent
@@ -277,11 +277,11 @@ EOF
277
277
  fi
278
278
  }
279
279
 
280
- # Web had no arm here at all until v17.1.0, which meant a repo with a full
281
- # Playwright suite reported "no UI test target" and every downstream consumer
282
- # believed it: the evidence probe closed tier 1, the PR body said UI tests were
283
- # not run, and the reason it gave was true of the runner rather than of the repo.
284
- # That is the failure mode this file exists to avoid, and it was shipped for web.
280
+ # Without a web arm, a repo with a full Playwright suite reports "no UI test
281
+ # target" and every downstream consumer believes it: the evidence probe closes
282
+ # tier 1, the PR body says UI tests were not run, and the reason it gives is
283
+ # true of the runner rather than of the repo. That is the failure mode this file
284
+ # exists to avoid.
285
285
  web_spec_files() {
286
286
  find "$REPO" \
287
287
  -name ".*" -type d -prune -o \