@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 (186) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -6
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +122 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/install/templates/claude-hooks.json +30 -1
  12. package/manifest.json +189 -77
  13. package/package.json +2 -1
  14. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  15. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  16. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  17. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  18. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  21. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  22. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  23. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  24. package/pipeline/contract/CHANGELOG.md +62 -0
  25. package/pipeline/contract/README.md +52 -6
  26. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  27. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  28. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  29. package/pipeline/contract/manifest.json +367 -22
  30. package/pipeline/contract/types/index.d.ts +531 -4
  31. package/pipeline/lib/hook-registration.mjs +252 -0
  32. package/pipeline/lib/prefs-value.mjs +44 -0
  33. package/pipeline/lib/run-completion.mjs +148 -0
  34. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  36. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  37. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  38. package/pipeline/multi-agent-refs/help/en.md +3 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  40. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  41. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  42. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  43. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  44. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  45. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  46. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  47. package/pipeline/office/NOTICE.md +42 -0
  48. package/pipeline/office/assets/BIN.png +0 -0
  49. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  50. package/pipeline/office/assets/CACTUS.png +0 -0
  51. package/pipeline/office/assets/CLOCK.png +0 -0
  52. package/pipeline/office/assets/COFFEE.png +0 -0
  53. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  54. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  55. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  56. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  57. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  58. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  59. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  60. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  61. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  62. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  63. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  64. package/pipeline/office/assets/PC_BACK.png +0 -0
  65. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  66. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  67. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  68. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  69. package/pipeline/office/assets/PC_SIDE.png +0 -0
  70. package/pipeline/office/assets/PLANT.png +0 -0
  71. package/pipeline/office/assets/PLANT_2.png +0 -0
  72. package/pipeline/office/assets/POT.png +0 -0
  73. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  74. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  75. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  76. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  77. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  78. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  79. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  80. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  81. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  82. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  83. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  84. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  85. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  86. package/pipeline/office/assets/char_0.png +0 -0
  87. package/pipeline/office/assets/char_1.png +0 -0
  88. package/pipeline/office/assets/char_2.png +0 -0
  89. package/pipeline/office/assets/char_3.png +0 -0
  90. package/pipeline/office/assets/char_4.png +0 -0
  91. package/pipeline/office/assets/char_5.png +0 -0
  92. package/pipeline/office/assets/floor_0.png +0 -0
  93. package/pipeline/office/assets/floor_1.png +0 -0
  94. package/pipeline/office/assets/floor_2.png +0 -0
  95. package/pipeline/office/assets/floor_3.png +0 -0
  96. package/pipeline/office/assets/floor_4.png +0 -0
  97. package/pipeline/office/assets/floor_5.png +0 -0
  98. package/pipeline/office/assets/floor_6.png +0 -0
  99. package/pipeline/office/assets/floor_7.png +0 -0
  100. package/pipeline/office/assets/floor_8.png +0 -0
  101. package/pipeline/office/assets/wall_0.png +0 -0
  102. package/pipeline/office/index.html +5319 -0
  103. package/pipeline/preferences-template.json +7 -0
  104. package/pipeline/rules/figma-pipeline.md +2 -0
  105. package/pipeline/schemas/activity-chat.schema.json +78 -0
  106. package/pipeline/schemas/activity-roles.json +48 -0
  107. package/pipeline/schemas/activity-roles.schema.json +49 -0
  108. package/pipeline/schemas/activity.schema.json +429 -0
  109. package/pipeline/schemas/agent-state.schema.json +20 -5
  110. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  111. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  112. package/pipeline/schemas/contract-error.schema.json +33 -3
  113. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  114. package/pipeline/schemas/ledger.schema.json +202 -0
  115. package/pipeline/schemas/permissions.schema.json +47 -0
  116. package/pipeline/schemas/phases.json +3 -2
  117. package/pipeline/schemas/phases.schema.json +5 -1
  118. package/pipeline/schemas/prefs.schema.json +56 -0
  119. package/pipeline/schemas/prs.schema.json +73 -0
  120. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  121. package/pipeline/schemas/review-verdict.schema.json +62 -0
  122. package/pipeline/schemas/routine-delete.schema.json +59 -0
  123. package/pipeline/schemas/routines.schema.json +38 -0
  124. package/pipeline/schemas/run-evidence.schema.json +29 -0
  125. package/pipeline/schemas/run-results.schema.json +218 -0
  126. package/pipeline/schemas/runs-index.schema.json +1 -1
  127. package/pipeline/schemas/service-read.schema.json +58 -0
  128. package/pipeline/schemas/stack-change.schema.json +82 -0
  129. package/pipeline/schemas/stack-request.schema.json +33 -0
  130. package/pipeline/schemas/stack.schema.json +59 -0
  131. package/pipeline/schemas/steer-request.schema.json +25 -0
  132. package/pipeline/schemas/steer.schema.json +59 -0
  133. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  134. package/pipeline/schemas/tool-call.schema.json +47 -0
  135. package/pipeline/schemas/tools.schema.json +60 -0
  136. package/pipeline/scripts/_active-run.mjs +158 -0
  137. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  138. package/pipeline/scripts/_cost.mjs +66 -1
  139. package/pipeline/scripts/_guard_rules.py +605 -0
  140. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  141. package/pipeline/scripts/_spawn-async.mjs +73 -0
  142. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  143. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  144. package/pipeline/scripts/agent-guard.py +118 -7
  145. package/pipeline/scripts/agent-guard.sh +50 -3
  146. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  147. package/pipeline/scripts/autopilot-control.mjs +214 -9
  148. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  149. package/pipeline/scripts/confirm-command.mjs +70 -0
  150. package/pipeline/scripts/contract-server.mjs +1178 -48
  151. package/pipeline/scripts/cost-analyze.mjs +2 -53
  152. package/pipeline/scripts/cost-table.json +1 -0
  153. package/pipeline/scripts/doctor.mjs +54 -0
  154. package/pipeline/scripts/evidence-gate.mjs +8 -4
  155. package/pipeline/scripts/gate-ledger.mjs +163 -7
  156. package/pipeline/scripts/office-build.mjs +89 -0
  157. package/pipeline/scripts/office-control.mjs +383 -0
  158. package/pipeline/scripts/office-ledger.mjs +409 -0
  159. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  160. package/pipeline/scripts/pre-commit-check.sh +12 -0
  161. package/pipeline/scripts/routine-registry.mjs +42 -6
  162. package/pipeline/scripts/run-results.mjs +373 -0
  163. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  164. package/pipeline/scripts/run-scope-guard.sh +23 -0
  165. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  166. package/pipeline/scripts/run-stop-guard.sh +25 -0
  167. package/pipeline/scripts/service-read.mjs +244 -0
  168. package/pipeline/scripts/stack-toggle.mjs +285 -0
  169. package/pipeline/scripts/steer-run.mjs +97 -0
  170. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  171. package/pipeline/scripts/uninstall.mjs +4 -0
  172. package/pipeline/scripts/validate-state.mjs +26 -0
  173. package/pipeline/scripts/work-items.mjs +304 -0
  174. package/pipeline/scripts/write-state.mjs +114 -0
  175. package/pipeline/skills/.skill-manifest.json +19 -7
  176. package/pipeline/skills/.skills-index.json +34 -1
  177. package/pipeline/skills/shared/README.md +6 -3
  178. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  179. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  180. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  181. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  182. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  183. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  184. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  185. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  186. package/pipeline/skills/skills-index.md +4 -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 | BLOCK_UNATTENDED<TAB>reason | BLOCK_PARSE | OK
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 force-push rules
13
- only, and any error prints OK (fail-open) so a guard bug cannot break a
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 (ATTRIBUTION.search(cmd) or ATTRIBUTION.search(normalize_for_match(cmd))):
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 != "OK":
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
- return "OK"
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, Anthropic no-reply address).
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();
@@ -16,8 +16,17 @@
16
16
  * under `autopilot/abandoned/<task-id>` and kept, a clean one is
