@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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +10 -0
- package/docs/facts.json +5 -4
- package/install/_dev-only-files.mjs +1 -0
- package/manifest.json +44 -41
- package/package.json +4 -3
- package/pipeline/agents/android-architect.md +1 -0
- package/pipeline/agents/backend-architect.md +1 -0
- package/pipeline/agents/bulk-reader.md +1 -0
- package/pipeline/agents/code-reviewer.md +2 -1
- package/pipeline/agents/dev-critic.md +1 -0
- package/pipeline/agents/explorer.md +1 -0
- package/pipeline/agents/ios-architect.md +1 -0
- package/pipeline/agents/plan-critic.md +1 -0
- package/pipeline/agents/security-auditor.md +1 -0
- package/pipeline/agents/task-clarifier.md +1 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +3 -1
- package/pipeline/lib/confusables.json +100 -0
- package/pipeline/lib/normalize-text.mjs +48 -0
- package/pipeline/lib/outbound-gate.mjs +10 -1
- package/pipeline/lib/redact.mjs +53 -0
- package/pipeline/lib/vercel-deploy.sh +5 -7
- package/pipeline/multi-agent-refs/features/unattended-security.md +5 -0
- package/pipeline/schemas/autopilot-config.schema.json +5 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +2 -0
- package/pipeline/scripts/agent-guard.py +30 -1
- package/pipeline/scripts/audit-log-rotate.sh +3 -0
- package/pipeline/scripts/audit-log.sh +10 -2
- package/pipeline/scripts/autopilot-intake.mjs +17 -15
- package/pipeline/scripts/autopilot-publish.mjs +16 -1
- package/pipeline/scripts/autopilot-runner.mjs +20 -0
- package/pipeline/scripts/log-metric.sh +5 -0
- package/pipeline/scripts/pr-request.mjs +9 -3
- package/pipeline/scripts/pre-commit-check.sh +59 -16
- package/pipeline/scripts/pre-push-check.sh +3 -3
- package/pipeline/scripts/run-ui-tests.sh +5 -5
- package/pipeline/scripts/update-check.sh +73 -21
- package/pipeline/scripts/website-deploy-commit.sh +3 -2
- package/pipeline/skills/.skill-manifest.json +2 -2
- 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
|
-
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 (
|
|
190
|
-
#
|
|
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
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
272
|
-
# blocked as a possible credential
|
|
273
|
-
#
|
|
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.
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
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
|
|
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
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
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 \
|