@mmerterden/multi-agent-pipeline 20.28.0 → 20.29.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 +13 -0
- package/README.md +14 -1
- package/docs/facts.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +53 -42
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +37 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +1 -1
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
"""Decision core for agent-guard.sh (see that file for the hook contract).
|
|
3
3
|
|
|
4
4
|
Reads the PreToolUse payload JSON on stdin and prints exactly one decision:
|
|
5
|
-
BLOCK_ATTRIB | BLOCK_FORCE |
|
|
5
|
+
BLOCK_ATTRIB | BLOCK_FORCE | BLOCK_NOVERIFY<TAB>reason | BLOCK_READONLY<TAB>reason |
|
|
6
|
+
BLOCK_UNATTENDED<TAB>reason | BLOCK_PARSE | ASK<TAB>hook-json |
|
|
7
|
+
DENY<TAB>reason | OK
|
|
6
8
|
|
|
7
9
|
Never executes the command. shlex.split only tokenizes shell-like text; a
|
|
8
10
|
`$(...)` in the command is a literal token, never a subshell. The processes it
|
|
9
11
|
does start are read-only `git rev-parse` / `git diff --name-only` calls on a
|
|
10
12
|
path, with argv and no shell.
|
|
11
13
|
|
|
12
|
-
Attended (MULTI_AGENT_UNATTENDED unset): the attribution and
|
|
13
|
-
|
|
14
|
+
Attended (MULTI_AGENT_UNATTENDED unset): the attribution (argv and -F
|
|
15
|
+
message files), force-push and hook-skipping rules, plus the confirmations in
|
|
16
|
+
_guard_rules.ask_reason, and any error prints OK (fail-open) so a guard bug cannot break a
|
|
14
17
|
legitimate tool call. Unattended: unattended_policy.py is consulted as well,
|
|
15
18
|
and an error prints BLOCK_PARSE (fail-closed).
|
|
16
19
|
"""
|
|
@@ -22,6 +25,9 @@ import signal
|
|
|
22
25
|
import sys
|
|
23
26
|
import unicodedata
|
|
24
27
|
|
|
28
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
import _guard_rules # noqa: E402
|
|
30
|
+
|
|
25
31
|
# An internal deadline well under the hook timeout: if the guard cannot decide
|
|
26
32
|
# in this long, it prints the blocking verdict when unattended (fail-closed) and
|
|
27
33
|
# the allow verdict when attended (a guard bug must not break legitimate work).
|
|
@@ -43,6 +49,7 @@ PROTECTED = {"main", "master", "develop"}
|
|
|
43
49
|
REMOTES = {"origin", "upstream"}
|
|
44
50
|
ATTRIBUTION = re.compile(
|
|
45
51
|
r"co-authored-by:\s*(claude|anthropic|opus|sonnet|haiku|fable)"
|
|
52
|
+
r"|co-authored-by:[^\n]*\bcopilot\b"
|
|
46
53
|
r"|noreply@anthropic\.com"
|
|
47
54
|
r"|generated with\s+.*claude"
|
|
48
55
|
r"|\U0001F916\s*generated"
|
|
@@ -276,16 +283,62 @@ def _nested_payload(toks: list):
|
|
|
276
283
|
return None
|
|
277
284
|
|
|
278
285
|
|
|
286
|
+
def _attributed(text: str) -> bool:
|
|
287
|
+
return bool(ATTRIBUTION.search(text) or ATTRIBUTION.search(normalize_for_match(text)))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# The directory the hook payload names as the session's cwd; message files a
|
|
291
|
+
# command names relative to it are read from there.
|
|
292
|
+
PAYLOAD_CWD = {"dir": None}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _command_rules(cmd: str) -> str:
|
|
296
|
+
"""Rules 1b, 3, 4 and 5, judged on the line's simple commands with heredoc
|
|
297
|
+
bodies removed and only where the program is the command word
|
|
298
|
+
(_guard_rules.command_segments / command_word). A `cd` moves later
|
|
299
|
+
segments into another directory."""
|
|
300
|
+
pending_ask = None
|
|
301
|
+
cwd = None
|
|
302
|
+
for seg, toks in _guard_rules.command_segments(cmd):
|
|
303
|
+
if toks and toks[0] in ("{", "}"):
|
|
304
|
+
toks = toks[1:]
|
|
305
|
+
if toks and toks[0] in ("cd", "pushd"):
|
|
306
|
+
cwd = _cd_target(toks, cwd)
|
|
307
|
+
continue
|
|
308
|
+
here = cwd or PAYLOAD_CWD["dir"]
|
|
309
|
+
# Rule 1b: the commit message given as a file (-F / --file).
|
|
310
|
+
for path in _guard_rules.message_files(toks):
|
|
311
|
+
if _attributed(_guard_rules.read_bounded(path, here)):
|
|
312
|
+
return "BLOCK_ATTRIB"
|
|
313
|
+
# Rule 5: history writes in a repository listed as read-only.
|
|
314
|
+
reason = _guard_rules.read_only_git_reason(toks, here)
|
|
315
|
+
if reason:
|
|
316
|
+
return "BLOCK_READONLY\t" + reason
|
|
317
|
+
# Rule 3: skipping the repository's git hooks.
|
|
318
|
+
reason = _guard_rules.no_verify(toks)
|
|
319
|
+
if reason:
|
|
320
|
+
return "BLOCK_NOVERIFY\t" + reason
|
|
321
|
+
# Rule 4: writes a person confirms (issue closure, Jira transition).
|
|
322
|
+
ask = _guard_rules.ask_reason(toks, seg, here)
|
|
323
|
+
if ask and not pending_ask:
|
|
324
|
+
pending_ask = "ASK\t" + ask
|
|
325
|
+
return pending_ask or "OK"
|
|
326
|
+
|
|
327
|
+
|
|
279
328
|
def decide(cmd: str, depth: int = 0) -> str:
|
|
280
329
|
low = cmd.lower()
|
|
281
330
|
segs = segments(flatten(cmd))
|
|
282
331
|
|
|
283
|
-
# Rule 1: AI attribution inside a git commit
|
|
332
|
+
# Rule 1: AI attribution inside a git commit, read from what the commit
|
|
333
|
+
# records (_guard_rules.commit_text), never from a neighbouring command.
|
|
284
334
|
commits = re.search(r"git\s+commit", low) or any(
|
|
285
335
|
git_subcommand(toks) == "commit" for _, toks in segs
|
|
286
336
|
)
|
|
287
|
-
if commits and (
|
|
337
|
+
if commits and _attributed(_guard_rules.commit_text(cmd, PAYLOAD_CWD["dir"])):
|
|
288
338
|
return "BLOCK_ATTRIB"
|
|
339
|
+
if commits and _guard_rules.ai_generated(cmd, PAYLOAD_CWD["dir"]):
|
|
340
|
+
return "BLOCK_ATTRIB"
|
|
341
|
+
pending_ask = None
|
|
289
342
|
|
|
290
343
|
# Rule 2: force-push to a protected branch. Evaluate each push segment on its
|
|
291
344
|
# own so a neighbouring command's flags cannot implicate it. A `cd` earlier
|
|
@@ -301,7 +354,9 @@ def decide(cmd: str, depth: int = 0) -> str:
|
|
|
301
354
|
payload = _nested_payload(toks)
|
|
302
355
|
if payload and depth < MAX_DEPTH:
|
|
303
356
|
verdict = decide(payload, depth + 1)
|
|
304
|
-
if verdict
|
|
357
|
+
if verdict.startswith("ASK"):
|
|
358
|
+
pending_ask = pending_ask or verdict
|
|
359
|
+
elif verdict != "OK":
|
|
305
360
|
return verdict
|
|
306
361
|
continue
|
|
307
362
|
if git_subcommand(toks) != "push" and not re.search(r"git\s+push", seg.lower()):
|
|
@@ -310,7 +365,12 @@ def decide(cmd: str, depth: int = 0) -> str:
|
|
|
310
365
|
if verdict != "OK":
|
|
311
366
|
return verdict
|
|
312
367
|
|
|
313
|
-
|
|
368
|
+
verdict = _command_rules(cmd)
|
|
369
|
+
if verdict.startswith("ASK"):
|
|
370
|
+
return pending_ask or verdict
|
|
371
|
+
if verdict != "OK":
|
|
372
|
+
return verdict
|
|
373
|
+
return pending_ask or "OK"
|
|
314
374
|
|
|
315
375
|
|
|
316
376
|
def _cd_target(toks: list, cwd):
|
|
@@ -530,6 +590,46 @@ def fallback_verdict(text: str) -> str:
|
|
|
530
590
|
return "OK"
|
|
531
591
|
|
|
532
592
|
|
|
593
|
+
FILE_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# Permission modes in which a hook's "ask" has no documented prompt to raise.
|
|
597
|
+
# The hooks reference documents "ask" as forcing a prompt (and states it for
|
|
598
|
+
# auto mode); it says nothing for bypassPermissions or dontAsk, where the
|
|
599
|
+
# permission prompt is switched off. There the guard denies with the reason
|
|
600
|
+
# and names the channel the user's answer travels through: the model asks,
|
|
601
|
+
# confirm-command.mjs records a yes for that exact command (sha256, ten
|
|
602
|
+
# minutes, one use), and the rerun finds it. Attended only: unattended, the
|
|
603
|
+
# record is never read.
|
|
604
|
+
NO_PROMPT_MODES = {"bypassPermissions", "dontAsk"}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def ask_verdict(reason: str, data: dict, strict: bool) -> str:
|
|
608
|
+
"""ASK<TAB>json for a prompt, DENY<TAB>reason where no prompt can be
|
|
609
|
+
raised, BLOCK_UNATTENDED<TAB>reason when nobody is there to answer."""
|
|
610
|
+
if strict:
|
|
611
|
+
return "BLOCK_UNATTENDED\t" + reason
|
|
612
|
+
if data.get("permission_mode") in NO_PROMPT_MODES:
|
|
613
|
+
# With prompts switched off the native ask cannot be raised, so the
|
|
614
|
+
# recorded confirmation stands in for it. In every other mode the
|
|
615
|
+
# prompt is the confirmation and a record must not skip it.
|
|
616
|
+
if _guard_rules.confirmed((data.get("tool_input") or {}).get("command", "")):
|
|
617
|
+
return "OK"
|
|
618
|
+
return (
|
|
619
|
+
"DENY\t" + reason + " (permission prompts are off in this mode: ask the user with AskUserQuestion; on yes, "
|
|
620
|
+
"record it with node \"$HOME/.claude/scripts/confirm-command.mjs\" --command '<the exact command>' "
|
|
621
|
+
"and run the command again, unchanged, within 10 minutes)"
|
|
622
|
+
)
|
|
623
|
+
out = {
|
|
624
|
+
"hookSpecificOutput": {
|
|
625
|
+
"hookEventName": "PreToolUse",
|
|
626
|
+
"permissionDecision": "ask",
|
|
627
|
+
"permissionDecisionReason": "agent-guard: " + reason,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return "ASK\t" + json.dumps(out)
|
|
631
|
+
|
|
632
|
+
|
|
533
633
|
def unattended() -> bool:
|
|
534
634
|
"""MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
|
|
535
635
|
never anything the run wrote (lib/unattended.sh ma_unattended)."""
|
|
@@ -687,9 +787,20 @@ def main() -> None:
|
|
|
687
787
|
try:
|
|
688
788
|
data = json.loads(seen["text"])
|
|
689
789
|
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
790
|
+
if isinstance(data.get("cwd"), str) and data.get("cwd"):
|
|
791
|
+
PAYLOAD_CWD["dir"] = data["cwd"]
|
|
690
792
|
if isinstance(cmd, str):
|
|
691
793
|
seen["text"], seen["raw"] = cmd, False
|
|
692
794
|
verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
|
|
795
|
+
if verdict == "OK" and data.get("tool_name") in FILE_TOOLS:
|
|
796
|
+
ti = data.get("tool_input") or {}
|
|
797
|
+
reason = _guard_rules.read_only_file_reason(
|
|
798
|
+
ti.get("file_path") or ti.get("notebook_path") or "", PAYLOAD_CWD["dir"]
|
|
799
|
+
)
|
|
800
|
+
if reason:
|
|
801
|
+
verdict = "BLOCK_READONLY\t" + reason
|
|
802
|
+
if verdict.startswith("ASK"):
|
|
803
|
+
verdict = ask_verdict(verdict.split("\t", 1)[1], data, strict)
|
|
693
804
|
if verdict == "OK" and strict:
|
|
694
805
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
695
806
|
import unattended_policy
|
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
# agent-guard.sh - PreToolUse guard for the multi-agent pipeline.
|
|
3
3
|
#
|
|
4
4
|
# Turns prompt-level rules into deterministic, OS-enforced gates:
|
|
5
|
-
# 1. No AI/assistant attribution in git commit messages (Co-Authored-By: Claude
|
|
6
|
-
# "Generated with Claude Code", robot emoji,
|
|
5
|
+
# 1. No AI/assistant attribution in git commit messages (Co-Authored-By: Claude
|
|
6
|
+
# or Copilot, "Generated with Claude Code", "Generated by AI", robot emoji,
|
|
7
|
+
# Anthropic no-reply address), on the command line or in a `-F` file.
|
|
7
8
|
# 2. No force-push to a protected branch (main / master / develop), whichever
|
|
8
9
|
# remote, push options or `git -C` directory the push names.
|
|
10
|
+
# 2b. No skipping the repository's git hooks (commit / push / merge with
|
|
11
|
+
# --no-verify, commit -n, -c core.hooksPath=...).
|
|
12
|
+
# 2c. No literal credential on a Bash command line (argv-secret-scan.mjs).
|
|
13
|
+
# 2e. No Edit / Write, git commit or git push inside a repository listed in
|
|
14
|
+
# prefs.global.readOnlyRepos (a reference checkout).
|
|
15
|
+
# 2d. A person confirms (permissionDecision "ask") closing a GitHub issue, a
|
|
16
|
+
# PR body with Closes/Fixes/Resolves #N and a Jira transition POST. Where
|
|
17
|
+
# the permission mode raises no prompt (bypassPermissions, dontAsk) the
|
|
18
|
+
# call is denied with the reason instead; unattended it is blocked.
|
|
9
19
|
# 3. With MULTI_AGENT_UNATTENDED=1 in the hook's environment only: no outward
|
|
10
20
|
# writes (push, PR, issue, tracker publishers, mutating API calls), no
|
|
11
21
|
# Keychain reads, no fetch outside the network allowlist, no package
|
|
@@ -15,7 +25,7 @@
|
|
|
15
25
|
# Contract (Claude Code PreToolUse hook, matcher Bash and Edit|Write|NotebookEdit):
|
|
16
26
|
# - Reads the tool-call JSON on stdin: {"tool_name":..., "tool_input":{...}, "cwd":...}.
|
|
17
27
|
# - Exit 2 -> BLOCK the tool call (reason on stderr, shown to the model).
|
|
18
|
-
# - Exit 0 -> allow.
|
|
28
|
+
# - Exit 0 -> allow, or, with hook JSON on stdout, ask the user to confirm.
|
|
19
29
|
#
|
|
20
30
|
# Safety design:
|
|
21
31
|
# - The command string is ONLY parsed/pattern-matched, NEVER executed or eval'd
|
|
@@ -52,6 +62,27 @@ CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
|
52
62
|
|
|
53
63
|
DECISION="$(printf '%s' "$PAYLOAD" | CUR_BRANCH="$CUR_BRANCH" python3 "$HELPER" 2>/dev/null || true)"
|
|
54
64
|
|
|
65
|
+
# A literal credential on a Bash command line is refused before anything else
|
|
66
|
+
# is said about the call (argv-secret-scan.mjs, the outbound gate's credential
|
|
67
|
+
# shapes). Only rule names are printed, never the value.
|
|
68
|
+
case "$DECISION" in
|
|
69
|
+
OK|ASK*|DENY*)
|
|
70
|
+
case "$PAYLOAD" in
|
|
71
|
+
*'"tool_name":"Bash"'*|*'"tool_name": "Bash"'*)
|
|
72
|
+
if command -v node >/dev/null 2>&1 && [ -f "$HERE/argv-secret-scan.mjs" ]; then
|
|
73
|
+
RULES="$(printf '%s' "$PAYLOAD" | node "$HERE/argv-secret-scan.mjs" 2>/dev/null)"
|
|
74
|
+
if [ "$?" = "7" ]; then
|
|
75
|
+
echo "BLOCKED by agent-guard: the command line carries a literal credential ($RULES)." >&2
|
|
76
|
+
echo "Read it from the credential store or an environment variable instead; a value typed" >&2
|
|
77
|
+
echo "into argv lands in the transcript, the process table and every log that echoes it." >&2
|
|
78
|
+
exit 2
|
|
79
|
+
fi
|
|
80
|
+
elif [ "$STRICT" = "1" ]; then
|
|
81
|
+
refuse_unjudged "node or argv-secret-scan.mjs is unavailable for the credential scan"
|
|
82
|
+
fi ;;
|
|
83
|
+
esac ;;
|
|
84
|
+
esac
|
|
85
|
+
|
|
55
86
|
case "$DECISION" in
|
|
56
87
|
BLOCK_ATTRIB)
|
|
57
88
|
echo "BLOCKED by agent-guard: the commit message carries AI/assistant attribution." >&2
|
|
@@ -62,6 +93,22 @@ case "$DECISION" in
|
|
|
62
93
|
echo "BLOCKED by agent-guard: force-push to a protected branch (main/master/develop) is not allowed." >&2
|
|
63
94
|
echo "Rewriting shared history is a data-loss risk. Push a normal commit, or force-push a feature branch." >&2
|
|
64
95
|
exit 2 ;;
|
|
96
|
+
BLOCK_READONLY*)
|
|
97
|
+
echo "BLOCKED by agent-guard: ${DECISION#BLOCK_READONLY?}." >&2
|
|
98
|
+
echo "Read it, cite it and port the change to the repository you are working in." >&2
|
|
99
|
+
exit 2 ;;
|
|
100
|
+
BLOCK_NOVERIFY*)
|
|
101
|
+
echo "BLOCKED by agent-guard: ${DECISION#BLOCK_NOVERIFY?}." >&2
|
|
102
|
+
echo "The repository's own hooks (lint, tests, secret checks) are part of the commit contract." >&2
|
|
103
|
+
echo "Fix what they report and commit without skipping them." >&2
|
|
104
|
+
exit 2 ;;
|
|
105
|
+
ASK*)
|
|
106
|
+
# The hook JSON the host reads: permissionDecision "ask" forces a prompt.
|
|
107
|
+
printf '%s\n' "${DECISION#ASK?}"
|
|
108
|
+
exit 0 ;;
|
|
109
|
+
DENY*)
|
|
110
|
+
echo "BLOCKED by agent-guard: ${DECISION#DENY?}." >&2
|
|
111
|
+
exit 2 ;;
|
|
65
112
|
BLOCK_UNATTENDED*)
|
|
66
113
|
echo "BLOCKED by agent-guard (unattended policy): ${DECISION#BLOCK_UNATTENDED?}." >&2
|
|
67
114
|
echo "This run has nobody watching it. Outward writes go through pr-request.json and the runner;" >&2
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* argv-secret-scan.mjs - a Bash command line that carries a literal
|
|
4
|
+
* credential is refused before it runs.
|
|
5
|
+
*
|
|
6
|
+
* agent-guard.sh pipes the PreToolUse payload here for every Bash call. A
|
|
7
|
+
* credential typed into a command line lands in the transcript, the shell
|
|
8
|
+
* history of whatever runs it, the process table and any log that echoes
|
|
9
|
+
* argv; the outbound gate (lib/outbound-gate.mjs) already knows the shapes, so
|
|
10
|
+
* its `certain` rules are applied to the command text.
|
|
11
|
+
*
|
|
12
|
+
* Three adjustments keep it to credentials that can leak:
|
|
13
|
+
* - A credential URL to a local host (localhost, 127.x, ::1, *.local) or with
|
|
14
|
+
* a placeholder pair (`postgres:postgres`, `user:pass`) is a dev DSN, not a
|
|
15
|
+
* secret, and is removed before the scan (withoutLocalCredentials).
|
|
16
|
+
* - Shell variable references (`$TOKEN`, `${TOKEN}`) and command
|
|
17
|
+
* substitutions (`$(...)`) are removed before the scan. A command that
|
|
18
|
+
* reads its secret from the environment or the credential store is the
|
|
19
|
+
* correct way to do it, and a clone URL whose password part is
|
|
20
|
+
* `${TOKEN}` must not read as a URL with credentials.
|
|
21
|
+
* - Rules that match a marker rather than a value
|
|
22
|
+
* (`service-account-json`, `private-key-block`) are skipped: grepping for
|
|
23
|
+
* the marker is how an audit finds the leak.
|
|
24
|
+
*
|
|
25
|
+
* Output never contains the matched value, only rule names.
|
|
26
|
+
*
|
|
27
|
+
* Exit: 0 clean (or not a Bash payload), 7 a literal credential found,
|
|
28
|
+
* 2 the payload could not be read.
|
|
29
|
+
*
|
|
30
|
+
* @module pipeline/scripts/argv-secret-scan
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
|
|
35
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
36
|
+
import { scan } from "../lib/outbound-gate.mjs";
|
|
37
|
+
|
|
38
|
+
const MARKER_RULES = new Set(["service-account-json", "private-key-block"]);
|
|
39
|
+
|
|
40
|
+
/** The command with variable references and substitutions removed. */
|
|
41
|
+
export function withoutReferences(cmd) {
|
|
42
|
+
let out = String(cmd);
|
|
43
|
+
// Innermost substitutions first, until none are left.
|
|
44
|
+
for (let i = 0; i < 8 && /\$\([^()]*\)/.test(out); i++) out = out.replace(/\$\([^()]*\)/g, "");
|
|
45
|
+
out = out.replace(/`[^`]*`/g, "");
|
|
46
|
+
out = out.replace(/\$\{[^}]*\}/g, "");
|
|
47
|
+
out = out.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, "");
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const URL_CREDS = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@]+):([^\s/@]+)@(\[[^\]\s]+\]|[^\s/:?#'"]+)/gi;
|
|
52
|
+
const LOCAL_HOST =
|
|
53
|
+
/^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|::1|[\w.-]+\.(?:local|localhost|test|example|invalid))$/i;
|
|
54
|
+
const PLACEHOLDERS = new Set([
|
|
55
|
+
"user",
|
|
56
|
+
"username",
|
|
57
|
+
"pass",
|
|
58
|
+
"password",
|
|
59
|
+
"secret",
|
|
60
|
+
"postgres",
|
|
61
|
+
"root",
|
|
62
|
+
"admin",
|
|
63
|
+
"test",
|
|
64
|
+
"example",
|
|
65
|
+
"changeme",
|
|
66
|
+
"default",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The command with credentials removed from URLs that cannot leak anything:
|
|
71
|
+
* a local host (a dev database DSN), or a placeholder pair (`user:pass`,
|
|
72
|
+
* `postgres:postgres`). A credential URL to any other host is left for the
|
|
73
|
+
* scan.
|
|
74
|
+
*/
|
|
75
|
+
export function withoutLocalCredentials(cmd) {
|
|
76
|
+
return String(cmd).replace(URL_CREDS, (all, scheme, user, pass, host) => {
|
|
77
|
+
const placeholder =
|
|
78
|
+
user === pass ||
|
|
79
|
+
(PLACEHOLDERS.has(user.toLowerCase()) && PLACEHOLDERS.has(pass.toLowerCase()));
|
|
80
|
+
return LOCAL_HOST.test(host) || placeholder ? `${scheme}${host}` : all;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Names of the credential rules a command line matches.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} cmd
|
|
88
|
+
* @returns {string[]}
|
|
89
|
+
*/
|
|
90
|
+
export function literalSecrets(cmd) {
|
|
91
|
+
if (typeof cmd !== "string" || !cmd) return [];
|
|
92
|
+
const found = scan(withoutLocalCredentials(withoutReferences(cmd))).filter(
|
|
93
|
+
(f) => f.certain && !MARKER_RULES.has(f.rule),
|
|
94
|
+
);
|
|
95
|
+
return [...new Set(found.map((f) => f.rule))];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function main() {
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
payload = JSON.parse(readFileSync(0, "utf8"));
|
|
102
|
+
} catch {
|
|
103
|
+
process.exitCode = 2;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (payload?.tool_name !== "Bash") return;
|
|
107
|
+
const rules = literalSecrets(payload?.tool_input?.command);
|
|
108
|
+
if (rules.length) {
|
|
109
|
+
process.stdout.write(`${rules.join(", ")}\n`);
|
|
110
|
+
process.exitCode = 7;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (invokedDirectly(import.meta.url)) main();
|
|
@@ -82,6 +82,7 @@ import { runMain } from "../lib/fatal.mjs";
|
|
|
82
82
|
import { LIVE_STATUSES, resolveClaudeBin } from "../lib/claude-sessions.mjs";
|
|
83
83
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
84
84
|
import { readRegularJson } from "../lib/regular-file.mjs";
|
|
85
|
+
import { completionGaps } from "../lib/run-completion.mjs";
|
|
85
86
|
import { messages, outputLanguage } from "../lib/user-facing.mjs";
|
|
86
87
|
import {
|
|
87
88
|
ARTIFACTS_SUBDIR,
|
|
@@ -1152,7 +1153,10 @@ export function prFromState(state) {
|
|
|
1152
1153
|
*
|
|
1153
1154
|
* "finished with no PR" is deliberately not "failed": a run can stop
|
|
1154
1155
|
* legitimately without opening one, and calling that a failure would put a
|
|
1155
|
-
* healthy item into the retry path.
|
|
1156
|
+
* healthy item into the retry path. It is only that when the state earns it
|
|
1157
|
+
* (lib/run-completion.mjs): a `complete` written below the mode's last phase,
|
|
1158
|
+
* without Phase 4 evidence or without a plan-coverage verdict is
|
|
1159
|
+
* `stopped-incomplete`, which is retried, not filed as finished.
|
|
1156
1160
|
*/
|
|
1157
1161
|
export function outcomeFor(supervised, state, prUrl) {
|
|
1158
1162
|
if (prUrl) return OUTCOME.PR_OPENED;
|
|
@@ -1166,7 +1170,9 @@ export function outcomeFor(supervised, state, prUrl) {
|
|
|
1166
1170
|
if (rateLimited({ row: supervised.lastRow, state })) return OUTCOME.RATE_LIMITED;
|
|
1167
1171
|
if (state && state.verificationFailed) return OUTCOME.VERIFICATION_FAILED;
|
|
1168
1172
|
const status = state && state.status;
|
|
1169
|
-
if (status === "complete" || status === "completed")
|
|
1173
|
+
if (status === "complete" || status === "completed") {
|
|
1174
|
+
return completionGaps(state).length ? OUTCOME.STOPPED_INCOMPLETE : OUTCOME.COMPLETED_NO_PR;
|
|
1175
|
+
}
|
|
1170
1176
|
if (status === "failed") return OUTCOME.FAILED;
|
|
1171
1177
|
return state ? `stopped-${status || "unknown"}` : OUTCOME.NO_STATE;
|
|
1172
1178
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* confirm-command.mjs - carry the user's yes for one command to agent-guard.
|
|
4
|
+
*
|
|
5
|
+
* agent-guard asks before a write a person must confirm (closing an issue, a
|
|
6
|
+
* PR body that closes one, a Jira transition POST). In bypassPermissions and
|
|
7
|
+
* dontAsk no permission prompt is raised, so the guard denies and says to ask
|
|
8
|
+
* the user with AskUserQuestion. On a yes, this records it: a file named by the
|
|
9
|
+
* sha256 of the exact command under <log root>/confirmations/. The next time
|
|
10
|
+
* agent-guard sees that exact command it consumes the record and lets it run;
|
|
11
|
+
* a record older than ten minutes, or for any other command, does nothing.
|
|
12
|
+
*
|
|
13
|
+
* Attended only. Under MULTI_AGENT_UNATTENDED=1 nobody gave an answer, so the
|
|
14
|
+
* script refuses (exit 2) and agent-guard never reads a record there.
|
|
15
|
+
*
|
|
16
|
+
* Usage: confirm-command.mjs --command '<the exact command>'
|
|
17
|
+
* Exit: 0 recorded, 2 usage or unattended, 3 could not write
|
|
18
|
+
*
|
|
19
|
+
* @module pipeline/scripts/confirm-command
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
28
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
29
|
+
import { isUnattended } from "../lib/unattended.mjs";
|
|
30
|
+
|
|
31
|
+
/** Where records live; agent-guard's _guard_rules.confirmations_dir agrees. */
|
|
32
|
+
export function confirmationsDir(env = process.env) {
|
|
33
|
+
const root = env.LOGS_ROOT || join(homedir(), ".claude", "logs", "multi-agent");
|
|
34
|
+
return join(root, "confirmations");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The record path for one command. */
|
|
38
|
+
export function recordPath(command, env = process.env) {
|
|
39
|
+
const digest = createHash("sha256").update(command, "utf8").digest("hex");
|
|
40
|
+
return join(confirmationsDir(env), `${digest}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function main() {
|
|
44
|
+
const argv = process.argv.slice(2);
|
|
45
|
+
const i = argv.indexOf("--command");
|
|
46
|
+
const command = i === -1 ? undefined : argv[i + 1];
|
|
47
|
+
if (typeof command !== "string" || !command) {
|
|
48
|
+
process.stderr.write("usage: confirm-command.mjs --command '<the exact command>'\n");
|
|
49
|
+
process.exitCode = 2;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (isUnattended()) {
|
|
53
|
+
process.stderr.write("confirm-command: refused, nobody can confirm in an unattended run\n");
|
|
54
|
+
process.exitCode = 2;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(confirmationsDir(), { recursive: true, mode: 0o700 });
|
|
59
|
+
writeFileSync(recordPath(command), `${JSON.stringify({ at: new Date().toISOString() })}\n`, {
|
|
60
|
+
mode: 0o600,
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
process.stderr.write(`confirm-command: ${err.message}\n`);
|
|
64
|
+
process.exitCode = 3;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
process.stdout.write("confirm-command: recorded for 10 minutes, one use\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (invokedDirectly(import.meta.url)) runMain("confirm-command", main, { code: 3 });
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "../lib/unattended-settings-location.mjs";
|
|
62
62
|
import { prefsHosts, profileGaps } from "../lib/unattended-profile-check.mjs";
|
|
63
63
|
import { xcodeSkillsStatus } from "../lib/xcode-plugin.mjs";
|
|
64
|
+
import { hookReport } from "../lib/hook-registration.mjs";
|
|
64
65
|
|
|
65
66
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
66
67
|
const HOME = homedir();
|
|
@@ -94,6 +95,7 @@ const CHECK_IDS = [
|
|
|
94
95
|
"prefs-valid",
|
|
95
96
|
"identity",
|
|
96
97
|
"hook-coverage",
|
|
98
|
+
"hook-reachability",
|
|
97
99
|
"credential-mapping",
|
|
98
100
|
"credential-liveness",
|
|
99
101
|
"store-reference",
|
|
@@ -515,6 +517,57 @@ function checkHookCoverage() {
|
|
|
515
517
|
ok("hook-coverage");
|
|
516
518
|
}
|
|
517
519
|
|
|
520
|
+
// hook-coverage asks whether each template command appears in settings.json
|
|
521
|
+
// at all. This asks what Claude Code will actually run: per event, a matcher
|
|
522
|
+
// that covers the template's tool names, the script present under
|
|
523
|
+
// ~/.claude/scripts, no disableAllHooks; and which preferences do nothing
|
|
524
|
+
// because their hook is missing (lib/hook-registration.mjs).
|
|
525
|
+
function checkHookReachability() {
|
|
526
|
+
const tpl = join(CLAUDE, "templates", "claude-hooks.json");
|
|
527
|
+
if (!existsSync(tpl)) {
|
|
528
|
+
skip("hook-reachability", "no hooks template installed to compare against");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const r = hookReport({ templatePath: tpl, home: HOME });
|
|
532
|
+
if (r.error) {
|
|
533
|
+
skip("hook-reachability", r.error);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (r.ok) {
|
|
537
|
+
ok("hook-reachability");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const parts = [];
|
|
541
|
+
if (r.disabled) parts.push(r.disabled);
|
|
542
|
+
else if (r.missing.length) {
|
|
543
|
+
const names = [
|
|
544
|
+
...new Set(
|
|
545
|
+
r.missing.map(
|
|
546
|
+
(m) => `${m.script || m.command} on ${m.event}${m.matcher ? ` ${m.matcher}` : ""}`,
|
|
547
|
+
),
|
|
548
|
+
),
|
|
549
|
+
];
|
|
550
|
+
parts.push(
|
|
551
|
+
`${r.missing.length} template hook(s) Claude Code will not run: ${names.slice(0, 4).join(", ")}${names.length > 4 ? ` and ${names.length - 4} more` : ""}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
if (r.inert.length) {
|
|
555
|
+
parts.push(
|
|
556
|
+
`inert preference(s): ${r.inert.map((i) => `${i.pref} (needs ${i.script})`).join(", ")}`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
report(
|
|
560
|
+
"hook-reachability",
|
|
561
|
+
"WARN",
|
|
562
|
+
parts.join("; "),
|
|
563
|
+
`merge ${tpl} into ${join(CLAUDE, "settings.json")} (or run ${hostCommand("setup", HERE)})`,
|
|
564
|
+
[
|
|
565
|
+
...r.missing.map((m) => `${m.event} ${m.matcher || "-"} ${m.command}: ${m.why}`),
|
|
566
|
+
...r.inert.map((i) => `prefs.global.${i.pref} -> ${i.script}`),
|
|
567
|
+
],
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
518
571
|
function credentialInventory() {
|
|
519
572
|
const inv = join(CLAUDE, "lib", "credential-inventory.sh");
|
|
520
573
|
if (!existsSync(inv)) return null;
|
|
@@ -1403,6 +1456,7 @@ function main() {
|
|
|
1403
1456
|
checkPrefsValid();
|
|
1404
1457
|
checkIdentity();
|
|
1405
1458
|
checkHookCoverage();
|
|
1459
|
+
checkHookReachability();
|
|
1406
1460
|
checkCredentials(credentialInventory());
|
|
1407
1461
|
checkStoreReference();
|
|
1408
1462
|
checkEmbeddedCredentials();
|
|
@@ -75,7 +75,7 @@ import { spawnSync } from "node:child_process";
|
|
|
75
75
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
76
76
|
import { fileURLToPath } from "node:url";
|
|
77
77
|
|
|
78
|
-
import { gateContext } from "./gate-ledger.mjs";
|
|
78
|
+
import { ATTENDED_RECORDED, gateContext } from "./gate-ledger.mjs";
|
|
79
79
|
|
|
80
80
|
const LEDGER = fileURLToPath(new URL("./gate-ledger.mjs", import.meta.url));
|
|
81
81
|
|
|
@@ -203,15 +203,19 @@ function parseArgs(argv) {
|
|
|
203
203
|
// the bytes are gone. The verdict is short today; the verdict is also the only
|
|
204
204
|
// thing a caller reads, so losing half of it is the one failure that matters.
|
|
205
205
|
let verdictExtra = {};
|
|
206
|
-
//
|
|
207
|
-
// one synchronous append, so the verdict and its ledger entry
|
|
206
|
+
// While the quality gates are active (lib/unattended.mjs, gatesActive), and
|
|
207
|
+
// for the build and test claims of an attended run: one synchronous append, so the verdict and its ledger entry
|
|
208
208
|
// cannot come apart. A ledger problem is reported and never changes the verdict.
|
|
209
209
|
let ledgerClaim = null;
|
|
210
210
|
let ledgerState = null;
|
|
211
211
|
function ledger(verdict, reason, extra) {
|
|
212
212
|
if (!ledgerClaim) return;
|
|
213
213
|
const ctx = gateContext({ statePath: ledgerState });
|
|
214
|
-
|
|
214
|
+
// Attended, only the build and test claims are recorded (marked attended):
|
|
215
|
+
// the attended commit check reads them. Without a run state there is
|
|
216
|
+
// nothing to record into.
|
|
217
|
+
if (!ctx.active && !(ctx.statePath && ATTENDED_RECORDED.has(`evidence-gate/${ledgerClaim}`)))
|
|
218
|
+
return;
|
|
215
219
|
const detail = { reason, ...(extra.summary ? { summary: extra.summary } : {}) };
|
|
216
220
|
if (verdictExtra.stack) detail.stack = verdictExtra.stack;
|
|
217
221
|
const argv = [LEDGER, "append", "--gate", `evidence-gate/${ledgerClaim}`, "--verdict", verdict];
|