17
17
  * removed. Without --now nothing in flight is touched.
18
18
  *
19
- * Turning the mode on stays /multi-agent:autopilot-on: it picks repositories
20
- * with the user.
19
+ * on --repo <owner/name>... [--configure-only]
20
+ * the writing half of /multi-agent:autopilot-on, for a selection
21
+ * already made: config.json keeps exactly the selected repos
22
+ * (each must already be configured; adding a new one, with its
23
+ * sources and label, stays the terminal picker), every
24
+ * credential the result needs is probed (autopilot-arming.mjs
25
+ * probeCredentials), and unless --configure-only or a probe
26
+ * fails, the schedule plist is rendered and loaded and the awake
27
+ * agent installed. An empty selection is refused.
28
+ * on-preview --repo <owner/name>...
29
+ * what `on` would keep, drop and probe, writing nothing.
21
30
  *
22
31
  * Injectable for tests: MA_AUTOPILOT_ROOT, MA_AUTOPILOT_LABEL,
23
32
  * MA_AUTOPILOT_PLIST, MA_AP_LAUNCH_AGENTS, MA_AP_LAUNCHCTL_BIN,
@@ -26,14 +35,28 @@
26
35
  * Usage:
27
36
  * node autopilot-control.mjs status
28
37
  * node autopilot-control.mjs off [--now]
38
+ * node autopilot-control.mjs on --repo <owner/name>... [--configure-only]
39
+ * node autopilot-control.mjs on-preview --repo <owner/name>...
29
40
  *
30
- * Exit: 0 done, 2 usage, 3 status could not be built.
41
+ * With MA_CONTROL_JSON_ERRORS=1 a refusal is also printed on stdout as
42
+ * {error: {code, message}}, for contract-server.mjs, which runs this script
43
+ * as a child process.
44
+ *
45
+ * Exit: 0 done, 1 `on` refused, 2 usage, 3 status could not be built.
31
46
  *
32
47
  * @module pipeline/scripts/autopilot-control
33
48
  */
34
49
 
35
50
  import { execFileSync, spawnSync } from "node:child_process";
36
- import { existsSync, readFileSync, rmSync } from "node:fs";
51
+ import {
52
+ chmodSync,
53
+ existsSync,
54
+ mkdirSync,
55
+ readFileSync,
56
+ renameSync,
57
+ rmSync,
58
+ writeFileSync,
59
+ } from "node:fs";
37
60
  import { homedir } from "node:os";
38
61
  import { dirname, join } from "node:path";
39
62
  import { fileURLToPath } from "node:url";
@@ -45,7 +68,9 @@ import {
45
68
  sessionIsLive,
46
69
  stashWorktree,
47
70
  } from "./autopilot-runner.mjs";
48
- import { removeAwake } from "./autopilot-awake.mjs";
71
+ import { installAwake, removeAwake } from "./autopilot-awake.mjs";
72
+ import { probeCredentials } from "./autopilot-arming.mjs";
73
+ import { configRefusal } from "./_autopilot-config.mjs";
49
74
  import { updateState } from "./gate-ledger.mjs";
50
75
  import { autopilotRoot } from "../lib/pr-request-location.mjs";
51
76
  import { runMain } from "../lib/fatal.mjs";
@@ -55,7 +80,12 @@ export const CONTRACT_VERSION = "1.0.0";
55
80
 
56
81
  const HERE = dirname(fileURLToPath(import.meta.url));
57
82
 
58
- export class ControlError extends Error {}
83
+ export class ControlError extends Error {
84
+ constructor(message, code = "internal") {
85
+ super(message);
86
+ this.code = code;
87
+ }
88
+ }
59
89
 
60
90
  function readJson(path, fallback) {
61
91
  try {
@@ -196,6 +226,155 @@ export async function autopilotOff({ now = false, env = process.env } = {}) {
196
226
  return { enabled: false, stoppedInFlight };
197
227
  }
198
228
 
229
+ const SCHEDULE_TEMPLATE = "multi-agent-autopilot.plist.template";
230
+
231
+ /** The installed tree keeps templates/ beside scripts/; the repo keeps it in install/. */
232
+ function scheduleTemplate(env) {
233
+ const home = env.HOME || homedir();
234
+ return (
235
+ [
236
+ env.MA_AP_SCHEDULE_TEMPLATE,
237
+ join(HERE, "..", "templates", SCHEDULE_TEMPLATE),
238
+ join(HERE, "..", "..", "install", "templates", SCHEDULE_TEMPLATE),
239
+ ...[".claude", ".copilot", ".codex"].map((d) =>
240
+ join(home, d, "templates", SCHEDULE_TEMPLATE),
241
+ ),
242
+ ]
243
+ .filter(Boolean)
244
+ .find((p) => existsSync(p)) ?? null
245
+ );
246
+ }
247
+
248
+ const xml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
249
+
250
+ function which(bin, env) {
251
+ const r = spawnSync("/bin/sh", ["-c", `command -v ${bin}`], {
252
+ env,
253
+ encoding: "utf8",
254
+ timeout: 5000,
255
+ });
256
+ return r.status === 0 ? r.stdout.trim() : "";
257
+ }
258
+
259
+ /** The schedule plist, rendered with the seven placeholders autopilot-on fills. */
260
+ export function renderSchedule(config, env = process.env) {
261
+ const tpl = scheduleTemplate(env);
262
+ if (!tpl)
263
+ throw new ControlError(
264
+ `${SCHEDULE_TEMPLATE} is not installed - re-run the installer`,
265
+ "not-installed",
266
+ );
267
+ const home = env.HOME || homedir();
268
+ const values = {
269
+ NODE: env.MA_AP_NODE_BIN || process.execPath,
270
+ TICK: join(HERE, "autopilot-runner.mjs"),
271
+ LABEL: env.MA_AUTOPILOT_LABEL || "com.multi-agent.autopilot",
272
+ ROOT: autopilotRoot(env),
273
+ INTERVAL: Number.isInteger(config?.scanIntervalSeconds) ? config.scanIntervalSeconds : 120,
274
+ HOME: home,
275
+ CLAUDE: env.MA_AP_CLAUDE_BIN || which("claude", env),
276
+ };
277
+ return readFileSync(tpl, "utf8").replace(/\{\{([A-Z]+)\}\}/g, (m, k) =>
278
+ k in values ? xml(values[k]) : m,
279
+ );
280
+ }
281
+
282
+ function readPrefs(env) {
283
+ const path =
284
+ env.MULTI_AGENT_PREFS ||
285
+ env.MA_PREFS_FILE ||
286
+ join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json");
287
+ return readJson(path, {});
288
+ }
289
+
290
+ /**
291
+ * What `on` would do for a selection: the config it writes and the probe of
292
+ * every credential that config needs. Writes nothing.
293
+ *
294
+ * @param {{repos: string[], env?: object}} opts nameWithOwner of each selected repo
295
+ */
296
+ export function autopilotOnPlan({ repos, env = process.env }) {
297
+ const selected = [...new Set(Array.isArray(repos) ? repos : [])];
298
+ if (!selected.length) {
299
+ throw new ControlError(
300
+ "no repository selected; autopilot is never turned on without one",
301
+ "no-selection",
302
+ );
303
+ }
304
+ const root = autopilotRoot(env);
305
+ const config = readJson(join(root, "config.json"), null);
306
+ const configured = Array.isArray(config?.repos) ? config.repos : [];
307
+ const names = configured.map((r) => r?.nameWithOwner);
308
+ const missing = selected.filter((n) => !names.includes(n));
309
+ if (missing.length) {
310
+ throw new ControlError(
311
+ `not configured yet: ${missing.join(", ")} - add a repository with /multi-agent:autopilot-on in a terminal, which asks its sources and label`,
312
+ "repo-not-configured",
313
+ );
314
+ }
315
+ const next = { ...config, repos: configured.filter((r) => selected.includes(r?.nameWithOwner)) };
316
+ const refusal = configRefusal(next);
317
+ if (refusal) throw new ControlError(refusal, "invalid-config");
318
+ const probe = probeCredentials({ config: next, prefs: readPrefs(env), env });
319
+ return {
320
+ config: next,
321
+ keep: selected,
322
+ drop: names.filter((n) => !selected.includes(n)),
323
+ probe: {
324
+ ready: probe.ready,
325
+ problems: probe.problems.map((p) => p.message),
326
+ warnings: probe.warnings,
327
+ },
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Turn continuous mode on for a selection of configured repositories.
333
+ *
334
+ * @param {{repos: string[], configureOnly?: boolean, env?: object}} opts
335
+ * @returns {{enabled: boolean, scheduled: boolean, repos: string[], dropped: string[],
336
+ * probe: {ready: boolean, problems: string[], warnings: string[]}, awake: boolean|null}}
337
+ */
338
+ export function autopilotOn({ repos, configureOnly = false, env = process.env }) {
339
+ const plan = autopilotOnPlan({ repos, env });
340
+ const root = autopilotRoot(env);
341
+ mkdirSync(root, { recursive: true, mode: 0o700 });
342
+ const file = join(root, "config.json");
343
+ const tmp = join(root, `.config.json.${process.pid}`);
344
+ writeFileSync(tmp, JSON.stringify(plan.config, null, 2) + "\n", { mode: 0o600 });
345
+ chmodSync(tmp, 0o600);
346
+ renameSync(tmp, file);
347
+ const base = { repos: plan.keep, dropped: plan.drop, probe: plan.probe };
348
+ if (configureOnly || !plan.probe.ready) {
349
+ return { enabled: false, scheduled: false, awake: null, ...base };
350
+ }
351
+ const label = env.MA_AUTOPILOT_LABEL || "com.multi-agent.autopilot";
352
+ const agents = env.MA_AP_LAUNCH_AGENTS || join(env.HOME || homedir(), "Library", "LaunchAgents");
353
+ const plist = env.MA_AUTOPILOT_PLIST || join(agents, `${label}.plist`);
354
+ mkdirSync(agents, { recursive: true });
355
+ const ptmp = `${plist}.${process.pid}.tmp`;
356
+ writeFileSync(ptmp, renderSchedule(plan.config, env), { mode: 0o644 });
357
+ renameSync(ptmp, plist);
358
+ const loaded =
359
+ launchctl(["bootstrap", `gui/${process.getuid()}`, plist], env) ||
360
+ launchctl(["load", plist], env);
361
+ if (!loaded) {
362
+ throw new ControlError("launchd refused the schedule plist", "launchd-refused");
363
+ }
364
+ const awake = installAwake({ env });
365
+ return { enabled: true, scheduled: true, awake: awake.loaded, ...base };
366
+ }
367
+
368
+ function refuse(err, code) {
369
+ process.stderr.write(`autopilot-control: ${err.message}\n`);
370
+ if (process.env.MA_CONTROL_JSON_ERRORS === "1") {
371
+ process.stdout.write(
372
+ `${JSON.stringify({ error: { code: err.code, message: err.message } })}\n`,
373
+ );
374
+ }
375
+ return code;
376
+ }
377
+
199
378
  async function main(argv) {
200
379
  const [mode, ...rest] = argv;
201
380
  if (mode === "status" && !rest.length) {
@@ -203,8 +382,8 @@ async function main(argv) {
203
382
  process.stdout.write(`${JSON.stringify(autopilotStatus())}\n`);
204
383
  return 0;
205
384
  } catch (err) {
206
- process.stderr.write(`autopilot-control: ${err.message}\n`);
207
- return 3;
385
+ if (!(err instanceof ControlError)) throw err;
386
+ return refuse(err, 3);
208
387
  }
209
388
  }
210
389
  if (mode === "off" && rest.every((a) => a === "--now")) {
@@ -212,7 +391,33 @@ async function main(argv) {
212
391
  process.stdout.write(`${JSON.stringify(out)}\n`);
213
392
  return 0;
214
393
  }
215
- process.stderr.write("usage: autopilot-control.mjs status | off [--now]\n");
394
+ if (mode === "on" || mode === "on-preview") {
395
+ const repos = [];
396
+ let configureOnly = false;
397
+ for (let i = 0; i < rest.length; i++) {
398
+ if (rest[i] === "--repo" && rest[i + 1]) repos.push(rest[++i]);
399
+ else if (rest[i] === "--configure-only" && mode === "on") configureOnly = true;
400
+ else return usage();
401
+ }
402
+ try {
403
+ const out =
404
+ mode === "on"
405
+ ? autopilotOn({ repos, configureOnly })
406
+ : (({ config: _c, ...plan }) => plan)(autopilotOnPlan({ repos }));
407
+ process.stdout.write(`${JSON.stringify(out)}\n`);
408
+ return 0;
409
+ } catch (err) {
410
+ if (!(err instanceof ControlError)) throw err;
411
+ return refuse(err, 1);
412
+ }
413
+ }
414
+ return usage();
415
+ }
416
+
417
+ function usage() {
418
+ process.stderr.write(
419
+ "usage: autopilot-control.mjs status | off [--now] | on --repo <owner/name>... [--configure-only] | on-preview --repo <owner/name>...\n",
420
+ );
216
421
  return 2;
217
422
  }
218
423