@mmerterden/multi-agent-pipeline 20.2.0 → 20.3.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 (621) hide show
  1. package/CHANGELOG.md +923 -1
  2. package/README.md +104 -81
  3. package/README.tr.md +103 -62
  4. package/docs/FIGMA_PIPELINE.md +35 -35
  5. package/docs/adr/0006-skills-core-external-split.md +1 -1
  6. package/docs/adr/0007-multi-tool-adapter-framework.md +6 -0
  7. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +7 -0
  8. package/docs/architecture.md +50 -14
  9. package/docs/best-practices.md +1 -1
  10. package/docs/ecosystem.md +56 -32
  11. package/docs/facts.json +10 -10
  12. package/docs/features.md +97 -5
  13. package/docs/recovery-guide.md +7 -14
  14. package/docs/server-readiness.md +31 -24
  15. package/index.js +1 -1
  16. package/install/_common.mjs +3 -5
  17. package/install/_platform-filter.mjs +23 -1
  18. package/install/_unattended-profile.mjs +321 -75
  19. package/install/claude.mjs +51 -10
  20. package/install/codex.mjs +2 -0
  21. package/install/copilot.mjs +2 -0
  22. package/install/index.mjs +30 -17
  23. package/install/templates/claude-hooks.json +16 -5
  24. package/install/templates/copilot-instructions.md +1 -1
  25. package/install/templates/multi-agent-autopilot-awake.plist.template +48 -0
  26. package/install/templates/multi-agent-autopilot.plist.template +12 -5
  27. package/install/unattended-profile-legacy.json +80 -0
  28. package/manifest.json +616 -483
  29. package/package.json +8 -3
  30. package/pipeline/agents/code-reviewer.md +10 -0
  31. package/pipeline/agents/plan-critic.md +98 -0
  32. package/pipeline/agents/security-auditor.md +10 -0
  33. package/pipeline/agents/task-clarifier.md +10 -0
  34. package/pipeline/commands/multi-agent/SKILL.md +2 -2
  35. package/pipeline/commands/multi-agent/analysis/SKILL.md +6 -2
  36. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +12 -1
  37. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +4 -0
  38. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  39. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +36 -6
  40. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +77 -12
  41. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +29 -8
  42. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +4 -0
  43. package/pipeline/commands/multi-agent/channels/SKILL.md +9 -9
  44. package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +5 -1
  45. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -0
  46. package/pipeline/commands/multi-agent/design-check/SKILL.md +16 -11
  47. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +4 -0
  48. package/pipeline/commands/multi-agent/doctor/SKILL.md +4 -0
  49. package/pipeline/commands/multi-agent/feedback/SKILL.md +5 -1
  50. package/pipeline/commands/multi-agent/forget/SKILL.md +4 -0
  51. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +4 -0
  52. package/pipeline/commands/multi-agent/graph/SKILL.md +4 -0
  53. package/pipeline/commands/multi-agent/help/SKILL.md +37 -39
  54. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +4 -0
  55. package/pipeline/commands/multi-agent/issue/SKILL.md +7 -1
  56. package/pipeline/commands/multi-agent/jira/SKILL.md +7 -1
  57. package/pipeline/commands/multi-agent/kill/SKILL.md +9 -3
  58. package/pipeline/commands/multi-agent/language/SKILL.md +4 -0
  59. package/pipeline/commands/multi-agent/log/SKILL.md +4 -0
  60. package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -0
  61. package/pipeline/commands/multi-agent/model/SKILL.md +4 -0
  62. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -0
  63. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +4 -0
  64. package/pipeline/commands/multi-agent/purge/SKILL.md +4 -0
  65. package/pipeline/commands/multi-agent/refactor/SKILL.md +5 -0
  66. package/pipeline/commands/multi-agent/research/SKILL.md +49 -0
  67. package/pipeline/commands/multi-agent/resume/SKILL.md +29 -6
  68. package/pipeline/commands/multi-agent/review/SKILL.md +28 -9
  69. package/pipeline/commands/multi-agent/review-analysis/SKILL.md +5 -1
  70. package/pipeline/commands/multi-agent/review-issue/SKILL.md +4 -0
  71. package/pipeline/commands/multi-agent/review-jira/SKILL.md +4 -0
  72. package/pipeline/commands/multi-agent/route-off/SKILL.md +4 -0
  73. package/pipeline/commands/multi-agent/route-on/SKILL.md +4 -0
  74. package/pipeline/commands/multi-agent/route-status/SKILL.md +4 -0
  75. package/pipeline/commands/multi-agent/routines/SKILL.md +4 -0
  76. package/pipeline/commands/multi-agent/save/SKILL.md +6 -2
  77. package/pipeline/commands/multi-agent/scaffold/SKILL.md +47 -0
  78. package/pipeline/commands/multi-agent/scan/SKILL.md +4 -0
  79. package/pipeline/commands/multi-agent/search/SKILL.md +12 -8
  80. package/pipeline/commands/multi-agent/security-review/SKILL.md +4 -0
  81. package/pipeline/commands/multi-agent/serve/SKILL.md +62 -0
  82. package/pipeline/commands/multi-agent/setup/SKILL.md +27 -36
  83. package/pipeline/commands/multi-agent/stack/SKILL.md +4 -0
  84. package/pipeline/commands/multi-agent/status/SKILL.md +10 -9
  85. package/pipeline/commands/multi-agent/steer/SKILL.md +5 -1
  86. package/pipeline/commands/multi-agent/store-ready/SKILL.md +4 -0
  87. package/pipeline/commands/multi-agent/sync/SKILL.md +13 -13
  88. package/pipeline/commands/multi-agent/test/SKILL.md +4 -0
  89. package/pipeline/commands/multi-agent/test-accessibility/SKILL.md +4 -0
  90. package/pipeline/commands/multi-agent/test-dark-mode/SKILL.md +4 -0
  91. package/pipeline/commands/multi-agent/test-dynamic-type/SKILL.md +4 -0
  92. package/pipeline/commands/multi-agent/test-screenshots/SKILL.md +5 -1
  93. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +4 -0
  94. package/pipeline/commands/multi-agent/uninstall/SKILL.md +5 -1
  95. package/pipeline/commands/multi-agent/update/SKILL.md +4 -0
  96. package/pipeline/contract/CHANGELOG.md +74 -0
  97. package/pipeline/contract/README.md +126 -0
  98. package/pipeline/contract/build.mjs +427 -0
  99. package/pipeline/contract/fixtures/answer-result.json +11 -0
  100. package/pipeline/contract/fixtures/error-invalid-request.json +8 -0
  101. package/pipeline/contract/fixtures/error-unauthorized.json +5 -0
  102. package/pipeline/contract/fixtures/error-unsigned.json +5 -0
  103. package/pipeline/contract/fixtures/issues-empty.json +18 -0
  104. package/pipeline/contract/fixtures/launch-plan.json +31 -0
  105. package/pipeline/contract/fixtures/runs-awaiting-question.json +70 -0
  106. package/pipeline/contract/fixtures/runs-empty.json +6 -0
  107. package/pipeline/contract/fixtures/runs-failed.json +84 -0
  108. package/pipeline/contract/fixtures/runs-old-schema.json +70 -0
  109. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +101 -0
  110. package/pipeline/contract/fixtures/runs-pr-opened.json +106 -0
  111. package/pipeline/contract/fixtures/runs-running.json +84 -0
  112. package/pipeline/contract/fixtures/worktrees-empty.json +4 -0
  113. package/pipeline/contract/frozen/toolbox.json +107 -0
  114. package/pipeline/contract/manifest.json +263 -0
  115. package/pipeline/contract/types/index.d.ts +343 -0
  116. package/pipeline/lib/_jira-auth.sh +6 -2
  117. package/pipeline/lib/account-resolver.sh +1 -1
  118. package/pipeline/lib/autopilot-state.sh +19 -0
  119. package/pipeline/lib/context-link-extractor.sh +12 -5
  120. package/pipeline/lib/credential-inventory.sh +12 -5
  121. package/pipeline/lib/credential-store.sh +116 -185
  122. package/pipeline/lib/fetch-confluence.sh +44 -3
  123. package/pipeline/lib/fetch-document.sh +3 -4
  124. package/pipeline/lib/fetch-fortify.sh +1 -1
  125. package/pipeline/lib/figma-mcp-refresh.sh +2 -2
  126. package/pipeline/lib/figma-token.sh +5 -1
  127. package/pipeline/lib/issue-fetcher.sh +233 -16
  128. package/pipeline/lib/json-file-lock.mjs +172 -0
  129. package/pipeline/lib/model-dispatch.sh +21 -12
  130. package/pipeline/lib/model-rung.sh +6 -1
  131. package/pipeline/lib/multi-repo-pipeline.sh +1 -1
  132. package/pipeline/lib/outbound-gate.mjs +46 -16
  133. package/pipeline/lib/parse-complaints.sh +14 -7
  134. package/pipeline/lib/plan-todos.sh +3 -3
  135. package/pipeline/lib/post-pr-review.sh +9 -9
  136. package/pipeline/lib/pr-request-location.mjs +85 -0
  137. package/pipeline/lib/regular-file.mjs +153 -0
  138. package/pipeline/lib/repo-hygiene.sh +17 -0
  139. package/pipeline/lib/route-state.sh +5 -1
  140. package/pipeline/lib/run-paths.sh +3 -2
  141. package/pipeline/lib/stack-detect.sh +19 -1
  142. package/pipeline/lib/unattended-profile-check.mjs +178 -0
  143. package/pipeline/lib/unattended-settings-location.mjs +28 -0
  144. package/pipeline/lib/unattended.mjs +76 -0
  145. package/pipeline/lib/unattended.sh +32 -0
  146. package/pipeline/lib/untrusted.mjs +76 -0
  147. package/pipeline/lib/user-facing.mjs +82 -0
  148. package/pipeline/lib/user-facing.sh +58 -0
  149. package/pipeline/multi-agent-refs/_dev-context.md +1 -1
  150. package/pipeline/multi-agent-refs/analysis/evidence.md +4 -0
  151. package/pipeline/multi-agent-refs/analysis/locked.md +2 -2
  152. package/pipeline/multi-agent-refs/analysis/render.md +4 -3
  153. package/pipeline/multi-agent-refs/analysis/resolve.md +3 -1
  154. package/pipeline/multi-agent-refs/analysis/synthesis.md +4 -5
  155. package/pipeline/multi-agent-refs/analysis-template.md +10 -17
  156. package/pipeline/multi-agent-refs/channels/jira.md +1 -1
  157. package/pipeline/multi-agent-refs/component-dispatch.md +3 -3
  158. package/pipeline/multi-agent-refs/conventions-defaults.md +32 -32
  159. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -17
  160. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +37 -9
  161. package/pipeline/multi-agent-refs/features/autopilot-operations.md +277 -0
  162. package/pipeline/multi-agent-refs/features/constitution.md +196 -0
  163. package/pipeline/multi-agent-refs/features/doctor.md +6 -3
  164. package/pipeline/multi-agent-refs/features/external-context-injection.md +6 -1
  165. package/pipeline/multi-agent-refs/features/jira-context.md +1 -1
  166. package/pipeline/multi-agent-refs/features/maturity-followup.md +43 -23
  167. package/pipeline/multi-agent-refs/features/model-fallback.md +9 -0
  168. package/pipeline/multi-agent-refs/features/phone-api.md +306 -0
  169. package/pipeline/multi-agent-refs/features/plan-critic.md +159 -0
  170. package/pipeline/multi-agent-refs/features/research.md +150 -0
  171. package/pipeline/multi-agent-refs/features/review-decision.md +185 -0
  172. package/pipeline/multi-agent-refs/features/scaffold.md +160 -0
  173. package/pipeline/multi-agent-refs/features/security-audit.md +6 -2
  174. package/pipeline/multi-agent-refs/features/skill-conformance.md +4 -1
  175. package/pipeline/multi-agent-refs/features/stack-adapters.md +116 -0
  176. package/pipeline/multi-agent-refs/features/unattended-gates.md +316 -0
  177. package/pipeline/multi-agent-refs/features/unattended-security.md +731 -0
  178. package/pipeline/multi-agent-refs/features/url-enrichment.md +2 -2
  179. package/pipeline/multi-agent-refs/features/usage-reporting.md +127 -31
  180. package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
  181. package/pipeline/multi-agent-refs/features/visual-evidence.md +7 -7
  182. package/pipeline/multi-agent-refs/keychain.md +6 -11
  183. package/pipeline/multi-agent-refs/payload-contracts.md +2 -2
  184. package/pipeline/multi-agent-refs/phases/log-format.md +2 -3
  185. package/pipeline/multi-agent-refs/phases/modes.md +10 -12
  186. package/pipeline/multi-agent-refs/phases/operations.md +11 -5
  187. package/pipeline/multi-agent-refs/phases/phase-0-init.md +73 -81
  188. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +22 -22
  189. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +42 -66
  190. package/pipeline/multi-agent-refs/phases/phase-3-review.md +45 -79
  191. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +28 -38
  192. package/pipeline/multi-agent-refs/phases/phase-5-report.md +27 -25
  193. package/pipeline/multi-agent-refs/phases.md +1 -1
  194. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  195. package/pipeline/multi-agent-refs/progress-contract.md +13 -16
  196. package/pipeline/multi-agent-refs/readiness-review.md +1 -1
  197. package/pipeline/multi-agent-refs/research/engine.md +91 -0
  198. package/pipeline/multi-agent-refs/rules.md +6 -4
  199. package/pipeline/multi-agent-refs/setup/repo-discovery.md +2 -2
  200. package/pipeline/multi-agent-refs/tracker-contract.md +2 -2
  201. package/pipeline/multi-agent-refs/unattended-contract.md +68 -16
  202. package/pipeline/rules/figma-pipeline.md +12 -12
  203. package/pipeline/schemas/agent-state.schema.json +480 -18
  204. package/pipeline/schemas/analysis-spec.schema.json +4 -4
  205. package/pipeline/schemas/answer-request.schema.json +24 -0
  206. package/pipeline/schemas/answer-result.schema.json +28 -0
  207. package/pipeline/schemas/autopilot-config.schema.json +111 -13
  208. package/pipeline/schemas/command-parameters.schema.json +99 -0
  209. package/pipeline/schemas/constitution.schema.json +56 -0
  210. package/pipeline/schemas/contract-error.schema.json +52 -0
  211. package/pipeline/schemas/design-check-config.schema.json +5 -1
  212. package/pipeline/schemas/issues.schema.json +61 -0
  213. package/pipeline/schemas/launch-plan.schema.json +46 -0
  214. package/pipeline/schemas/launch-request.schema.json +45 -0
  215. package/pipeline/schemas/launch.json +61 -0
  216. package/pipeline/schemas/launch.schema.json +84 -0
  217. package/pipeline/schemas/phases.json +2 -2
  218. package/pipeline/schemas/phases.schema.json +68 -0
  219. package/pipeline/schemas/phone-devices.schema.json +61 -0
  220. package/pipeline/schemas/phone-signed-request.schema.json +67 -0
  221. package/pipeline/schemas/plan-critique.schema.json +99 -0
  222. package/pipeline/schemas/plan-todos.schema.json +7 -7
  223. package/pipeline/schemas/planning-output.schema.json +5 -0
  224. package/pipeline/schemas/pr-request.schema.json +46 -0
  225. package/pipeline/schemas/prefs.schema.json +82 -7
  226. package/pipeline/schemas/research-output.schema.json +118 -0
  227. package/pipeline/schemas/review-file-exclusions.schema.json +25 -0
  228. package/pipeline/schemas/reviewer-output.schema.json +40 -4
  229. package/pipeline/schemas/run-questions.json +392 -0
  230. package/pipeline/schemas/run-questions.schema.json +118 -0
  231. package/pipeline/schemas/runs-index.schema.json +189 -0
  232. package/pipeline/schemas/scaffold-manifest.schema.json +43 -0
  233. package/pipeline/schemas/secret-patterns.schema.json +28 -0
  234. package/pipeline/schemas/stack-adapters.json +527 -0
  235. package/pipeline/schemas/stack-adapters.schema.json +184 -0
  236. package/pipeline/schemas/token-budget.json +1 -1
  237. package/pipeline/schemas/token-budget.schema.json +26 -0
  238. package/pipeline/schemas/triage-output.schema.json +64 -3
  239. package/pipeline/schemas/unattended-policy.json +139 -0
  240. package/pipeline/schemas/unattended-policy.schema.json +73 -0
  241. package/pipeline/schemas/unattended-profile.json +248 -0
  242. package/pipeline/schemas/unattended-profile.schema.json +198 -0
  243. package/pipeline/schemas/worktrees.schema.json +51 -0
  244. package/pipeline/scripts/README.md +1 -0
  245. package/pipeline/scripts/_autopilot-config.mjs +130 -0
  246. package/pipeline/scripts/_autopilot-ops.mjs +567 -0
  247. package/pipeline/scripts/_autopilot-outcomes.mjs +174 -0
  248. package/pipeline/scripts/_command-contract.mjs +384 -0
  249. package/pipeline/scripts/_cost.mjs +40 -0
  250. package/pipeline/scripts/_notices.mjs +160 -0
  251. package/pipeline/scripts/_phone-auth.mjs +485 -0
  252. package/pipeline/scripts/_pre-existing.mjs +294 -0
  253. package/pipeline/scripts/_redact.mjs +77 -0
  254. package/pipeline/scripts/_run-paths.mjs +4 -2
  255. package/pipeline/scripts/_stack-adapter.mjs +678 -0
  256. package/pipeline/scripts/_stack-routing.mjs +1 -1
  257. package/pipeline/scripts/agent-guard.py +348 -37
  258. package/pipeline/scripts/agent-guard.sh +41 -13
  259. package/pipeline/scripts/analysis-story-tree.mjs +79 -3
  260. package/pipeline/scripts/answer-question.mjs +181 -0
  261. package/pipeline/scripts/audit-log-rotate.sh +1 -4
  262. package/pipeline/scripts/audit-log.sh +4 -4
  263. package/pipeline/scripts/autopilot-arming.mjs +389 -21
  264. package/pipeline/scripts/autopilot-awake.mjs +255 -0
  265. package/pipeline/scripts/autopilot-intake.mjs +137 -36
  266. package/pipeline/scripts/autopilot-menubar.swift +156 -44
  267. package/pipeline/scripts/autopilot-publish.mjs +1625 -0
  268. package/pipeline/scripts/autopilot-runner.mjs +1678 -222
  269. package/pipeline/scripts/autopilot-status.sh +198 -33
  270. package/pipeline/scripts/build-lock.sh +120 -0
  271. package/pipeline/scripts/build-references.mjs +4 -1
  272. package/pipeline/scripts/build-stack-plugins.mjs +59 -22
  273. package/pipeline/scripts/capture-flush.sh +1 -1
  274. package/pipeline/scripts/capture-resume.sh +13 -9
  275. package/pipeline/scripts/check-derived-drift.mjs +52 -11
  276. package/pipeline/scripts/commands.mjs +88 -0
  277. package/pipeline/scripts/constitution.mjs +362 -0
  278. package/pipeline/scripts/contract-server.mjs +776 -0
  279. package/pipeline/scripts/cost-analyze.mjs +89 -39
  280. package/pipeline/scripts/diff-explain.mjs +12 -1
  281. package/pipeline/scripts/doctor.mjs +77 -28
  282. package/pipeline/scripts/evidence-gate.mjs +192 -12
  283. package/pipeline/scripts/feedback-send.mjs +4 -2
  284. package/pipeline/scripts/gate-ledger.mjs +449 -0
  285. package/pipeline/scripts/gc-abandoned.sh +132 -13
  286. package/pipeline/scripts/gen-facts.mjs +31 -15
  287. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  288. package/pipeline/scripts/github-ssh-setup.sh +140 -29
  289. package/pipeline/scripts/graph-mermaid.mjs +4 -1
  290. package/pipeline/scripts/issues.mjs +236 -0
  291. package/pipeline/scripts/jira-attach.sh +6 -2
  292. package/pipeline/scripts/jira-search.sh +4 -3
  293. package/pipeline/scripts/keychain-save.sh +125 -24
  294. package/pipeline/scripts/keychain.py +63 -93
  295. package/pipeline/scripts/launch-request.mjs +747 -0
  296. package/pipeline/scripts/localize-commands.mjs +4 -10
  297. package/pipeline/scripts/log-metric.sh +6 -5
  298. package/pipeline/scripts/maturity-followup.mjs +13 -4
  299. package/pipeline/scripts/memory-save.sh +25 -0
  300. package/pipeline/scripts/migrate-prefs.mjs +4 -3
  301. package/pipeline/scripts/open-questions-gate.mjs +276 -0
  302. package/pipeline/scripts/phase-tracker.sh +41 -27
  303. package/pipeline/scripts/phase0-exit-gate.mjs +22 -4
  304. package/pipeline/scripts/phone-devices.mjs +224 -0
  305. package/pipeline/scripts/plan-coverage-gate.mjs +200 -66
  306. package/pipeline/scripts/plan-critique-gate.mjs +591 -0
  307. package/pipeline/scripts/pr-request.mjs +188 -0
  308. package/pipeline/scripts/pre-commit-check.sh +115 -4
  309. package/pipeline/scripts/probe-evidence-capability.sh +44 -5
  310. package/pipeline/scripts/record-phase.mjs +71 -0
  311. package/pipeline/scripts/render-agent-log-cost.sh +17 -2
  312. package/pipeline/scripts/render-cost-summary.sh +1 -1
  313. package/pipeline/scripts/render-work-summary.sh +1 -1
  314. package/pipeline/scripts/require-supported-version.sh +4 -1
  315. package/pipeline/scripts/research-gate.mjs +704 -0
  316. package/pipeline/scripts/review-decision-gate.mjs +403 -0
  317. package/pipeline/scripts/routine-registry.mjs +5 -2
  318. package/pipeline/scripts/runs-index.mjs +135 -27
  319. package/pipeline/scripts/scaffold-gate.mjs +393 -0
  320. package/pipeline/scripts/skill-conformance.mjs +25 -8
  321. package/pipeline/scripts/skill-siblings.mjs +2 -1
  322. package/pipeline/scripts/smoke-cross-cli-behavior.sh +32 -6
  323. package/pipeline/scripts/smoke-schema-validation.sh +6 -2
  324. package/pipeline/scripts/spec-consistency-gate.mjs +469 -0
  325. package/pipeline/scripts/symbol-existence-gate.mjs +450 -0
  326. package/pipeline/scripts/test-gap-scan.mjs +40 -2
  327. package/pipeline/scripts/test-integrity-gate.mjs +20 -4
  328. package/pipeline/scripts/test-strength.mjs +484 -0
  329. package/pipeline/scripts/test-summary.mjs +651 -0
  330. package/pipeline/scripts/triage-memory.mjs +49 -9
  331. package/pipeline/scripts/unattended_policy.py +2786 -0
  332. package/pipeline/scripts/uninstall.mjs +10 -10
  333. package/pipeline/scripts/update-issue-progress.sh +1 -1
  334. package/pipeline/scripts/usage-identity.mjs +288 -0
  335. package/pipeline/scripts/usage-register.mjs +185 -63
  336. package/pipeline/scripts/usage-report.mjs +230 -66
  337. package/pipeline/scripts/validate-complaint-doc.mjs +28 -10
  338. package/pipeline/scripts/validate-planning.mjs +6 -0
  339. package/pipeline/scripts/verify-citations.mjs +151 -38
  340. package/pipeline/scripts/verify.mjs +58 -18
  341. package/pipeline/scripts/worktree-prepare.sh +126 -0
  342. package/pipeline/scripts/worktrees.mjs +124 -0
  343. package/pipeline/scripts/write-state.mjs +48 -17
  344. package/pipeline/skills/.skill-manifest.json +222 -226
  345. package/pipeline/skills/.skills-index.json +77 -88
  346. package/pipeline/skills/shared/README.md +44 -45
  347. package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +3 -2
  348. package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +3 -2
  349. package/pipeline/skills/shared/core/multi-agent/SKILL.md +10 -9
  350. package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -5
  351. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -3
  352. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +2 -1
  353. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -3
  354. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +32 -6
  355. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +66 -8
  356. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +27 -9
  357. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +2 -1
  358. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -4
  359. package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +3 -2
  360. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +2 -1
  361. package/pipeline/skills/shared/core/multi-agent-design-check/SKILL.md +7 -5
  362. package/pipeline/skills/shared/core/multi-agent-diff-explain/SKILL.md +2 -1
  363. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +3 -2
  364. package/pipeline/skills/shared/core/multi-agent-feedback/SKILL.md +2 -1
  365. package/pipeline/skills/shared/core/multi-agent-forget/SKILL.md +2 -1
  366. package/pipeline/skills/shared/core/multi-agent-garbage-collect/SKILL.md +2 -1
  367. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +2 -1
  368. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -1
  369. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -1
  370. package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +3 -2
  371. package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +2 -1
  372. package/pipeline/skills/shared/core/multi-agent-kill/SKILL.md +7 -4
  373. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +2 -1
  374. package/pipeline/skills/shared/core/multi-agent-log/SKILL.md +2 -1
  375. package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +2 -1
  376. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -1
  377. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +2 -1
  378. package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +2 -1
  379. package/pipeline/skills/shared/core/multi-agent-purge/SKILL.md +2 -1
  380. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +2 -1
  381. package/pipeline/skills/shared/core/multi-agent-research/SKILL.md +35 -0
  382. package/pipeline/skills/shared/core/multi-agent-resume/SKILL.md +7 -3
  383. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +7 -5
  384. package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +2 -1
  385. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +3 -2
  386. package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +2 -1
  387. package/pipeline/skills/shared/core/multi-agent-route-off/SKILL.md +2 -1
  388. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +2 -1
  389. package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +2 -1
  390. package/pipeline/skills/shared/core/multi-agent-routines/SKILL.md +2 -1
  391. package/pipeline/skills/shared/core/multi-agent-save/SKILL.md +3 -2
  392. package/pipeline/skills/shared/core/multi-agent-scaffold/SKILL.md +30 -0
  393. package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -1
  394. package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +4 -3
  395. package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +2 -1
  396. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +60 -0
  397. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +8 -8
  398. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +2 -1
  399. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +7 -8
  400. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +2 -1
  401. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +2 -1
  402. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +10 -10
  403. package/pipeline/skills/shared/core/multi-agent-test/SKILL.md +2 -1
  404. package/pipeline/skills/shared/core/multi-agent-test-accessibility/SKILL.md +2 -1
  405. package/pipeline/skills/shared/core/multi-agent-test-dark-mode/SKILL.md +2 -1
  406. package/pipeline/skills/shared/core/multi-agent-test-dynamic-type/SKILL.md +2 -1
  407. package/pipeline/skills/shared/core/multi-agent-test-screenshots/SKILL.md +2 -1
  408. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -1
  409. package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +3 -2
  410. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +2 -1
  411. package/pipeline/skills/shared/external/NOTICE-avdlee-swiftui-agent-skill.md +46 -0
  412. package/pipeline/skills/shared/external/NOTICE-dimillian-skills.md +9 -3
  413. package/pipeline/skills/shared/external/NOTICE-paul-hudson-skills.md +54 -0
  414. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +9 -11
  415. package/pipeline/skills/shared/external/NOTICE-vibeship-spawner-skills.md +203 -0
  416. package/pipeline/skills/shared/external/NOTICE-xcode-build-skills.md +10 -3
  417. package/pipeline/skills/shared/external/accessibility-compliance-accessibility-audit/SKILL.md +131 -25
  418. package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +4 -3
  419. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -1
  420. package/pipeline/skills/shared/external/android-architecture/SKILL.md +85 -76
  421. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +51 -3
  422. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +9 -0
  423. package/pipeline/skills/shared/external/android-datastore/SKILL.md +4 -3
  424. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +4 -3
  425. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +106 -181
  426. package/pipeline/skills/shared/external/android-jetpack-compose-expert/references/patterns.md +147 -0
  427. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +45 -4
  428. package/pipeline/skills/shared/external/android-performance/SKILL.md +27 -2
  429. package/pipeline/skills/shared/external/android-performance/references/patterns.md +37 -37
  430. package/pipeline/skills/shared/external/api-patterns/SKILL.md +111 -67
  431. package/pipeline/skills/shared/external/api-patterns/references/contract-details.md +128 -0
  432. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +136 -186
  433. package/pipeline/skills/shared/external/api-security-best-practices/references/abuse-controls.md +140 -0
  434. package/pipeline/skills/shared/external/api-security-best-practices/references/identity-and-access.md +207 -0
  435. package/pipeline/skills/shared/external/api-security-best-practices/references/operations.md +66 -0
  436. package/pipeline/skills/shared/external/api-security-best-practices/references/request-handling.md +175 -0
  437. package/pipeline/skills/shared/external/app-clips/SKILL.md +2 -0
  438. package/pipeline/skills/shared/external/app-intents/SKILL.md +2 -0
  439. package/pipeline/skills/shared/external/app-store-changelog/SKILL.md +4 -3
  440. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +2 -0
  441. package/pipeline/skills/shared/external/app-store-review/SKILL.md +2 -0
  442. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +2 -0
  443. package/pipeline/skills/shared/external/architecture/SKILL.md +106 -39
  444. package/pipeline/skills/shared/external/architecture/references/adr-and-review.md +76 -0
  445. package/pipeline/skills/shared/external/authentication/SKILL.md +2 -0
  446. package/pipeline/skills/shared/external/avkit/SKILL.md +2 -0
  447. package/pipeline/skills/shared/external/background-processing/SKILL.md +2 -1
  448. package/pipeline/skills/shared/external/backlog/SKILL.md +3 -2
  449. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +2 -0
  450. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +1 -1
  451. package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +3 -2
  452. package/pipeline/skills/shared/external/clean-code/SKILL.md +192 -90
  453. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +2 -1
  454. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +1 -1
  455. package/pipeline/skills/shared/external/compose-components/SKILL.md +4 -4
  456. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +22 -22
  457. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +10 -10
  458. package/pipeline/skills/shared/external/compose-testing/SKILL.md +13 -6
  459. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +59 -59
  460. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +2 -0
  461. package/pipeline/skills/shared/external/context-compression/SKILL.md +118 -250
  462. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +2 -0
  463. package/pipeline/skills/shared/external/core-data/SKILL.md +2 -0
  464. package/pipeline/skills/shared/external/core-motion/SKILL.md +2 -0
  465. package/pipeline/skills/shared/external/core-nfc/SKILL.md +2 -0
  466. package/pipeline/skills/shared/external/coreml/SKILL.md +2 -1
  467. package/pipeline/skills/shared/external/council/SKILL.md +2 -1
  468. package/pipeline/skills/shared/external/cryptokit/SKILL.md +2 -0
  469. package/pipeline/skills/shared/external/css-modern/SKILL.md +3 -2
  470. package/pipeline/skills/shared/external/database-patterns/SKILL.md +3 -2
  471. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +2 -0
  472. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +130 -20
  473. package/pipeline/skills/shared/external/debugging-strategies/references/hard-cases.md +54 -0
  474. package/pipeline/skills/shared/external/device-integrity/SKILL.md +2 -0
  475. package/pipeline/skills/shared/external/docker-expert/SKILL.md +137 -381
  476. package/pipeline/skills/shared/external/docker-expert/references/patterns.md +98 -0
  477. package/pipeline/skills/shared/external/energykit/SKILL.md +2 -1
  478. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +3 -1
  479. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +2 -2
  480. package/pipeline/skills/shared/external/fastapi-pro/SKILL.md +116 -185
  481. package/pipeline/skills/shared/external/fastapi-pro/references/app-structure.md +235 -0
  482. package/pipeline/skills/shared/external/fastapi-pro/references/testing-and-deployment.md +69 -0
  483. package/pipeline/skills/shared/external/firebase/SKILL.md +4 -3
  484. package/pipeline/skills/shared/external/github-actions-templates/SKILL.md +151 -293
  485. package/pipeline/skills/shared/external/github-actions-templates/references/patterns.md +96 -0
  486. package/pipeline/skills/shared/external/healthkit/SKILL.md +2 -0
  487. package/pipeline/skills/shared/external/hig-components-content/SKILL.md +69 -72
  488. package/pipeline/skills/shared/external/hig-components-content/references/content-views.md +111 -0
  489. package/pipeline/skills/shared/external/hig-components-layout/SKILL.md +77 -86
  490. package/pipeline/skills/shared/external/hig-components-layout/references/containers.md +98 -0
  491. package/pipeline/skills/shared/external/hig-components-status/SKILL.md +150 -78
  492. package/pipeline/skills/shared/external/hig-components-system/SKILL.md +76 -97
  493. package/pipeline/skills/shared/external/hig-components-system/references/surfaces.md +98 -0
  494. package/pipeline/skills/shared/external/hig-foundations/SKILL.md +48 -76
  495. package/pipeline/skills/shared/external/hig-foundations/references/foundations-detail.md +132 -0
  496. package/pipeline/skills/shared/external/hig-inputs/SKILL.md +147 -106
  497. package/pipeline/skills/shared/external/hig-patterns/SKILL.md +61 -77
  498. package/pipeline/skills/shared/external/hig-patterns/references/patterns.md +92 -0
  499. package/pipeline/skills/shared/external/hig-platforms/SKILL.md +147 -77
  500. package/pipeline/skills/shared/external/hig-technologies/SKILL.md +61 -121
  501. package/pipeline/skills/shared/external/hig-technologies/references/technologies.md +108 -0
  502. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +2 -0
  503. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +1 -1
  504. package/pipeline/skills/shared/external/html-semantic/SKILL.md +3 -2
  505. package/pipeline/skills/shared/external/humanizer/SKILL.md +2 -1
  506. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +2 -0
  507. package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +2 -1
  508. package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +14 -2
  509. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  510. package/pipeline/skills/shared/external/ios-debugger-agent/SKILL.md +4 -3
  511. package/pipeline/skills/shared/external/ios-localization/SKILL.md +8 -6
  512. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +2 -2
  513. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +11 -11
  514. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +2 -1
  515. package/pipeline/skills/shared/external/ios-networking/SKILL.md +2 -0
  516. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +2 -0
  517. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +102 -193
  518. package/pipeline/skills/shared/external/kotlin-coroutines-expert/references/patterns.md +103 -0
  519. package/pipeline/skills/shared/external/live-activities/SKILL.md +4 -2
  520. package/pipeline/skills/shared/external/live-activities/evals/evals.json +2 -2
  521. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +7 -9
  522. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +3 -3
  523. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +2 -2
  524. package/pipeline/skills/shared/external/macos-menubar-tuist-app/SKILL.md +4 -3
  525. package/pipeline/skills/shared/external/macos-spm-app-packaging/SKILL.md +5 -3
  526. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +2 -0
  527. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +1 -1
  528. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +2 -0
  529. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +1 -1
  530. package/pipeline/skills/shared/external/monorepo-architect/SKILL.md +149 -46
  531. package/pipeline/skills/shared/external/monorepo-architect/references/patterns.md +70 -0
  532. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +2 -0
  533. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +1 -1
  534. package/pipeline/skills/shared/external/natural-language/SKILL.md +2 -0
  535. package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +3 -2
  536. package/pipeline/skills/shared/external/nodejs-backend-patterns/SKILL.md +91 -21
  537. package/pipeline/skills/shared/external/nodejs-backend-patterns/references/runtime-patterns.md +247 -0
  538. package/pipeline/skills/shared/external/nodejs-backend-patterns/references/testing-and-frameworks.md +57 -0
  539. package/pipeline/skills/shared/external/observability-engineer/SKILL.md +155 -232
  540. package/pipeline/skills/shared/external/observability-engineer/references/patterns.md +75 -0
  541. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +2 -0
  542. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +2 -2
  543. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +18 -18
  544. package/pipeline/skills/shared/external/pdfkit/SKILL.md +2 -1
  545. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -0
  546. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  547. package/pipeline/skills/shared/external/permissionkit/SKILL.md +2 -0
  548. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +2 -1
  549. package/pipeline/skills/shared/external/push-notifications/SKILL.md +2 -0
  550. package/pipeline/skills/shared/external/python-patterns/SKILL.md +3 -2
  551. package/pipeline/skills/shared/external/react-best-practices/SKILL.md +3 -2
  552. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +2 -2
  553. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  554. package/pipeline/skills/shared/external/rest-api-design/SKILL.md +3 -2
  555. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +7 -7
  556. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +69 -69
  557. package/pipeline/skills/shared/external/room-database/references/patterns.md +126 -126
  558. package/pipeline/skills/shared/external/search-first/SKILL.md +2 -1
  559. package/pipeline/skills/shared/external/security-review/SKILL.md +4 -3
  560. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +2 -2
  561. package/pipeline/skills/shared/external/signal-community/SKILL.md +1 -1
  562. package/pipeline/skills/shared/external/skill-creator/SKILL.md +2 -1
  563. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +2 -1
  564. package/pipeline/skills/shared/external/spm-build-analysis/SKILL.md +2 -0
  565. package/pipeline/skills/shared/external/storekit/SKILL.md +2 -0
  566. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +2 -0
  567. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +2 -1
  568. package/pipeline/skills/shared/external/swift-charts/SKILL.md +2 -1
  569. package/pipeline/skills/shared/external/swift-codable/SKILL.md +2 -0
  570. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -1
  571. package/pipeline/skills/shared/external/swift-concurrency-expert/SKILL.md +5 -4
  572. package/pipeline/skills/shared/external/swift-concurrency-pro/SKILL.md +2 -1
  573. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +2 -0
  574. package/pipeline/skills/shared/external/swift-language/SKILL.md +3 -1
  575. package/pipeline/skills/shared/external/swift-security/SKILL.md +2 -1
  576. package/pipeline/skills/shared/external/swift-testing/SKILL.md +2 -0
  577. package/pipeline/skills/shared/external/swift-testing-pro/SKILL.md +2 -1
  578. package/pipeline/skills/shared/external/swift-testing-pro/references/new-features.md +10 -0
  579. package/pipeline/skills/shared/external/swiftdata/SKILL.md +2 -0
  580. package/pipeline/skills/shared/external/swiftdata-pro/SKILL.md +2 -1
  581. package/pipeline/skills/shared/external/swiftlint/SKILL.md +2 -0
  582. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +2 -0
  583. package/pipeline/skills/shared/external/swiftui-expert-skill/SKILL.md +3 -1
  584. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +2 -0
  585. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +2 -0
  586. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +2 -0
  587. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +2 -0
  588. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +3 -1
  589. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +3 -1
  590. package/pipeline/skills/shared/external/swiftui-performance-audit/SKILL.md +5 -4
  591. package/pipeline/skills/shared/external/swiftui-pro/SKILL.md +2 -1
  592. package/pipeline/skills/shared/external/swiftui-ui-patterns/SKILL.md +5 -4
  593. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +2 -0
  594. package/pipeline/skills/shared/external/swiftui-view-refactor/SKILL.md +4 -3
  595. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +2 -0
  596. package/pipeline/skills/shared/external/tailwind-css/SKILL.md +3 -2
  597. package/pipeline/skills/shared/external/testing-backend/SKILL.md +3 -2
  598. package/pipeline/skills/shared/external/tipkit/SKILL.md +2 -0
  599. package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +3 -2
  600. package/pipeline/skills/shared/external/vision-framework/SKILL.md +2 -0
  601. package/pipeline/skills/shared/external/vue-composition/SKILL.md +3 -2
  602. package/pipeline/skills/shared/external/weatherkit/SKILL.md +2 -0
  603. package/pipeline/skills/shared/external/web-accessibility/SKILL.md +3 -2
  604. package/pipeline/skills/shared/external/web-performance/SKILL.md +3 -2
  605. package/pipeline/skills/shared/external/web-testing/SKILL.md +3 -2
  606. package/pipeline/skills/shared/external/widgetkit/SKILL.md +2 -0
  607. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +1 -1
  608. package/pipeline/skills/shared/external/xcode-build-benchmark/SKILL.md +2 -0
  609. package/pipeline/skills/shared/external/xcode-build-fixer/SKILL.md +2 -0
  610. package/pipeline/skills/shared/external/xcode-build-orchestrator/SKILL.md +2 -0
  611. package/pipeline/skills/shared/external/xcode-compilation-analyzer/SKILL.md +2 -0
  612. package/pipeline/skills/shared/external/xcode-project-analyzer/SKILL.md +2 -0
  613. package/pipeline/skills/skills-index.md +40 -41
  614. package/docs/token-budget-history.md +0 -24
  615. package/pipeline/skills/shared/external/agentflow/SKILL.md +0 -199
  616. package/pipeline/skills/shared/external/android-ui-verification/SKILL.md +0 -66
  617. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +0 -299
  618. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +0 -255
  619. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +0 -167
  620. package/pipeline/skills/shared/external/closed-loop-delivery/SKILL.md +0 -116
  621. package/pipeline/skills/shared/external/ios-developer/SKILL.md +0 -216
@@ -0,0 +1,2786 @@
1
+ #!/usr/bin/env python3
2
+ """Unattended policy for agent-guard.py: what a run nobody is watching may not do.
3
+
4
+ Consulted only when MULTI_AGENT_UNATTENDED=1 is in the hook's own environment,
5
+ which the autopilot runner sets on the `claude` process it spawns. It is never
6
+ read from agent-state.json: the run writes that file, and a rule the run can
7
+ switch off is not a rule. With the variable unset this module is not imported.
8
+
9
+ The model is refuse-what-it-cannot-parse. A Bash command is accepted only when
10
+ it reduces to simple commands joined by `;`, `&&`, `||` and plain pipes between
11
+ non-interpreter commands. Anything that hides a command from a line reader -
12
+ a subshell, a group, a substitution, a heredoc, a background job, a shell
13
+ keyword, a pipe into an interpreter, a `-c` on a shell - is refused outright,
14
+ because the guard reads the command line and cannot judge what it cannot parse.
15
+ The command families it can parse (git, gh, curl, installs, writes) are then
16
+ checked against the lists in ../schemas/unattended-policy.json.
17
+
18
+ Every check returns None (allowed) or a short reason string (blocked). Nothing
19
+ here executes the inspected command; the only processes it starts are read-only
20
+ `git` reads, memoized per invocation. Contract:
21
+ multi-agent-refs/features/unattended-security.md.
22
+ """
23
+ import json
24
+ import os
25
+ import re
26
+ import shlex
27
+ import stat
28
+ from urllib.parse import urlsplit
29
+
30
+ HERE = os.path.dirname(os.path.abspath(__file__))
31
+ POLICY_CANDIDATES = [
32
+ os.path.join(HERE, "..", "schemas", "unattended-policy.json"),
33
+ os.path.join(HERE, "..", "pipeline", "schemas", "unattended-policy.json"),
34
+ ]
35
+ PR_REQUESTS_SUBDIR = "pr-requests"
36
+ PR_VERDICT_SUFFIX = ".verdict.json"
37
+ UNATTENDED_RUN_DIR = ".multi-agent-unattended"
38
+
39
+ DARWIN = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False
40
+ MAX_SEGMENTS = 50
41
+
42
+ # --- shell structure the guard refuses to reason about -----------------------
43
+
44
+ # Command position tokens that are not simple commands.
45
+ SHELL_KEYWORDS = {
46
+ "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done",
47
+ "case", "esac", "function", "select", "eval", "exec", "source", ".",
48
+ "export", "trap", "set", "unset", "alias", "unalias", "declare", "typeset",
49
+ "readonly", "let", "return", "coproc", "time",
50
+ }
51
+ SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
52
+ INTERPRETERS = SHELLS | {"node", "perl", "ruby", "osascript", "php", "lua", "tclsh", "wish", "rscript"}
53
+ # Wrappers that run the rest of the line as a command; their option values are
54
+ # consumed so the wrapped command is what gets judged.
55
+ WRAPPER_VALUE_OPTS = {
56
+ "env": {"-u", "-P", "--unset", "--block-signal", "--default-signal", "--ignore-signal"},
57
+ "nice": {"-n", "--adjustment"},
58
+ "timeout": {"-s", "--signal", "-k", "--kill-after"},
59
+ "gtimeout": {"-s", "--signal", "-k", "--kill-after"},
60
+ "ionice": {"-c", "-n", "-p"},
61
+ "nohup": set(),
62
+ "caffeinate": set(),
63
+ "command": set(),
64
+ "builtin": set(),
65
+ "stdbuf": {"-i", "-o", "-e"},
66
+ "xcrun": {"-sdk", "--sdk", "-toolchain", "--toolchain"},
67
+ "arch": {"-arch", "-d"},
68
+ "sandbox-exec": {"-f", "-n", "-p", "-D"},
69
+ }
70
+ # Wrappers whose command line the guard does not model: `script` takes an
71
+ # output file before the command, `watch` hands its arguments to `sh -c`, a
72
+ # debugger runs its target under its own control (and takes commands that spawn
73
+ # a shell), and a job runner (parallel, xargs) or a locker (flock) hands the
74
+ # rest to a shell or repeats it per input. `chroot` and `unbuffer` and the
75
+ # traces run their target too. Each is refused rather than parsed.
76
+ OPAQUE_WRAPPERS = {
77
+ "script", "watch",
78
+ "lldb", "gdb", "lldb-mi",
79
+ "parallel", "flock", "unbuffer", "chroot", "dtruss", "dtrace", "ktrace",
80
+ "strace", "ltrace", "rlwrap", "expect",
81
+ "automator", "shortcuts", "osacompile",
82
+ }
83
+ ENV_REFUSED_OPTS = ("-S", "--split-string", "-C", "--chdir")
84
+ XCRUN_LOOKUPS = ("-f", "--find", "--show-sdk-path", "--show-sdk-version", "--show-sdk-build-version",
85
+ "--show-sdk-platform-path", "--show-sdk-platform-version", "--version", "-version", "-h", "--help")
86
+
87
+
88
+ def _is_interpreter(base):
89
+ return base in INTERPRETERS or base.startswith("python")
90
+
91
+
92
+ def _casefold(s):
93
+ return s.casefold() if DARWIN else s
94
+
95
+
96
+ def _strip_quotes(cmd, keep_double):
97
+ """cmd with quoted spans removed. keep_double keeps the CONTENT of
98
+ double-quoted spans (where `$(` and backticks stay active) while dropping
99
+ single-quoted spans (inert). A backslash escapes the next char outside
100
+ single quotes."""
101
+ out = []
102
+ q = None
103
+ i = 0
104
+ n = len(cmd)
105
+ while i < n:
106
+ c = cmd[i]
107
+ if q is None:
108
+ if c == "\\" and i + 1 < n:
109
+ i += 2
110
+ continue
111
+ if c == "'":
112
+ q = "'"
113
+ elif c == '"':
114
+ q = '"'
115
+ else:
116
+ out.append(c)
117
+ elif q == "'":
118
+ if c == "'":
119
+ q = None
120
+ else: # double quote
121
+ if c == "\\" and i + 1 < n:
122
+ if keep_double:
123
+ out.append(cmd[i + 1])
124
+ i += 2
125
+ continue
126
+ if c == '"':
127
+ q = None
128
+ elif keep_double:
129
+ out.append(c)
130
+ i += 1
131
+ return "".join(out)
132
+
133
+
134
+ class Refused(Exception):
135
+ """The line uses shell syntax the guard does not analyse."""
136
+
137
+
138
+ PLACEHOLDER_RX = re.compile(r"__MA_SUBST_(\d+)__", re.I)
139
+
140
+
141
+ def _placeholder(k):
142
+ return f"__MA_SUBST_{k}__"
143
+
144
+
145
+ def _has_ph(s):
146
+ return bool(s) and bool(PLACEHOLDER_RX.search(s))
147
+
148
+
149
+ def _match_paren(cmd, start):
150
+ """Index of the `)` closing a `$(` whose body starts at `start`. Quote-aware
151
+ and balanced; a nested `$(` or an unterminated body is refused."""
152
+ depth = 1
153
+ q = None
154
+ i = start
155
+ n = len(cmd)
156
+ while i < n:
157
+ c = cmd[i]
158
+ if q == "'":
159
+ if c == "'":
160
+ q = None
161
+ i += 1
162
+ continue
163
+ if c == "\\" and i + 1 < n:
164
+ i += 2
165
+ continue
166
+ if q == '"':
167
+ if c == '"':
168
+ q = None
169
+ elif c == "$" and cmd.startswith("$(", i):
170
+ raise Refused("a command substitution nested inside another is not analysed")
171
+ elif c == "`":
172
+ raise Refused("a backtick substitution hides a command from the guard")
173
+ i += 1
174
+ continue
175
+ if c in ("'", '"'):
176
+ q = c
177
+ elif c == "$" and cmd.startswith("$(", i):
178
+ raise Refused("a command substitution nested inside another is not analysed")
179
+ elif c == "(":
180
+ depth += 1
181
+ elif c == ")":
182
+ depth -= 1
183
+ if depth == 0:
184
+ return i
185
+ i += 1
186
+ raise Refused("an unterminated command substitution cannot be parsed")
187
+
188
+
189
+ HEREDOC_TOKEN = "__MA_HEREDOC__"
190
+
191
+
192
+ def _heredoc_delim(cmd, i):
193
+ """At an unquoted `<<` (not `<<<`): (end, delimiter, dash, quoted)."""
194
+ j = i + 2
195
+ dash = j < len(cmd) and cmd[j] == "-"
196
+ if dash:
197
+ j += 1
198
+ while j < len(cmd) and cmd[j] in " \t":
199
+ j += 1
200
+ if j < len(cmd) and cmd[j] in ("'", '"'):
201
+ qc = cmd[j]
202
+ k = cmd.find(qc, j + 1)
203
+ if k < 0:
204
+ raise Refused("an unterminated heredoc delimiter cannot be parsed")
205
+ return k + 1, cmd[j + 1:k], dash, True
206
+ quoted = j < len(cmd) and cmd[j] == "\\"
207
+ if quoted:
208
+ j += 1
209
+ m = re.match(r"[A-Za-z0-9_.-]+", cmd[j:])
210
+ if not m:
211
+ raise Refused("a heredoc without a readable delimiter cannot be parsed")
212
+ return j + m.end(), m.group(0), dash, quoted
213
+
214
+
215
+ def strip_heredocs(cmd):
216
+ """cmd with every heredoc body removed and its `<<'DELIM'` replaced by an
217
+ input redirect from a placeholder. A body behind a quoted delimiter is
218
+ literal data. An unquoted delimiter expands `$(...)` inside the body, which
219
+ the guard does not analyse, so it is refused. Quote-aware, and `$(` opens a
220
+ fresh quoting context the way the shell does."""
221
+ out = []
222
+ i = 0
223
+ n = len(cmd)
224
+ q = None
225
+ stack = []
226
+ depth = 0
227
+ pending = []
228
+ while i < n:
229
+ c = cmd[i]
230
+ if q == "'":
231
+ out.append(c)
232
+ if c == "'":
233
+ q = None
234
+ i += 1
235
+ continue
236
+ if c == "\\" and i + 1 < n:
237
+ out.append(cmd[i:i + 2])
238
+ i += 2
239
+ continue
240
+ if q == '"':
241
+ if c == '"':
242
+ q = None
243
+ elif cmd.startswith("$(", i):
244
+ stack.append((q, depth))
245
+ q, depth = None, 0
246
+ out.append("$(")
247
+ i += 2
248
+ continue
249
+ out.append(c)
250
+ i += 1
251
+ continue
252
+ if c in ("'", '"'):
253
+ q = c
254
+ out.append(c)
255
+ i += 1
256
+ continue
257
+ if cmd.startswith("$(", i):
258
+ stack.append((None, depth))
259
+ depth = 0
260
+ out.append("$(")
261
+ i += 2
262
+ continue
263
+ if c == "(":
264
+ depth += 1
265
+ elif c == ")":
266
+ if depth == 0 and stack:
267
+ q, depth = stack.pop()
268
+ out.append(c)
269
+ i += 1
270
+ continue
271
+ depth -= 1
272
+ if cmd.startswith("<<", i) and not cmd.startswith("<<<", i) and not (i > 0 and cmd[i - 1] == "<"):
273
+ end, delim, dash, quoted = _heredoc_delim(cmd, i)
274
+ if not quoted:
275
+ raise Refused("a heredoc with an unquoted delimiter expands its body, which the guard does not analyse")
276
+ out.append(f"< {HEREDOC_TOKEN} ")
277
+ pending.append((delim, dash))
278
+ i = end
279
+ continue
280
+ if c == "\n" and pending:
281
+ out.append("\n")
282
+ i += 1
283
+ for delim, dash in pending:
284
+ while True:
285
+ if i >= n:
286
+ raise Refused("an unterminated heredoc cannot be parsed")
287
+ k = cmd.find("\n", i)
288
+ line = cmd[i:] if k < 0 else cmd[i:k]
289
+ i = n if k < 0 else k + 1
290
+ if (line.lstrip("\t") if dash else line) == delim:
291
+ break
292
+ pending = []
293
+ continue
294
+ out.append(c)
295
+ i += 1
296
+ if pending:
297
+ raise Refused("an unterminated heredoc cannot be parsed")
298
+ return "".join(out)
299
+
300
+
301
+ def extract_substitutions(cmd):
302
+ """(outer, inners): every top-level `$(...)` replaced by an indexed
303
+ placeholder, and the inner command texts. Single-quoted text is literal.
304
+ `$((` arithmetic and an unbalanced quote are refused."""
305
+ out = []
306
+ inners = []
307
+ q = None
308
+ i = 0
309
+ n = len(cmd)
310
+ while i < n:
311
+ c = cmd[i]
312
+ if q == "'":
313
+ out.append(c)
314
+ if c == "'":
315
+ q = None
316
+ i += 1
317
+ continue
318
+ if c == "\\" and i + 1 < n:
319
+ out.append(cmd[i:i + 2])
320
+ i += 2
321
+ continue
322
+ if c == '"':
323
+ q = None if q == '"' else '"'
324
+ out.append(c)
325
+ i += 1
326
+ continue
327
+ if c == "'" and q is None:
328
+ q = "'"
329
+ out.append(c)
330
+ i += 1
331
+ continue
332
+ if c == "$" and cmd.startswith("$((", i):
333
+ raise Refused("arithmetic expansion $((...)) is not analysed")
334
+ if c == "$" and cmd.startswith("$(", i):
335
+ j = _match_paren(cmd, i + 2)
336
+ inners.append(cmd[i + 2:j])
337
+ out.append(_placeholder(len(inners) - 1))
338
+ i = j + 1
339
+ continue
340
+ out.append(c)
341
+ i += 1
342
+ if q is not None:
343
+ raise Refused("an unbalanced quote leaves the command unparseable")
344
+ return "".join(out), inners
345
+
346
+
347
+ BACKGROUND = re.compile(r"(?<![&>0-9])&(?![&>])")
348
+ SUBST = re.compile(r"\$\(|`|<\(|>\(")
349
+ HEREDOC = re.compile(r"(?<!<)<<(?!<)")
350
+ GROUPING = re.compile(r"[(){}]")
351
+ OP_SPLIT = re.compile(r"\|\||&&|;|\n|\|")
352
+
353
+
354
+ def split_ops(cmd):
355
+ """The simple-command segments of a line, split on unquoted `;`, `&&`,
356
+ `||`, `|`, `|&` and newlines - the way the shell splits them, so an
357
+ operator inside a quoted sed, jq or awk program is not a separator."""
358
+ segs = []
359
+ cur = []
360
+ q = None
361
+ i = 0
362
+ n = len(cmd)
363
+ while i < n:
364
+ c = cmd[i]
365
+ if q == "'":
366
+ cur.append(c)
367
+ if c == "'":
368
+ q = None
369
+ i += 1
370
+ continue
371
+ if c == "\\" and i + 1 < n:
372
+ cur.append(cmd[i:i + 2])
373
+ i += 2
374
+ continue
375
+ if q == '"':
376
+ cur.append(c)
377
+ if c == '"':
378
+ q = None
379
+ i += 1
380
+ continue
381
+ if c in ("'", '"'):
382
+ q = c
383
+ cur.append(c)
384
+ i += 1
385
+ continue
386
+ two = cmd[i:i + 2]
387
+ if two in ("&&", "||", "|&"):
388
+ segs.append("".join(cur))
389
+ cur = []
390
+ i += 2
391
+ continue
392
+ if c == "|" and cur and cur[-1] == ">":
393
+ cur.append(c)
394
+ i += 1
395
+ continue
396
+ if c in (";", "|", "\n"):
397
+ segs.append("".join(cur))
398
+ cur = []
399
+ i += 1
400
+ continue
401
+ cur.append(c)
402
+ i += 1
403
+ segs.append("".join(cur))
404
+ return segs
405
+
406
+
407
+ WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
408
+
409
+
410
+ def redirect_targets(seg):
411
+ """Files a segment's redirections write, read with a quote-aware lexer so a
412
+ `>` inside quotes is text and a quoted target is still seen."""
413
+ try:
414
+ lex = shlex.shlex(seg, posix=True, punctuation_chars=True)
415
+ lex.whitespace_split = True
416
+ toks = list(lex)
417
+ except ValueError:
418
+ raise Refused("an unbalanced quote leaves the redirections unparseable")
419
+ out = []
420
+ for i, t in enumerate(toks):
421
+ if i + 1 >= len(toks):
422
+ continue
423
+ nxt = toks[i + 1]
424
+ if t in WRITE_REDIRECTS:
425
+ out.append(nxt)
426
+ elif t == ">&" and not (nxt.isdigit() or nxt == "-"):
427
+ out.append(nxt)
428
+ return out
429
+
430
+
431
+ SIMPLE_PARAM = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")
432
+
433
+
434
+ def structural_refusal(cmd):
435
+ """A reason the command cannot be reduced to simple commands, or None.
436
+ Called after heredoc bodies are stripped and `$(...)` extracted, so what is
437
+ left of `<<` is a here-string and any `$(` left is unanalysable."""
438
+ meta = SIMPLE_PARAM.sub("$P", _strip_quotes(cmd, keep_double=False))
439
+ subst = _strip_quotes(cmd, keep_double=True)
440
+ if SUBST.search(subst):
441
+ return "a backtick or process substitution hides a command from the guard"
442
+ if HEREDOC.search(meta):
443
+ return "a heredoc the guard could not strip feeds a command it cannot see"
444
+ if GROUPING.search(meta):
445
+ return "a subshell, brace group or non-trivial ${...} expansion hides a command from the guard"
446
+ if BACKGROUND.search(meta):
447
+ return "a background job (&) runs a command the guard cannot wait on"
448
+ segs = split_ops(cmd)
449
+ if len(segs) > MAX_SEGMENTS:
450
+ return f"the command has more than {MAX_SEGMENTS} segments and is refused unparsed"
451
+ return None
452
+
453
+
454
+ def tokenize(text):
455
+ try:
456
+ return shlex.split(text)
457
+ except ValueError:
458
+ return text.split()
459
+
460
+
461
+ ASSIGN_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
462
+
463
+
464
+ def _walk_wrappers(toks):
465
+ """(assignments, command, refusal) for a token list: the VAR=value
466
+ assignments that apply to the command (leading ones, those after `env`,
467
+ and `arch -e` values), the wrapped command with every wrapper and its
468
+ option values consumed, and a reason when a wrapper hides the command."""
469
+ assigns = []
470
+ i = 0
471
+ while i < len(toks):
472
+ t = toks[i]
473
+ if ASSIGN_NAME.match(t):
474
+ assigns.append(t)
475
+ i += 1
476
+ continue
477
+ base = os.path.basename(t).lower()
478
+ if base in OPAQUE_WRAPPERS:
479
+ return assigns, toks[i:], f"{base} runs the rest of the line in a way the guard does not model"
480
+ if base not in WRAPPER_VALUE_OPTS:
481
+ break
482
+ value_opts = WRAPPER_VALUE_OPTS[base]
483
+ i += 1
484
+ while i < len(toks) and toks[i].startswith("-"):
485
+ opt = toks[i]
486
+ i += 1
487
+ if base == "env" and any(opt == o or opt.startswith(o + ("=" if o.startswith("--") else "")) for o in ENV_REFUSED_OPTS):
488
+ return assigns, toks[i:], "env -S / -C re-splits the command or moves it to another directory"
489
+ if base == "arch" and opt == "-e":
490
+ if i < len(toks):
491
+ assigns.append(toks[i])
492
+ i += 1
493
+ continue
494
+ if base == "xcrun" and (opt in XCRUN_LOOKUPS or opt.startswith("--show-sdk")):
495
+ return assigns, [], None
496
+ if opt in value_opts and i < len(toks):
497
+ i += 1
498
+ # timeout takes a bare DURATION positional before the command.
499
+ if base in ("timeout", "gtimeout") and i < len(toks) and re.match(r"^[0-9]", toks[i]):
500
+ i += 1
501
+ return assigns, toks[i:], None
502
+
503
+
504
+ def _strip_wrappers(toks):
505
+ """Drop env assignments and wrappers that run the rest as a command,
506
+ consuming each wrapper's option VALUES so the wrapped command survives
507
+ intact. sudo, doas, xargs-with-a-command and shell keywords are NOT
508
+ stripped here; they are refused by simple_command_refusal."""
509
+ return _walk_wrappers(toks)[1]
510
+
511
+
512
+ REDIR_TOKEN = re.compile(r"^\d*(>>|>\||>&|>|<<<|<>|<&|<|&>>|&>)(.*)$")
513
+ INTERP_VALUE_OPTS = {"-r", "--require", "--import", "--loader", "--experimental-loader", "--input-type",
514
+ "-C", "--conditions", "-W", "-X", "-I", "-M"}
515
+ INTERP_INLINE = {"-e", "-E", "-p", "--eval", "--print"}
516
+
517
+
518
+ def _operands(args):
519
+ """args with redirection operators and their targets removed."""
520
+ out = []
521
+ i = 0
522
+ while i < len(args):
523
+ m = REDIR_TOKEN.match(args[i])
524
+ if m:
525
+ i += 1 if m.group(2) else 2
526
+ continue
527
+ out.append(args[i])
528
+ i += 1
529
+ return out
530
+
531
+
532
+ def _program_source(low, args):
533
+ """What an interpreter runs: "script" (a named file or module), "inline"
534
+ (code on the command line), or "stdin" (no program named, so it reads the
535
+ program from its input - a pipe, here-string or heredoc)."""
536
+ ops = _operands(args)
537
+ j = 0
538
+ while j < len(ops):
539
+ a = ops[j]
540
+ if a == "--":
541
+ return ("script", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
542
+ if a == "-":
543
+ return "stdin", ""
544
+ if a.startswith("-"):
545
+ if low.startswith("python") and a == "-m":
546
+ return ("module", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
547
+ if a in INTERP_INLINE:
548
+ return "inline", ""
549
+ if low in ("sh", "bash", "zsh", "dash", "ksh", "fish") and a == "-s":
550
+ return "stdin", ""
551
+ j += 2 if a in INTERP_VALUE_OPTS else 1
552
+ continue
553
+ return "script", a
554
+ return "stdin", ""
555
+
556
+
557
+ _TRACK_CACHE = {}
558
+
559
+
560
+ def _run_base_sha(env=None):
561
+ env = os.environ if env is None else env
562
+ sha = (env.get("MA_RUN_BASE_SHA") or "").strip()
563
+ return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None
564
+
565
+
566
+ def _worktree_top(d):
567
+ try:
568
+ import subprocess
569
+
570
+ r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
571
+ capture_output=True, text=True, timeout=3)
572
+ return os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
573
+ except Exception:
574
+ return None
575
+
576
+
577
+ def _tracked_clean(path, env=None, cwd=None):
578
+ """path is a regular file the run may run as a program: it is tracked by
579
+ git, unchanged in the working tree, AND present unchanged at the run's BASE
580
+ commit (MA_RUN_BASE_SHA). A file the run committed itself is clean against
581
+ HEAD but is not in the base, so it does not count. The path must sit inside
582
+ the RUN's worktree (the one cwd is in); a file in another clone that happens
583
+ to share the base commit does not count. Without a base sha the guard cannot
584
+ prove provenance, so it refuses."""
585
+ p = os.path.realpath(path)
586
+ key = (p, _run_base_sha(env) or "", os.path.realpath(cwd) if cwd else "")
587
+ if key in _TRACK_CACHE:
588
+ return _TRACK_CACHE[key]
589
+ ok = False
590
+ base = _run_base_sha(env)
591
+ run_top = _worktree_top(cwd) if cwd else None
592
+ if base and os.path.isfile(p):
593
+ d, name = os.path.split(p)
594
+ if run_top is not None and _worktree_top(d) != run_top:
595
+ _TRACK_CACHE[key] = False
596
+ return False
597
+ try:
598
+ import subprocess
599
+
600
+ def g(*a):
601
+ return subprocess.run(["git", "-C", d, *a], capture_output=True, text=True, timeout=3)
602
+
603
+ # The file's path relative to the worktree root, so the base lookup
604
+ # names it correctly and a file outside this worktree is rejected.
605
+ top = g("rev-parse", "--show-toplevel")
606
+ ls = g("ls-files", "--error-unmatch", "--", name)
607
+ st = g("status", "--porcelain", "--untracked-files=all", "--", name)
608
+ in_worktree = top.returncode == 0 and bool(top.stdout.strip())
609
+ rel = os.path.relpath(p, os.path.realpath(top.stdout.strip())) if in_worktree else None
610
+ at_base = (
611
+ g("cat-file", "-e", f"{base}:{rel}").returncode == 0
612
+ and g("diff", "--quiet", base, "--", name).returncode == 0
613
+ if rel and not rel.startswith("..")
614
+ else False
615
+ )
616
+ ok = (
617
+ in_worktree
618
+ and ls.returncode == 0
619
+ and st.returncode == 0
620
+ and not st.stdout.strip()
621
+ and at_base
622
+ )
623
+ except Exception:
624
+ ok = False
625
+ _TRACK_CACHE[key] = ok
626
+ return ok
627
+
628
+
629
+ GLOB_CHARS = "*?["
630
+
631
+
632
+ def _program_refusal(what, raw, cwd, variables, env):
633
+ """Reason `what` may not run the file `raw`, or None. A run may execute the
634
+ installed pipeline (~/.claude/scripts, ~/.claude/lib), a file it cannot
635
+ write, a binary npm linked into node_modules/.bin, or a file committed at
636
+ HEAD and left unmodified - never a file the run itself wrote or edited."""
637
+ e = expand(raw, variables or {}, env)
638
+ if e is None:
639
+ return f"{what} runs a file whose name is built at run time"
640
+ if any(ch in e for ch in GLOB_CHARS):
641
+ return f"{what} runs a file named by a glob pattern"
642
+ e = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), e))
643
+ if not os.path.isabs(e) and cwd is None:
644
+ return f"{what} runs a file relative to a directory the guard could not resolve"
645
+ literal = os.path.normpath(e if os.path.isabs(e) else os.path.join(cwd, e))
646
+ real = os.path.realpath(literal)
647
+ # A program under /dev (/dev/stdin, /dev/fd/N) or any non-regular file
648
+ # (a FIFO, a device) is the program the guard cannot see: its bytes are the
649
+ # command's own input, not a file on disk it can judge.
650
+ if literal == "/dev" or literal.startswith("/dev/") or real == "/dev" or real.startswith("/dev/"):
651
+ return f"{what} runs a program read from {raw[:40]}, which the guard cannot see"
652
+ try:
653
+ st = os.stat(real)
654
+ if not stat.S_ISREG(st.st_mode):
655
+ return f"{what} runs {raw[:40]}, which is not a regular file (a FIFO, device or socket)"
656
+ except OSError:
657
+ pass
658
+ for h in {home(), os.path.expanduser("~")}:
659
+ for sub in ("scripts", "lib"):
660
+ base = os.path.join(h, ".claude", sub)
661
+ if _under(literal, base) or _under(real, os.path.realpath(base)):
662
+ return None
663
+ if (os.sep + os.path.join("node_modules", ".bin") + os.sep) in literal:
664
+ return None
665
+ if os.path.exists(real) and not os.access(real, os.W_OK) and not os.access(os.path.dirname(real), os.W_OK):
666
+ return None
667
+ if _tracked_clean(real, env, cwd):
668
+ return None
669
+ return (f"{what} runs {raw[:60]}, which is neither an installed pipeline script nor a file "
670
+ "committed unmodified at the run's base commit")
671
+
672
+
673
+ # Interpreter options that put the program on the command line, by interpreter:
674
+ # (short letters that mean "inline code", short letters that take a value and
675
+ # end the cluster). python -c and sh -c are covered by the `-c` rule.
676
+ INLINE_SHORT = {
677
+ "node": ("ep", "r"),
678
+ "bun": ("ep", "r"),
679
+ "ruby": ("e", "IrCEFKTWx0"),
680
+ "perl": ("eE", "IMmixCdD"),
681
+ "lua": ("e", "l"),
682
+ "osascript": ("e", "l"),
683
+ "swift": ("e", ""),
684
+ "rscript": ("e", ""),
685
+ "php": ("rRBE", "cdzfF"),
686
+ }
687
+ INLINE_LONG = {"--eval", "--print"}
688
+
689
+
690
+ def _inline_flag(low, args):
691
+ if low not in INLINE_SHORT:
692
+ return None
693
+ inline, valued = INLINE_SHORT[low]
694
+ for a in args:
695
+ if a == "--":
696
+ return None
697
+ if a.split("=", 1)[0] in INLINE_LONG and low in ("node", "bun"):
698
+ return a
699
+ if a.startswith("-") and not a.startswith("--") and len(a) > 1:
700
+ for ch in a[1:]:
701
+ if ch in inline:
702
+ return a
703
+ if ch in valued:
704
+ break
705
+ elif not a.startswith("-"):
706
+ return None
707
+ return None
708
+
709
+
710
+ SCRIPT_EXT = re.compile(r"\.(m?js|cjs|ts|tsx|jsx|mts|cts)$")
711
+
712
+
713
+ def _module_refusal(what, name, cwd, variables, env):
714
+ """python -m NAME imports NAME from the cwd first: a module the run wrote
715
+ there is a file the run executes."""
716
+ if cwd is None:
717
+ return f"{what} imports a module relative to a directory the guard could not resolve"
718
+ first = name.split(".", 1)[0]
719
+ for cand in (first + ".py", first):
720
+ if os.path.exists(os.path.join(cwd, cand)):
721
+ return _program_refusal(what, cand, cwd, variables, env)
722
+ return None
723
+
724
+
725
+ NODE_PRELOAD_OPTS = ("-r", "--require", "--import", "--loader", "--experimental-loader")
726
+
727
+
728
+ def _node_preload_refusal(base, args, cwd, variables, env):
729
+ """node -r / --require / --import / --loader FILE runs FILE before the main
730
+ script; a file the run wrote there executes just as a script argument would.
731
+ The value is checked with _program_refusal."""
732
+ i = 0
733
+ while i < len(args):
734
+ a = args[i]
735
+ if a == "--":
736
+ break
737
+ val = None
738
+ for opt in NODE_PRELOAD_OPTS:
739
+ if a == opt and i + 1 < len(args):
740
+ val = args[i + 1]
741
+ i += 1
742
+ break
743
+ if a.startswith(opt + "="):
744
+ val = a.split("=", 1)[1]
745
+ break
746
+ if val is not None:
747
+ if "://" in val:
748
+ return f"{base} {a.split('=', 1)[0]} preloads a remote module"
749
+ r = _program_refusal(f"{base} {a.split('=', 1)[0]}", val, cwd, variables, env)
750
+ if r:
751
+ return r
752
+ i += 1
753
+ return None
754
+
755
+
756
+ def interpreter_refusal(stripped, cwd, variables=None, env=None):
757
+ """What an interpreter runs must be a file the run did not write: inline
758
+ code (-e, --eval, -p, php -r, deno eval) is refused, a script file is
759
+ checked with _program_refusal, and deno, bun and swift are judged by
760
+ their subcommand."""
761
+ base = os.path.basename(stripped[0])
762
+ low = base.lower()
763
+ args = stripped[1:]
764
+ flag = _inline_flag(low, args)
765
+ if flag:
766
+ return f"{base} {flag} runs an inline program the guard cannot see"
767
+ plains = [a for a in _operands(args) if not a.startswith("-")]
768
+ if low == "deno":
769
+ sub = plains[0] if plains else ""
770
+ if sub in ("eval", "repl"):
771
+ return f"deno {sub} runs an inline program the guard cannot see"
772
+ target = plains[1] if sub == "run" and len(plains) > 1 else (sub if SCRIPT_EXT.search(sub) or "/" in sub else "")
773
+ if target:
774
+ if "://" in target:
775
+ return "deno runs a remote module"
776
+ return _program_refusal(base, target, cwd, variables, env)
777
+ return None
778
+ if low == "bun":
779
+ sub = plains[0] if plains else ""
780
+ target = plains[1] if sub == "run" and len(plains) > 1 else ("" if sub == "run" else sub)
781
+ if target and (SCRIPT_EXT.search(target) or "/" in target):
782
+ return _program_refusal(base, target, cwd, variables, env)
783
+ return None
784
+ if low == "swift":
785
+ sub = plains[0] if plains else ""
786
+ if sub and (sub.endswith(".swift") or "/" in sub):
787
+ return _program_refusal(base, sub, cwd, variables, env)
788
+ return None
789
+ if low in ("node", "nodejs"):
790
+ r = _node_preload_refusal(base, args, cwd, variables, env)
791
+ if r:
792
+ return r
793
+ if not _is_interpreter(low):
794
+ return None
795
+ for a in args:
796
+ if a.startswith("-") and not a.startswith("--") and "c" in a[1:]:
797
+ return f"{base} -c runs an inline program the guard cannot see"
798
+ kind, script = _program_source(low, args)
799
+ if kind == "stdin":
800
+ return f"{base} with no script reads its program from stdin (a pipe, here-string or heredoc)"
801
+ if kind == "inline" and not low.startswith("python"):
802
+ return f"{base} runs an inline program the guard cannot see"
803
+ if _has_ph(script):
804
+ return f"{base} runs a script whose name is built at run time"
805
+ if kind == "module":
806
+ return _module_refusal(base, script, cwd, variables, env)
807
+ if kind == "script":
808
+ return _program_refusal(base, script, cwd, variables, env)
809
+ return None
810
+
811
+
812
+ AWKS = {"awk", "gawk", "mawk", "nawk"}
813
+ AWK_DANGER = re.compile(r"\bsystem\s*\(|(?<!\|)\|(?!\|)|\bgetline\b|\bprintf?\b[^;{}]*>|@load|@include")
814
+
815
+
816
+ def awk_refusal(stripped, cwd, variables=None, env=None):
817
+ """An awk program may not start commands (system, a pipe, getline) or
818
+ write files (print > file); a program file must be one the run did not
819
+ write."""
820
+ args = stripped[1:]
821
+ progs, files = [], []
822
+ i = 0
823
+ while i < len(args):
824
+ a = args[i]
825
+ if a == "--":
826
+ i += 1
827
+ break
828
+ if not a.startswith("-") or a == "-":
829
+ break
830
+ key = a.split("=", 1)[0]
831
+ if key in ("-i", "--include", "-l", "--load", "-E", "--exec"):
832
+ return f"awk {key} loads code the guard cannot see"
833
+ if a in ("-f", "--file", "-e", "--source") and i + 1 < len(args):
834
+ (files if a in ("-f", "--file") else progs).append(args[i + 1])
835
+ i += 2
836
+ continue
837
+ if key in ("--file", "--source") and "=" in a:
838
+ (files if key == "--file" else progs).append(a.split("=", 1)[1])
839
+ elif a.startswith("-f") and len(a) > 2:
840
+ files.append(a[2:])
841
+ elif a in ("-F", "-v") and i + 1 < len(args):
842
+ i += 1
843
+ i += 1
844
+ if not files and not progs and i < len(args):
845
+ progs.append(args[i])
846
+ for f in files:
847
+ r = _program_refusal("awk -f", f, cwd, variables, env)
848
+ if r:
849
+ return r
850
+ for p in progs:
851
+ if AWK_DANGER.search(p):
852
+ return "an awk program with system, a pipe, getline or an output redirect runs or writes what the guard cannot see"
853
+ return None
854
+
855
+
856
+ def _skip_sed_regex(s, i, delim):
857
+ n = len(s)
858
+ while i < n and s[i] != delim:
859
+ if s[i] == "\\":
860
+ i += 1
861
+ elif s[i] == "\n":
862
+ break
863
+ i += 1
864
+ return i + 1
865
+
866
+
867
+ def sed_program_refusal(script):
868
+ """A sed program that writes a file (w, W, the s///w flag) or runs a
869
+ command (e, the s///e flag). A light scanner: it walks addresses and
870
+ commands, skipping regex and replacement text."""
871
+ s = script
872
+ n = len(s)
873
+ i = 0
874
+ reason = "a sed program with a w or e command writes a file or runs a command the guard cannot see"
875
+ while i < n:
876
+ if s[i] in " \t\n;{}!":
877
+ i += 1
878
+ continue
879
+ for _ in range(2):
880
+ if i < n and s[i].isdigit():
881
+ while i < n and (s[i].isdigit() or s[i] == "~"):
882
+ i += 1
883
+ elif i < n and s[i] == "$":
884
+ i += 1
885
+ elif i < n and s[i] == "/":
886
+ i = _skip_sed_regex(s, i + 1, "/")
887
+ while i < n and s[i] in "IM":
888
+ i += 1
889
+ elif i < n - 1 and s[i] == "\\":
890
+ i = _skip_sed_regex(s, i + 2, s[i + 1])
891
+ else:
892
+ break
893
+ while i < n and s[i] in " \t":
894
+ i += 1
895
+ if i < n and s[i] == ",":
896
+ i += 1
897
+ while i < n and s[i] in " \t":
898
+ i += 1
899
+ continue
900
+ break
901
+ while i < n and s[i] in " \t!":
902
+ i += 1
903
+ if i >= n:
904
+ break
905
+ c = s[i]
906
+ if c in "wWe":
907
+ return reason
908
+ if c in "sy" and i + 1 < n:
909
+ d = s[i + 1]
910
+ i = _skip_sed_regex(s, i + 2, d)
911
+ i = _skip_sed_regex(s, i, d)
912
+ if c == "s":
913
+ j = i
914
+ while j < n and s[j] not in ";\n} \t":
915
+ j += 1
916
+ if "w" in s[i:j] or "e" in s[i:j]:
917
+ return reason
918
+ i = j
919
+ continue
920
+ if c in "aicrR":
921
+ k = s.find("\n", i)
922
+ i = n if k < 0 else k + 1
923
+ continue
924
+ if c in "btT:":
925
+ while i < n and s[i] not in ";\n":
926
+ i += 1
927
+ continue
928
+ i += 1
929
+ return None
930
+
931
+
932
+ def sed_refusal(stripped, cwd, variables=None, env=None):
933
+ args = stripped[1:]
934
+ progs, files, ops = [], [], []
935
+ i = 0
936
+ while i < len(args):
937
+ a = args[i]
938
+ if a in ("-e", "--expression") and i + 1 < len(args):
939
+ progs.append(args[i + 1])
940
+ i += 2
941
+ continue
942
+ if a.startswith("--expression="):
943
+ progs.append(a.split("=", 1)[1])
944
+ elif a.startswith("-e") and len(a) > 2 and not a.startswith("--"):
945
+ progs.append(a[2:])
946
+ elif a in ("-f", "--file") and i + 1 < len(args):
947
+ files.append(args[i + 1])
948
+ i += 2
949
+ continue
950
+ elif a.startswith("--file="):
951
+ files.append(a.split("=", 1)[1])
952
+ elif a == "-i" and i + 1 < len(args) and (args[i + 1] == "" or (args[i + 1].startswith(".") and "/" not in args[i + 1])):
953
+ i += 2
954
+ continue
955
+ elif a in ("-l", "--line-length") and i + 1 < len(args):
956
+ i += 2
957
+ continue
958
+ elif not a.startswith("-") or a == "-":
959
+ ops.append(a)
960
+ i += 1
961
+ if not progs and not files and ops:
962
+ progs.append(ops[0])
963
+ for f in files:
964
+ r = _program_refusal("sed -f", f, cwd, variables, env)
965
+ if r:
966
+ return r
967
+ for p in progs:
968
+ r = sed_program_refusal(p)
969
+ if r:
970
+ return r
971
+ return None
972
+
973
+
974
+ MAKEFILES = ("GNUmakefile", "makefile", "Makefile")
975
+
976
+
977
+ def make_refusal(stripped, cwd, variables=None, env=None):
978
+ """make runs the recipes of a Makefile: it has to be one committed
979
+ unmodified at HEAD, and the command line may not add code (--eval) or
980
+ override a variable a recipe expands (CC=...)."""
981
+ args = stripped[1:]
982
+ files, directory = [], None
983
+ i = 0
984
+ while i < len(args):
985
+ a = args[i]
986
+ key = a.split("=", 1)[0]
987
+ if key in ("-E", "--eval"):
988
+ return "make --eval adds rules the guard cannot see"
989
+ if a in ("-f", "--file", "--makefile") and i + 1 < len(args):
990
+ files.append(args[i + 1])
991
+ i += 2
992
+ continue
993
+ if key in ("--file", "--makefile") and "=" in a:
994
+ files.append(a.split("=", 1)[1])
995
+ elif a.startswith("-f") and len(a) > 2:
996
+ files.append(a[2:])
997
+ elif a in ("-C", "--directory") and i + 1 < len(args):
998
+ directory = args[i + 1]
999
+ i += 2
1000
+ continue
1001
+ elif key == "--directory" and "=" in a:
1002
+ directory = a.split("=", 1)[1]
1003
+ elif a.startswith("-C") and len(a) > 2:
1004
+ directory = a[2:]
1005
+ elif a in ("-I", "--include-dir", "-o", "-W", "--old-file", "--what-if", "--new-file", "--assume-old", "--assume-new") and i + 1 < len(args):
1006
+ i += 2
1007
+ continue
1008
+ elif not a.startswith("-") and "=" in a:
1009
+ return f"make {key}=... overrides a variable a recipe runs"
1010
+ i += 1
1011
+ base = cwd
1012
+ if directory is not None:
1013
+ e = expand(directory, variables or {}, env)
1014
+ if e is None or (cwd is None and not os.path.isabs(e)):
1015
+ return "make -C into a directory the guard could not resolve"
1016
+ base = os.path.join(cwd or "/", os.path.expanduser(e))
1017
+ if base is None:
1018
+ return "make in a directory the guard could not resolve"
1019
+ if not files:
1020
+ try:
1021
+ entries = os.listdir(base)
1022
+ except OSError:
1023
+ entries = []
1024
+ found = [e for m in MAKEFILES for e in entries if _casefold(e) == _casefold(m)]
1025
+ files = found[:1]
1026
+ for f in files:
1027
+ r = _program_refusal("make", f, base, variables, env)
1028
+ if r:
1029
+ return r
1030
+ return None
1031
+
1032
+
1033
+ def simple_command_refusal(cmd, toks, variables=None, env=None, cwd=None):
1034
+ """Refuse a segment that is not a simple command the guard can judge:
1035
+ a shell keyword, a runtime-built command name, sudo/doas, a shell with a
1036
+ `-c` cluster, an interpreter reading its program from stdin or from the
1037
+ command line, a program file the run wrote, find -exec, or xargs with a
1038
+ command."""
1039
+ variables = variables or {}
1040
+ raw = _strip_leading_assignments_for_check(tokenize(cmd))
1041
+ if raw and _has_ph(raw[0]):
1042
+ return "a command substitution in command position builds the command name at run time"
1043
+ if raw and "$" in raw[0]:
1044
+ e = expand(raw[0], variables, env)
1045
+ if e is None:
1046
+ return "the command name is built at run time and cannot be judged"
1047
+ raw = [e] + raw[1:]
1048
+ if raw:
1049
+ base = os.path.basename(raw[0])
1050
+ low = base.lower()
1051
+ if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
1052
+ return f"{base} is a shell keyword, not a simple command"
1053
+ if low in ("sudo", "doas"):
1054
+ return f"{low} escalates privilege and is refused"
1055
+ stripped = _strip_wrappers(tokenize(cmd))
1056
+ if not stripped:
1057
+ return None
1058
+ if _has_ph(stripped[0]):
1059
+ return "the command name is built at run time and cannot be judged"
1060
+ if "$" in stripped[0]:
1061
+ e = expand(stripped[0], variables, env)
1062
+ if e is None:
1063
+ return "the command name is built at run time and cannot be judged"
1064
+ stripped = [e] + stripped[1:]
1065
+ base = os.path.basename(stripped[0])
1066
+ low = base.lower()
1067
+ if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
1068
+ return f"{base} is a shell keyword, not a simple command"
1069
+ if low in ("sudo", "doas"):
1070
+ return f"{low} escalates privilege and is refused"
1071
+ if "/" in stripped[0]:
1072
+ r = _program_refusal("the command", stripped[0], cwd, variables, env)
1073
+ if r:
1074
+ return r
1075
+ for judge in (interpreter_refusal,
1076
+ awk_refusal if low in AWKS else None,
1077
+ sed_refusal if low in ("sed", "gsed") else None,
1078
+ make_refusal if low in ("make", "gmake") else None):
1079
+ if judge:
1080
+ r = judge(stripped, cwd, variables, env)
1081
+ if r:
1082
+ return r
1083
+ if low in ("sort", "gsort", "rg", "grep", "ggrep"):
1084
+ r = _helper_program_refusal(low, stripped[1:], cwd, variables, env)
1085
+ if r:
1086
+ return r
1087
+ if low == "find":
1088
+ for a in stripped[1:]:
1089
+ if a in ("-exec", "-execdir", "-ok", "-okdir"):
1090
+ return "find -exec runs a command the guard cannot see"
1091
+ if low == "xargs":
1092
+ rest = stripped[1:]
1093
+ j = 0
1094
+ while j < len(rest) and rest[j].startswith("-"):
1095
+ j += 1
1096
+ if j < len(rest):
1097
+ return "xargs with a command runs it the guard cannot judge"
1098
+ return None
1099
+
1100
+
1101
+ # Options whose value names a helper program a tool runs: sort's external merge
1102
+ # compressor, ripgrep's and grep's preprocessor. A file the run wrote there
1103
+ # executes.
1104
+ HELPER_PROGRAM_OPTS = {
1105
+ "sort": ("--compress-program",),
1106
+ "gsort": ("--compress-program",),
1107
+ "rg": ("--pre",),
1108
+ "grep": (),
1109
+ "ggrep": (),
1110
+ }
1111
+
1112
+
1113
+ def _helper_program_refusal(low, args, cwd, variables, env):
1114
+ opts = HELPER_PROGRAM_OPTS.get(low, ())
1115
+ i = 0
1116
+ while i < len(args):
1117
+ a = args[i]
1118
+ val = None
1119
+ for opt in opts:
1120
+ if a == opt and i + 1 < len(args):
1121
+ val = args[i + 1]
1122
+ i += 1
1123
+ break
1124
+ if a.startswith(opt + "="):
1125
+ val = a.split("=", 1)[1]
1126
+ break
1127
+ if val is not None:
1128
+ r = _program_refusal(f"{low} {a.split('=', 1)[0]}", val, cwd, variables, env)
1129
+ if r:
1130
+ return r
1131
+ i += 1
1132
+ return None
1133
+
1134
+
1135
+ def _strip_leading_assignments_for_check(toks):
1136
+ """Leading VAR=value assignments: refuse a dangerous one, else drop them and
1137
+ return the rest so the command can be judged."""
1138
+ i = 0
1139
+ while i < len(toks) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[i]):
1140
+ i += 1
1141
+ return toks[i:]
1142
+
1143
+
1144
+ # Environment variables that make a later program run code, load a library,
1145
+ # read another config or resolve paths differently.
1146
+ BLOCKED_ENV = {
1147
+ "PATH", "CDPATH", "BASH_ENV", "ENV", "PROMPT_COMMAND", "SHELLOPTS", "BASHOPTS", "ZDOTDIR", "IFS",
1148
+ "HOME", "XDG_CONFIG_HOME", "NODE_OPTIONS", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
1149
+ "PERL5OPT", "PERL5LIB", "RUBYOPT", "RUBYLIB", "SSH_ASKPASS", "LESSOPEN", "LESSCLOSE",
1150
+ # Proxies redirect every request somewhere the allowlist cannot check.
1151
+ "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
1152
+ "https_proxy", "http_proxy", "all_proxy", "no_proxy",
1153
+ # These point a fetcher, git or a toolchain at another config, host or CA.
1154
+ "CURL_HOME", "WGETRC", "GH_HOST", "GH_CONFIG_DIR", "BROWSER", "GH_BROWSER",
1155
+ "DEVELOPER_DIR", "TOOLCHAINS", "SDKROOT", "GRADLE_USER_HOME", "JAVA_TOOL_OPTIONS",
1156
+ "NODE_PATH", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE",
1157
+ "REQUESTS_CA_BUNDLE", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONFIG_FILE",
1158
+ "GEM_HOME", "GEM_PATH", "BUNDLE_GEMFILE",
1159
+ }
1160
+ BLOCKED_ENV_PREFIXES = ("MULTI_AGENT", "LD_", "DYLD_", "npm_config_", "NPM_CONFIG_", "GH_",
1161
+ "PYTHON", "GRADLE_", "JAVA_", "MAVEN_")
1162
+ # Variables that name a program git or a pager runs; only the no-op values pass.
1163
+ PROGRAM_ENV = {"EDITOR", "VISUAL", "PAGER", "MANPAGER", "GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_PAGER"}
1164
+ PROGRAM_ENV_SAFE = {"", "cat", "true", ":", "less"}
1165
+ # The GIT_* variables that only label a commit or turn off a prompt. Every
1166
+ # other GIT_* is refused: GIT_CONFIG_COUNT/KEY_n/VALUE_n, GIT_CONFIG_GLOBAL,
1167
+ # GIT_EXTERNAL_DIFF, GIT_SSH(_COMMAND), GIT_ASKPASS, GIT_EXEC_PATH, GIT_DIR and
1168
+ # the rest reconfigure what git runs or where it looks.
1169
+ SAFE_GIT_ENV = re.compile(
1170
+ r"^GIT_((AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|TERMINAL_PROMPT|OPTIONAL_LOCKS|LFS_SKIP_SMUDGE|FLUSH|ADVICE)$")
1171
+
1172
+
1173
+ def assignment_refusal(assignment):
1174
+ key, _, value = assignment.partition("=")
1175
+ if key in PROGRAM_ENV:
1176
+ if value.strip("'\"") in PROGRAM_ENV_SAFE:
1177
+ return None
1178
+ return f"setting {key} names a program git or a pager runs"
1179
+ if key in BLOCKED_ENV or key.startswith(BLOCKED_ENV_PREFIXES):
1180
+ return f"setting {key} changes what the run executes or how the guard sees it"
1181
+ if key.startswith("GIT_") and not SAFE_GIT_ENV.match(key):
1182
+ return f"setting {key} reconfigures what git runs or where it looks"
1183
+ return None
1184
+
1185
+
1186
+ def dangerous_assignment(toks):
1187
+ assigns, _, refusal = _walk_wrappers(toks)
1188
+ if refusal:
1189
+ return refusal
1190
+ for t in assigns:
1191
+ r = assignment_refusal(t)
1192
+ if r:
1193
+ return r
1194
+ return None
1195
+
1196
+
1197
+ # --- policy data --------------------------------------------------------------
1198
+
1199
+ def load_policy():
1200
+ for p in POLICY_CANDIDATES:
1201
+ try:
1202
+ with open(p, encoding="utf-8") as f:
1203
+ return json.load(f)
1204
+ except (OSError, ValueError):
1205
+ continue
1206
+ raise RuntimeError("unattended-policy.json not found")
1207
+
1208
+
1209
+ def home():
1210
+ return os.path.realpath(os.path.expanduser("~"))
1211
+
1212
+
1213
+ def autopilot_root(env=None):
1214
+ env = os.environ if env is None else env
1215
+ root = env.get("MA_AUTOPILOT_ROOT") or os.path.join(home(), ".claude", "autopilot")
1216
+ return os.path.expanduser(root)
1217
+
1218
+
1219
+ def unattended_run_root(env=None):
1220
+ env = os.environ if env is None else env
1221
+ return os.path.expanduser(env.get("MA_UNATTENDED_RUN_ROOT") or os.path.join(home(), UNATTENDED_RUN_DIR))
1222
+
1223
+
1224
+ def pr_request_path(env=None):
1225
+ env = os.environ if env is None else env
1226
+ sid = env.get("MULTI_AGENT_SESSION_ID", "")
1227
+ if not re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", sid):
1228
+ return None
1229
+ return os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR, sid + ".json")
1230
+
1231
+
1232
+ def resolve(path, cwd):
1233
+ p = path.strip()
1234
+ p = re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), p)
1235
+ p = os.path.expanduser(p)
1236
+ if not os.path.isabs(p):
1237
+ p = os.path.join(cwd or os.getcwd(), p)
1238
+ return os.path.realpath(os.path.normpath(p))
1239
+
1240
+
1241
+ # --- paths --------------------------------------------------------------------
1242
+
1243
+ def _under(target, base):
1244
+ """target == base or target is under base, casefolded on darwin."""
1245
+ t, b = _casefold(target), _casefold(base)
1246
+ return t == b or t.startswith(b + os.sep)
1247
+
1248
+
1249
+ def check_path(path, cwd, policy, env=None):
1250
+ if not path:
1251
+ return None
1252
+ target = resolve(path, cwd)
1253
+ tcf = _casefold(target)
1254
+ exempt = pr_request_path(env)
1255
+ if exempt and tcf == _casefold(os.path.realpath(exempt)):
1256
+ return None
1257
+ requests = os.path.realpath(os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR))
1258
+ if _under(target, requests):
1259
+ return "another session's PR request (or the requests directory itself) is not this run's to write"
1260
+ h = home()
1261
+ # The home directory itself, or an ancestor of it (rm -rf ~, mv ~ /tmp),
1262
+ # contains every host root and credential file below; writing it writes them.
1263
+ if tcf == _casefold(h) or _under(h, target):
1264
+ return "$HOME (or a directory above it) holds every host root and credential the guard protects"
1265
+ ap = os.path.realpath(autopilot_root(env))
1266
+ if _under(target, ap):
1267
+ return "the autopilot runner's own files (queue, config, requests, PR verification) are not the run's to write"
1268
+ for name in policy.get("protectedHomeFiles", []):
1269
+ if tcf == _casefold(os.path.join(h, name)):
1270
+ return f"~/{name} is host configuration"
1271
+ for d in policy.get("protectedHomeDirs", []):
1272
+ if _under(target, os.path.join(h, d)):
1273
+ return f"~/{d}/ is host configuration"
1274
+ # The whole host root is protected, not an enumerated list of entries: an
1275
+ # unattended run writes its logs and PR request under the unattended run
1276
+ # directory (lib/pr-request-location.mjs), never here, so nothing under
1277
+ # ~/.claude, ~/.copilot or ~/.codex is the run's to write.
1278
+ for root in policy.get("protectedHostRoots", []):
1279
+ base = os.path.join(h, root)
1280
+ if tcf == _casefold(base) or tcf.startswith(_casefold(base) + os.sep):
1281
+ return f"~/{root}/ is host configuration and the installed pipeline; an unattended run writes under ~/{UNATTENDED_RUN_DIR}"
1282
+ segs = target.split(os.sep)
1283
+ segs_cf = [_casefold(s) for s in segs]
1284
+ repo = policy.get("protectedRepoPaths", {})
1285
+ for d in repo.get("dirs", []):
1286
+ parts = [_casefold(p) for p in d.split("/")]
1287
+ for i in range(len(segs_cf) - len(parts) + 1):
1288
+ if segs_cf[i:i + len(parts)] == parts:
1289
+ return f"{d}/ is protected: workflows, hooks and agent config change what runs next"
1290
+ # A directory target that is an ancestor of a protected path (mv evil .github).
1291
+ for k in range(1, len(parts)):
1292
+ if segs_cf[-k:] == parts[:k]:
1293
+ return f"{'/'.join(d.split('/')[:k])} is protected: it holds {d}/"
1294
+ name = segs[-1]
1295
+ ncf = _casefold(name)
1296
+ if any(ncf == _casefold(f) for f in repo.get("files", [])):
1297
+ return f"{name} is protected"
1298
+ man = policy.get("manifests", {})
1299
+ if any(ncf == _casefold(f) for f in man.get("files", [])) or any(re.search(rx, name) for rx in man.get("patterns", [])):
1300
+ return f"{name} is a dependency manifest or lockfile; adding a package is a person's decision"
1301
+ return None
1302
+
1303
+
1304
+ def _short_cluster_has(tok, ch):
1305
+ return tok.startswith("-") and not tok.startswith("--") and ch in tok[1:]
1306
+
1307
+
1308
+ # Options of cp/mv/rsync/install/ditto whose next token is a VALUE, not a path.
1309
+ COPY_VALUE_OPTS = {
1310
+ "cp": set(),
1311
+ "ln": set(),
1312
+ "install": {"-m", "-o", "-g", "-B", "-S"},
1313
+ "ditto": {"--rsrc", "--arch", "--bom"},
1314
+ "mv": set(),
1315
+ "rsync": {"--exclude", "--include", "--exclude-from", "--include-from", "--files-from",
1316
+ "-e", "--rsh", "--chmod", "--chown", "--rsync-path", "--compress-level",
1317
+ "--filter", "-f", "--out-format", "--log-file", "--bwlimit", "--timeout",
1318
+ "--partial-dir", "--backup-dir", "--suffix", "--max-size", "--min-size",
1319
+ "--block-size", "-B", "--modify-window", "-T", "--temp-dir", "--link-dest",
1320
+ "--copy-dest", "--compare-dest", "--port", "--sockopts"},
1321
+ }
1322
+
1323
+
1324
+ def _copy_operands(cmd, args):
1325
+ """The positional path operands of a copy-like command, with option values
1326
+ consumed so `rsync --exclude X src dst` does not treat X as a path."""
1327
+ value_opts = COPY_VALUE_OPTS.get(cmd, set())
1328
+ out, i = [], 0
1329
+ while i < len(args):
1330
+ a = args[i]
1331
+ if a == "--":
1332
+ out.extend(args[i + 1:])
1333
+ break
1334
+ if a.startswith("-") and a != "-":
1335
+ key = a.split("=", 1)[0]
1336
+ if key in value_opts and "=" not in a:
1337
+ i += 2
1338
+ continue
1339
+ i += 1
1340
+ continue
1341
+ out.append(a)
1342
+ i += 1
1343
+ return out
1344
+
1345
+
1346
+ def _is_dir_dest(cmd, args, dest):
1347
+ """A copy into a directory names files inside it by their source basenames.
1348
+ Recursive (cp -R, rsync -a, ditto) or an existing directory destination, or
1349
+ a destination written with a trailing slash."""
1350
+ if dest.endswith("/"):
1351
+ return True
1352
+ recursive = any(
1353
+ _short_cluster_has(a, "R") or _short_cluster_has(a, "r") or _short_cluster_has(a, "a")
1354
+ or a in ("--recursive", "--archive")
1355
+ for a in args
1356
+ )
1357
+ d = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), dest.strip("'\"")))
1358
+ if recursive or cmd == "rsync" or cmd == "ditto":
1359
+ return True
1360
+ try:
1361
+ return os.path.isdir(d)
1362
+ except OSError:
1363
+ return False
1364
+
1365
+
1366
+ def dest_write_targets(cmd, args, sources, dest):
1367
+ """The paths a copy writes: the destination, plus, when it is a directory,
1368
+ the destination joined with each source's basename (cp -R dir ~/ writes
1369
+ ~/dir)."""
1370
+ out = [dest]
1371
+ if _is_dir_dest(cmd, args, dest):
1372
+ for src in sources:
1373
+ name = os.path.basename(src.rstrip("/"))
1374
+ if name:
1375
+ out.append(dest.rstrip("/") + "/" + name)
1376
+ return out
1377
+
1378
+
1379
+ def write_targets(toks):
1380
+ """Files a simple shell command writes."""
1381
+ if not toks:
1382
+ return []
1383
+ cmd = os.path.basename(toks[0]).lower()
1384
+ args = toks[1:]
1385
+ plain = [a for a in args if not a.startswith("-")]
1386
+ if cmd == "tee":
1387
+ return plain
1388
+ if cmd in ("sed", "gsed"):
1389
+ inplace = any(a == "-i" or a.startswith("-i") or a.startswith("--in-place") or _short_cluster_has(a, "i") for a in args)
1390
+ return plain[1:] if inplace and len(plain) >= 2 else []
1391
+ if cmd == "perl":
1392
+ if not any(_short_cluster_has(a, "i") or a.startswith("-i") for a in args):
1393
+ return []
1394
+ files, skip = [], False
1395
+ for a in args:
1396
+ if skip:
1397
+ skip = False
1398
+ continue
1399
+ if a in ("-e", "-E"):
1400
+ skip = True
1401
+ continue
1402
+ if a.startswith("-"):
1403
+ continue
1404
+ files.append(a)
1405
+ return files
1406
+ if cmd in ("cp", "mv", "ln", "install", "rsync", "ditto"):
1407
+ operands = _copy_operands(cmd, args)
1408
+ for i, a in enumerate(args):
1409
+ if a in ("-t", "--target-directory") and i + 1 < len(args):
1410
+ return [args[i + 1]]
1411
+ if a.startswith("--target-directory="):
1412
+ return [a.split("=", 1)[1]]
1413
+ if a.startswith("-t") and len(a) > 2 and not a.startswith("--"):
1414
+ return [a[2:]]
1415
+ if cmd == "ln":
1416
+ return operands
1417
+ if len(operands) < 2:
1418
+ return []
1419
+ sources, dest = operands[:-1], operands[-1]
1420
+ out = list(dest_write_targets(cmd, args, sources, dest))
1421
+ # mv removes its sources; rsync with --remove-source-files does too.
1422
+ if cmd == "mv" or (cmd == "rsync" and any(a == "--remove-source-files" for a in args)):
1423
+ out.extend(sources)
1424
+ return out
1425
+ if cmd in ("rm", "unlink", "shred"):
1426
+ # rm removes what it names: the paths are writes, and a home entry named
1427
+ # as an ancestor (rm -rf ~) is a write to everything under it.
1428
+ return plain
1429
+ if cmd in ("touch", "truncate", "mkdir", "rmdir", "mkfifo", "mknod", "mktemp"):
1430
+ return plain
1431
+ if cmd in ("chflags", "xattr"):
1432
+ # Metadata changes to a protected file are refused the same as a write.
1433
+ return plain[1:] if len(plain) >= 2 else plain
1434
+ if cmd in ("chmod", "chown"):
1435
+ return plain[1:]
1436
+ if cmd == "dd":
1437
+ return [a[3:] for a in args if a.startswith("of=")]
1438
+ if cmd in ("sort", "gsort"):
1439
+ out = []
1440
+ for i, a in enumerate(args):
1441
+ if a in ("-o", "--output") and i + 1 < len(args):
1442
+ out.append(args[i + 1])
1443
+ elif a.startswith("--output="):
1444
+ out.append(a.split("=", 1)[1])
1445
+ elif a.startswith("-o") and len(a) > 2 and not a.startswith("--"):
1446
+ out.append(a[2:])
1447
+ return out
1448
+ if cmd in ("find", "gfind"):
1449
+ out = []
1450
+ for i, a in enumerate(args):
1451
+ if a in ("-fprint", "-fprint0", "-fprintf", "-fls") and i + 1 < len(args):
1452
+ out.append(args[i + 1])
1453
+ if "-delete" in args:
1454
+ for a in args:
1455
+ if a.startswith("-") or a in ("(", "!"):
1456
+ break
1457
+ out.append(a)
1458
+ return out
1459
+ return []
1460
+
1461
+
1462
+ def _opt_value(args, i, names):
1463
+ """Value of args[i] when it is one of names: `--opt value`, `--opt=value`,
1464
+ or `-oVALUE` attached. Returns (value, consumed) or (None, 1)."""
1465
+ a = args[i]
1466
+ for n in names:
1467
+ if a == n:
1468
+ return (args[i + 1] if i + 1 < len(args) else None), 2
1469
+ if n.startswith("--") and a.startswith(n + "="):
1470
+ return a.split("=", 1)[1], 1
1471
+ if not n.startswith("--") and len(n) == 2 and a.startswith(n) and len(a) > 2 and not a.startswith("--"):
1472
+ return a[2:], 1
1473
+ return None, 1
1474
+
1475
+
1476
+ def extractor_write_targets(toks):
1477
+ """Write destinations of curl -o, tar -C, unzip -d, git checkout -- / restore,
1478
+ patch -o - the tools whose write target is an option value, not a positional."""
1479
+ if not toks:
1480
+ return []
1481
+ base = os.path.basename(toks[0]).lower()
1482
+ args = toks[1:]
1483
+ out = []
1484
+ if base == "curl":
1485
+ i = 0
1486
+ while i < len(args):
1487
+ v, step = _opt_value(args, i, ("-o", "--output", "--output-dir"))
1488
+ if v is not None and (args[i] in ("-o", "--output", "--output-dir") or args[i].startswith("--output") or (args[i].startswith("-o") and len(args[i]) > 2)):
1489
+ out.append(v)
1490
+ i += step
1491
+ return out
1492
+ if base in ("tar", "gtar", "bsdtar"):
1493
+ i = 0
1494
+ while i < len(args):
1495
+ v, step = _opt_value(args, i, ("-C", "--directory"))
1496
+ if v is not None and (args[i] in ("-C", "--directory") or args[i].startswith("--directory=") or (args[i].startswith("-C") and len(args[i]) > 2)):
1497
+ out.append(v)
1498
+ i += step
1499
+ return out
1500
+ if base == "unzip":
1501
+ for i, a in enumerate(args):
1502
+ if a == "-d" and i + 1 < len(args):
1503
+ out.append(args[i + 1])
1504
+ elif a.startswith("-d") and len(a) > 2:
1505
+ out.append(a[2:])
1506
+ return out
1507
+ if base in ("7z", "7za", "7zr"):
1508
+ for a in args:
1509
+ if a.startswith("-o") and len(a) > 2:
1510
+ out.append(a[2:])
1511
+ return out
1512
+ if base in ("diff", "gdiff"):
1513
+ for a in args:
1514
+ if a.startswith("--to-file="):
1515
+ out.append(a.split("=", 1)[1])
1516
+ return out
1517
+ if base == "xcodebuild":
1518
+ opts = ("-derivedDataPath", "-resultBundlePath", "-exportPath", "-archivePath")
1519
+ i = 0
1520
+ while i < len(args):
1521
+ if args[i] in opts and i + 1 < len(args):
1522
+ out.append(args[i + 1])
1523
+ i += 2
1524
+ continue
1525
+ i += 1
1526
+ return out
1527
+ if base == "swift":
1528
+ opts = ("--scratch-path", "--build-path", "--cache-path", "--package-path")
1529
+ i = 0
1530
+ while i < len(args):
1531
+ a = args[i]
1532
+ for o in opts:
1533
+ if a == o and i + 1 < len(args):
1534
+ out.append(args[i + 1])
1535
+ i += 1
1536
+ elif a.startswith(o + "="):
1537
+ out.append(a.split("=", 1)[1])
1538
+ i += 1
1539
+ return out
1540
+ if base == "patch":
1541
+ for i, a in enumerate(args):
1542
+ if a in ("-o", "--output") and i + 1 < len(args):
1543
+ out.append(args[i + 1])
1544
+ elif a.startswith("--output="):
1545
+ out.append(a.split("=", 1)[1])
1546
+ plain = [a for a in args if not a.startswith("-")]
1547
+ out.extend(plain)
1548
+ return out
1549
+ if base == "git":
1550
+ sub, rest = _git_sub(toks)
1551
+ plain = [a for a in rest if not a.startswith("-")]
1552
+ if sub == "restore":
1553
+ return plain
1554
+ if sub == "checkout" and "--" in rest:
1555
+ k = rest.index("--")
1556
+ return [a for a in rest[k + 1:] if not a.startswith("-")]
1557
+ # checkout <ref> <path...>: everything after the first plain (the ref).
1558
+ if sub == "checkout" and len(plain) >= 2:
1559
+ out.extend(plain[1:])
1560
+ if sub == "mv" and len(plain) >= 2:
1561
+ out.append(plain[-1])
1562
+ # clone / init / worktree add write into a destination directory the run
1563
+ # would then own; refusing a protected destination stops seeding .claude.
1564
+ if sub == "clone" and len(plain) >= 2:
1565
+ out.append(plain[-1])
1566
+ if sub == "init" and plain:
1567
+ out.append(plain[-1])
1568
+ if sub == "worktree" and len(plain) >= 2 and plain[0] == "add":
1569
+ out.append(plain[1])
1570
+ if sub == "read-tree" and "--prefix" in " ".join(rest):
1571
+ for a in rest:
1572
+ if a.startswith("--prefix="):
1573
+ out.append(a.split("=", 1)[1])
1574
+ if sub == "checkout-index":
1575
+ for a in rest:
1576
+ if a.startswith("--prefix="):
1577
+ out.append(a.split("=", 1)[1])
1578
+ elif a == "--prefix":
1579
+ j = rest.index(a)
1580
+ if j + 1 < len(rest):
1581
+ out.append(rest[j + 1])
1582
+ if sub in ("apply", "am"):
1583
+ for a in rest:
1584
+ if a.startswith("--directory="):
1585
+ out.append(a.split("=", 1)[1])
1586
+ elif a == "--directory":
1587
+ j = rest.index(a)
1588
+ if j + 1 < len(rest):
1589
+ out.append(rest[j + 1])
1590
+ if sub in ("archive", "bundle"):
1591
+ for a in rest:
1592
+ if a.startswith("--output="):
1593
+ out.append(a.split("=", 1)[1])
1594
+ elif a in ("-o", "--output"):
1595
+ j = rest.index(a)
1596
+ if j + 1 < len(rest):
1597
+ out.append(rest[j + 1])
1598
+ if sub == "bundle" and plain and plain[0] == "create" and len(plain) >= 2:
1599
+ out.append(plain[1])
1600
+ names = ("--output", "--output-directory") + (("-o",) if sub == "format-patch" else ())
1601
+ i = 0
1602
+ while i < len(rest):
1603
+ v, step = _opt_value(rest, i, names)
1604
+ if v is not None:
1605
+ out.append(v)
1606
+ i += step
1607
+ return out
1608
+ return []
1609
+
1610
+
1611
+ # --- commands -----------------------------------------------------------------
1612
+
1613
+ def _git_sub(toks):
1614
+ """The git subcommand and its remaining args, skipping git's global options.
1615
+ Refusal of dangerous `-c` values is handled by git_config_refusal."""
1616
+ i = 1
1617
+ while i < len(toks):
1618
+ t = toks[i]
1619
+ if t in ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
1620
+ i += 2
1621
+ elif t.startswith("-"):
1622
+ i += 1
1623
+ else:
1624
+ return t.lower(), toks[i + 1:]
1625
+ return "", []
1626
+
1627
+
1628
+ # Config keys that make git run a program, load more config, or send somewhere
1629
+ # else. One list for `git -c`, `git --config-env`, `git clone -c` and
1630
+ # `git config`.
1631
+ GIT_EXEC_CONFIG = re.compile(
1632
+ r"^(alias\..*"
1633
+ r"|core\.(hookspath|sshcommand|fsmonitor|editor|pager|askpass|gitproxy|alternaterefscommand|worktree|attributesfile|excludesfile)"
1634
+ r"|sequence\.editor|diff\.external|diff\..*\.(command|textconv)|difftool\..*|mergetool\..*|merge\..*\.driver"
1635
+ r"|filter\..*|credential\..*|gpg\..*|url\..*\.(insteadof|pushinsteadof)|include\..*|includeif\..*"
1636
+ r"|http\..*|remote\..*|branch\..*\.(remote|pushremote)|protocol\..*|pager\..*|interactive\.difffilter"
1637
+ r"|uploadpack\..*|receive\..*|ssh\.variant|init\.templatedir|submodule\..*|trailer\..*|hook\..*"
1638
+ r"|man\..*|browser\..*|web\.browser|instaweb\..*|sendemail\..*|safe\.directory|color\.pager)$",
1639
+ re.I,
1640
+ )
1641
+
1642
+
1643
+ def _git_exec_key(val):
1644
+ key = val.split("=", 1)[0]
1645
+ return key if GIT_EXEC_CONFIG.match(key) else None
1646
+
1647
+
1648
+ def git_config_refusal(toks):
1649
+ """git -c KEY=VALUE / --config-env=KEY=ENV with a key that runs a program
1650
+ or redirects git, before the subcommand."""
1651
+ i = 1
1652
+ while i < len(toks):
1653
+ t = toks[i]
1654
+ val = None
1655
+ if t == "-c" and i + 1 < len(toks):
1656
+ val = toks[i + 1]
1657
+ i += 2
1658
+ elif t.startswith("-c") and len(t) > 2:
1659
+ val = t[2:]
1660
+ i += 1
1661
+ elif t == "--config-env" and i + 1 < len(toks):
1662
+ val = toks[i + 1]
1663
+ i += 2
1664
+ elif t.startswith("--config-env="):
1665
+ val = t.split("=", 1)[1]
1666
+ i += 1
1667
+ else:
1668
+ if t.startswith("-"):
1669
+ i += 1
1670
+ if t in ("-C", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
1671
+ i += 1
1672
+ continue
1673
+ break
1674
+ if val is None:
1675
+ continue
1676
+ key = _git_exec_key(val)
1677
+ if key:
1678
+ return f"git -c {key} reconfigures git for the whole run"
1679
+ return None
1680
+
1681
+
1682
+ GIT_CONFIG_READS = {"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l", "--show-origin",
1683
+ "--show-scope", "--name-only", "get", "list"}
1684
+ GIT_CONFIG_WRITE_SUBS = {"set", "unset", "rename-section", "remove-section"}
1685
+ GIT_CONFIG_WRITE_OPTS = {"--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section"}
1686
+
1687
+
1688
+ def _git_config(rest):
1689
+ if any(a in ("-e", "--edit", "edit") for a in rest):
1690
+ return "git config --edit opens an editor on the config"
1691
+ if any(a in GIT_CONFIG_READS for a in rest):
1692
+ return None
1693
+ keys = [a for a in rest if not a.startswith("-")]
1694
+ writes = bool(keys and keys[0] in GIT_CONFIG_WRITE_SUBS)
1695
+ if writes:
1696
+ keys = keys[1:]
1697
+ writes = writes or len(keys) >= 2 or any(a in GIT_CONFIG_WRITE_OPTS for a in rest)
1698
+ if not writes:
1699
+ return None
1700
+ if any(a in ("--global", "--system") for a in rest):
1701
+ return "git config --global / --system writes this machine's git configuration"
1702
+ for k in keys[:2]:
1703
+ if GIT_EXEC_CONFIG.match(k):
1704
+ return f"git config {k} changes what git runs or where it sends"
1705
+ return None
1706
+
1707
+
1708
+ # git subcommands that write to a remote or another version-control system.
1709
+ GIT_OUTWARD_SUBS = {
1710
+ "send-pack": "git send-pack writes to a remote",
1711
+ "http-push": "git http-push writes to a remote",
1712
+ "upload-archive": "git upload-archive serves this repository to a remote",
1713
+ "svn": "git svn writes to a Subversion remote",
1714
+ "p4": "git p4 writes to a Perforce remote",
1715
+ "imap-send": "git imap-send sends mail",
1716
+ "request-pull": "git request-pull is a publish step",
1717
+ "web--browse": "git web--browse opens a browser on a URL",
1718
+ "instaweb": "git instaweb starts a web server and opens a browser",
1719
+ "daemon": "git daemon serves this repository to the network",
1720
+ }
1721
+ # Global flags before the subcommand that move where git reads or writes, or
1722
+ # what it runs. --git-dir / --work-tree / --exec-path / --namespace are refused;
1723
+ # -C and -c are handled elsewhere (-c by git_config_refusal).
1724
+ GIT_GLOBAL_REFUSED = ("--git-dir", "--work-tree", "--exec-path", "--namespace")
1725
+
1726
+
1727
+ def git_global_flag_refusal(toks):
1728
+ """A git global flag before the subcommand that redirects git's directories
1729
+ or its helper path."""
1730
+ for t in toks[1:]:
1731
+ if not t.startswith("-"):
1732
+ break
1733
+ key = t.split("=", 1)[0]
1734
+ if key in GIT_GLOBAL_REFUSED:
1735
+ return f"git {key} redirects where git reads, writes or finds its programs"
1736
+ return None
1737
+
1738
+
1739
+ def git_credential_refusal(sub, rest):
1740
+ """git credential / credential-<helper>: reading or writing the store."""
1741
+ if sub == "credential" or sub.startswith("credential-"):
1742
+ return f"git {sub} reads or writes the credential store"
1743
+ if sub == "lfs" and rest and rest[0] == "push":
1744
+ return "git lfs push writes to a remote"
1745
+ return None
1746
+
1747
+
1748
+ def git_exec_refusal(sub, rest, toks):
1749
+ """git subcommands and options that run a command line or a transport the
1750
+ guard cannot see."""
1751
+ if any(t.startswith(("ext::", "fd::")) for t in toks):
1752
+ return "an ext:: or fd:: git transport runs a command"
1753
+ if sub in GIT_OUTWARD_SUBS:
1754
+ return GIT_OUTWARD_SUBS[sub]
1755
+ r = git_credential_refusal(sub, rest)
1756
+ if r:
1757
+ return r
1758
+ if sub == "rebase":
1759
+ for a in rest:
1760
+ if a in ("--exec", "-x") or a.startswith("--exec=") or _short_cluster_has(a, "x"):
1761
+ return "git rebase --exec runs a command after each commit"
1762
+ if sub == "bisect" and rest and rest[0] == "run":
1763
+ return "git bisect run runs a command"
1764
+ if sub == "submodule" and "foreach" in rest:
1765
+ return "git submodule foreach runs a command in each submodule"
1766
+ if sub == "difftool":
1767
+ for a in rest:
1768
+ if a in ("-x", "--extcmd") or a.startswith("--extcmd=") or _short_cluster_has(a, "x"):
1769
+ return "git difftool --extcmd runs a command"
1770
+ if sub in ("filter-branch", "filter-repo"):
1771
+ return f"git {sub} runs filter commands"
1772
+ if sub == "grep":
1773
+ for a in rest:
1774
+ if a.startswith("-O") or a.startswith("--open-files-in-pager"):
1775
+ return "git grep -O runs a pager command"
1776
+ if sub == "maintenance" and rest and rest[0] in ("start", "register"):
1777
+ return f"git maintenance {rest[0]} installs a background job that runs git"
1778
+ if sub == "help" and any(a in ("-w", "--web") for a in rest):
1779
+ return "git help -w opens a browser"
1780
+ for a in rest:
1781
+ key = a.split("=", 1)[0]
1782
+ if key in ("--upload-pack", "--receive-pack", "--exec", "--template") or (sub == "clone" and a == "-u"):
1783
+ return f"git {sub} {key} runs a program or installs hooks"
1784
+ if sub == "clone":
1785
+ for i, a in enumerate(rest):
1786
+ val = None
1787
+ if a in ("-c", "--config") and i + 1 < len(rest):
1788
+ val = rest[i + 1]
1789
+ elif a.startswith("--config="):
1790
+ val = a.split("=", 1)[1]
1791
+ if val and _git_exec_key(val):
1792
+ return f"git clone -c {_git_exec_key(val)} reconfigures the new repository"
1793
+ return None
1794
+
1795
+
1796
+ # git subcommands that reach a remote host: the URL argument (or the configured
1797
+ # remote) must be on the allowlist, the same rule curl and gh follow.
1798
+ GIT_NETWORK_SUBS = {"fetch", "clone", "pull", "ls-remote", "archive", "bundle", "remote-https", "remote-http"}
1799
+
1800
+
1801
+ def git_url_refusal(sub, rest, toks, policy, env):
1802
+ """A git subcommand that contacts a remote, checked against the network
1803
+ allowlist. A literal URL argument is host-checked; a bare remote name or no
1804
+ argument (fetch origin, pull) is left to the configured remote, which the
1805
+ guard does not resolve, so it passes here and the sandbox confines it."""
1806
+ urls = []
1807
+ if sub in GIT_NETWORK_SUBS:
1808
+ for a in rest:
1809
+ if "://" in a or a.startswith("git@") or re.match(r"^[a-z]+::", a):
1810
+ urls.append(a)
1811
+ if sub == "submodule" and rest and rest[0] == "add":
1812
+ for a in rest[1:]:
1813
+ if "://" in a or a.startswith("git@"):
1814
+ urls.append(a)
1815
+ if sub == "remote" and rest and rest[0] in ("add", "set-url"):
1816
+ for a in rest[1:]:
1817
+ if "://" in a or a.startswith("git@"):
1818
+ urls.append(a)
1819
+ for a in rest:
1820
+ if a.startswith("--remote="):
1821
+ v = a.split("=", 1)[1]
1822
+ if "://" in v or v.startswith("git@"):
1823
+ urls.append(v)
1824
+ for u in urls:
1825
+ if u.startswith("git@") or re.match(r"^[a-z]+::", u):
1826
+ return f"git {sub} over ssh or a custom transport ({u[:40]}) cannot be host-checked; refused unattended"
1827
+ r = check_url_host(u, policy, env, f"git {sub}")
1828
+ if r:
1829
+ return r
1830
+ return None
1831
+
1832
+
1833
+ GH_WRITES = {
1834
+ "pr": {"create", "merge", "ready", "edit", "comment", "review", "close", "reopen", "lock", "unlock"},
1835
+ "issue": {"create", "close", "edit", "comment", "reopen", "delete", "transfer", "lock", "unlock", "pin", "unpin", "develop"},
1836
+ "release": {"create", "edit", "delete", "upload", "delete-asset"},
1837
+ "repo": {"create", "edit", "delete", "fork", "rename", "archive", "unarchive", "sync", "deploy-key", "set-default"},
1838
+ "workflow": {"run", "enable", "disable"},
1839
+ "run": {"rerun", "cancel", "delete"},
1840
+ "label": {"create", "edit", "delete", "clone"},
1841
+ "gist": {"create", "edit", "delete", "clone"},
1842
+ "secret": None,
1843
+ "variable": None,
1844
+ "ruleset": None,
1845
+ "alias": None,
1846
+ "ssh-key": None,
1847
+ "gpg-key": None,
1848
+ "extension": None,
1849
+ "codespace": None,
1850
+ "cache": {"delete"},
1851
+ "project": {"create", "edit", "delete", "close", "copy", "field-create", "field-delete", "item-add", "item-archive", "item-create", "item-delete", "item-edit", "link", "unlink", "mark-template"},
1852
+ }
1853
+ GH_API_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}
1854
+
1855
+
1856
+ def _is_field_flag(t):
1857
+ if t in GH_API_FIELD_FLAGS:
1858
+ return True
1859
+ if any(t.startswith(f + "=") for f in GH_API_FIELD_FLAGS if f.startswith("--")):
1860
+ return True
1861
+ # attached short forms: -ftitle=x, -Fbody=@f
1862
+ return bool(re.match(r"^-[fF].", t))
1863
+
1864
+
1865
+ # gh's built-in command aliases, so `gh cs` is judged as `gh codespace`.
1866
+ GH_ALIASES = {
1867
+ "cs": "codespace", "rs": "release", "ext": "extension", "co": "pr",
1868
+ "pr": "pr", "i": "issue", "r": "repo", "w": "workflow",
1869
+ }
1870
+ # gh config keys that hold or point at a credential; reading one prints a secret.
1871
+ GH_TOKEN_KEYS = {"oauth_token", "token"}
1872
+
1873
+
1874
+ def _gh(toks, policy=None, env=None):
1875
+ args = toks[1:]
1876
+ # Capture -R/--repo (dropped) and --hostname (dropped from the verb scan but
1877
+ # host-checked). A --hostname off the allowlist is an exfiltration target.
1878
+ cleaned, hostname, i = [], None, 0
1879
+ while i < len(args):
1880
+ a = args[i]
1881
+ if a in ("-R", "--repo"):
1882
+ i += 2
1883
+ continue
1884
+ if a.startswith("--repo="):
1885
+ i += 1
1886
+ continue
1887
+ if a == "--hostname" and i + 1 < len(args):
1888
+ hostname = args[i + 1]
1889
+ i += 2
1890
+ continue
1891
+ if a.startswith("--hostname="):
1892
+ hostname = a.split("=", 1)[1]
1893
+ i += 1
1894
+ continue
1895
+ cleaned.append(a)
1896
+ i += 1
1897
+ if hostname and policy is not None:
1898
+ r = check_url_host(hostname, policy, env, "gh --hostname")
1899
+ if r:
1900
+ return r
1901
+ plains = [a for a in cleaned if not a.startswith("-")]
1902
+ if not plains:
1903
+ return None
1904
+ group = GH_ALIASES.get(plains[0], plains[0])
1905
+ if group == "api":
1906
+ return _gh_api(cleaned[cleaned.index(plains[0]) + 1:], policy, env)
1907
+ if group == "auth":
1908
+ sub = plains[1] if len(plains) > 1 else ""
1909
+ if sub == "token" or "--show-token" in cleaned or "-t" in cleaned:
1910
+ return "gh auth token prints a credential"
1911
+ if sub == "status":
1912
+ return None
1913
+ return f"gh auth {sub}".rstrip() + " reads or changes this machine's GitHub credentials"
1914
+ if group == "config":
1915
+ sub = plains[1] if len(plains) > 1 else ""
1916
+ if sub == "set":
1917
+ return "gh config set changes the GitHub CLI's configuration"
1918
+ if sub == "get":
1919
+ if any(k in GH_TOKEN_KEYS for k in plains[2:]):
1920
+ return "gh config get of a token prints a credential"
1921
+ return None
1922
+ if group not in GH_WRITES:
1923
+ return None
1924
+ verbs = GH_WRITES[group]
1925
+ sub = plains[1] if len(plains) > 1 else ""
1926
+ if verbs is None or sub in verbs:
1927
+ return f"gh {group} {sub}".strip() + " writes to GitHub; the runner is the only writer"
1928
+ return None
1929
+
1930
+
1931
+ def _gh_api(rest, policy=None, env=None):
1932
+ method = "GET"
1933
+ fields = False
1934
+ endpoint = ""
1935
+ field_values = []
1936
+ i = 0
1937
+ while i < len(rest):
1938
+ t = rest[i]
1939
+ if t in ("-X", "--method"):
1940
+ method = (rest[i + 1] if i + 1 < len(rest) else "").upper()
1941
+ i += 2
1942
+ continue
1943
+ if t.startswith("--method="):
1944
+ method = t.split("=", 1)[1].upper()
1945
+ elif t.startswith("-X") and len(t) > 2:
1946
+ method = t[2:].upper()
1947
+ elif _is_field_flag(t):
1948
+ fields = True
1949
+ if t in GH_API_FIELD_FLAGS:
1950
+ if i + 1 < len(rest):
1951
+ field_values.append(rest[i + 1])
1952
+ i += 2
1953
+ continue
1954
+ field_values.append(t.split("=", 1)[1] if "=" in t else t[2:])
1955
+ elif not t.startswith("-") and not endpoint:
1956
+ endpoint = t
1957
+ i += 1
1958
+ # An endpoint given as a full URL goes to whatever host it names, not
1959
+ # api.github.com; host-check it.
1960
+ if "://" in endpoint and policy is not None:
1961
+ r = check_url_host(endpoint, policy, env, "gh api")
1962
+ if r:
1963
+ return r
1964
+ if endpoint == "graphql":
1965
+ joined = " ".join(rest).lower()
1966
+ if any(v.startswith("@") or "=@" in v for v in field_values):
1967
+ return "gh api graphql with an @file field reads a file into the mutation the guard cannot see"
1968
+ if re.search(r"\bmutation\b", joined) or "--input" in rest:
1969
+ return "gh api graphql mutation writes to GitHub"
1970
+ return None
1971
+ explicit = any(t in ("-X", "--method") or t.startswith("--method=") or (t.startswith("-X") and len(t) > 2) for t in rest)
1972
+ if method not in ("GET", "HEAD") or (fields and not explicit):
1973
+ return f"gh api with method {method if explicit else 'POST (fields imply it)'} writes to GitHub"
1974
+ return None
1975
+
1976
+
1977
+ CURL_VALUE_FLAGS = {"-o", "--output", "-H", "--header", "-u", "--user", "-A", "--user-agent", "-e", "--referer",
1978
+ "-X", "--request", "-m", "--max-time", "--connect-timeout", "-w", "--write-out", "-K", "--config",
1979
+ "-x", "--proxy", "-b", "--cookie", "-c", "--cookie-jar", "--retry", "-r", "--range", "--cacert",
1980
+ "--cert", "--key", "-E", "--resolve", "--connect-to", "-O", "--output-dir", "--limit-rate", "-Y", "-y",
1981
+ "--unix-socket", "--doh-url"}
1982
+ CURL_REFUSED_FLAGS = {"-x", "--proxy", "--connect-to", "--resolve", "-K", "--config", "--unix-socket", "--doh-url"}
1983
+ WGET_WRITE_FLAGS = {"--post-data", "--post-file", "--body-data", "--body-file", "--method"}
1984
+ CURL_BODY_LONG = {"--data", "--data-raw", "--data-binary", "--data-urlencode", "--data-ascii", "--json", "--form", "--form-string", "--upload-file"}
1985
+
1986
+
1987
+ def _fetch(toks, policy, env=None):
1988
+ tool = os.path.basename(toks[0]).lower()
1989
+ args = toks[1:]
1990
+ if tool == "curl":
1991
+ method = "GET"
1992
+ for a in args:
1993
+ if a in CURL_REFUSED_FLAGS or a.split("=", 1)[0] in CURL_REFUSED_FLAGS:
1994
+ return f"curl {a.split('=', 1)[0]} redirects the connection somewhere the allowlist cannot check"
1995
+ for i, t in enumerate(args):
1996
+ if t in ("-X", "--request") and i + 1 < len(args):
1997
+ method = args[i + 1].upper()
1998
+ elif t.startswith("-X") and len(t) > 2 and not t.startswith("--"):
1999
+ method = t[2:].upper()
2000
+ elif t.startswith("--request="):
2001
+ method = t.split("=", 1)[1].upper()
2002
+ body = t in CURL_BODY_LONG or any(t.startswith(f + "=") for f in CURL_BODY_LONG)
2003
+ if not body and t.startswith("-") and not t.startswith("--"):
2004
+ # short cluster: -d, -F, -T, and attached forms -d@file, -Tfile
2005
+ if any(ch in t[1:] for ch in ("d", "F", "T")):
2006
+ body = True
2007
+ if body:
2008
+ return "curl with a request body or upload writes to a remote service; the runner is the only writer"
2009
+ if method not in ("GET", "HEAD"):
2010
+ return f"curl -X {method} writes to a remote service; the runner is the only writer"
2011
+ urls, skip = [], False
2012
+ for i, t in enumerate(args):
2013
+ if skip:
2014
+ skip = False
2015
+ continue
2016
+ if t == "--url" and i + 1 < len(args):
2017
+ urls.append(args[i + 1])
2018
+ skip = True
2019
+ continue
2020
+ if t in CURL_VALUE_FLAGS:
2021
+ skip = True
2022
+ continue
2023
+ if not t.startswith("-"):
2024
+ urls.append(t)
2025
+ else:
2026
+ if any(t in WGET_WRITE_FLAGS or t.split("=", 1)[0] in WGET_WRITE_FLAGS for t in args):
2027
+ return "wget with a request body writes to a remote service; the runner is the only writer"
2028
+ urls = [t for t in args if not t.startswith("-")]
2029
+ if not urls:
2030
+ return f"{tool} with no literal URL: the host cannot be checked against the allowlist"
2031
+ for u in urls:
2032
+ r = check_url_host(u, policy, env, tool)
2033
+ if r:
2034
+ return r
2035
+ return None
2036
+
2037
+
2038
+ def _hosts(policy, env=None):
2039
+ hosts = set(h.lower() for h in policy.get("network", {}).get("staticHosts", []))
2040
+ env = os.environ if env is None else env
2041
+ snap = env.get("MA_UNATTENDED_HOSTS")
2042
+ if snap:
2043
+ for h in snap.split(","):
2044
+ if h.strip():
2045
+ hosts.add(h.strip().lower())
2046
+ return hosts
2047
+ prefs = env.get("MULTI_AGENT_PREFS") or os.path.join(home(), ".claude", "multi-agent-preferences.json")
2048
+ try:
2049
+ with open(prefs, encoding="utf-8") as f:
2050
+ configured = ((json.load(f).get("global") or {}).get("hosts") or {})
2051
+ for key in policy.get("network", {}).get("prefsHostKeys", []):
2052
+ v = configured.get(key)
2053
+ if isinstance(v, str) and v.strip():
2054
+ hosts.add(re.sub(r"^[a-z]+://", "", v.strip().lower()).split("/")[0].split(":")[0])
2055
+ except (OSError, ValueError, AttributeError):
2056
+ pass
2057
+ return hosts
2058
+
2059
+
2060
+ def _host_of(url):
2061
+ if "$" in url or "`" in url:
2062
+ return None
2063
+ try:
2064
+ parts = urlsplit(url if "://" in url else "//" + url, scheme="https")
2065
+ except ValueError:
2066
+ return None
2067
+ host = parts.hostname
2068
+ return host.lower() if host else None
2069
+
2070
+
2071
+ def _allowed_host(host, allowed):
2072
+ return any(host == a or host.endswith("." + a) for a in allowed)
2073
+
2074
+
2075
+ def check_url_host(url, policy, env=None, tool="fetch"):
2076
+ if "$" in url or "`" in url:
2077
+ return f"{tool} to a URL built at run time: the host cannot be checked against the allowlist"
2078
+ host = _host_of(url)
2079
+ if not host:
2080
+ return f"{tool} target '{url[:60]}' is not a URL the allowlist can check"
2081
+ if not _allowed_host(host, _hosts(policy, env)):
2082
+ return f"{tool} to {host}: not in the unattended network allowlist (unattended-policy.json + prefs.global.hosts)"
2083
+ return None
2084
+
2085
+
2086
+ # Tools that open a connection to a host the allowlist cannot check (an
2087
+ # arbitrary host, a raw socket, a name lookup that can carry data in the query).
2088
+ # Refused outright unattended; documented as residual in unattended-security.md.
2089
+ NETWORK_TOOLS = {
2090
+ "ssh": "ssh opens a connection to an arbitrary host and can run a command there",
2091
+ "scp": "scp copies over ssh to an arbitrary host",
2092
+ "sftp": "sftp transfers over ssh to an arbitrary host",
2093
+ "rsh": "rsh runs a command on a remote host",
2094
+ "telnet": "telnet opens a raw connection to an arbitrary host",
2095
+ "nc": "nc opens a raw socket the allowlist cannot check",
2096
+ "ncat": "ncat opens a raw socket the allowlist cannot check",
2097
+ "netcat": "netcat opens a raw socket the allowlist cannot check",
2098
+ "socat": "socat opens a raw socket the allowlist cannot check",
2099
+ "dig": "dig resolves a name and can carry data in the query",
2100
+ "nslookup": "nslookup resolves a name and can carry data in the query",
2101
+ "host": "host resolves a name and can carry data in the query",
2102
+ "drill": "drill resolves a name and can carry data in the query",
2103
+ "ping": "ping reaches an arbitrary host",
2104
+ "ping6": "ping6 reaches an arbitrary host",
2105
+ "aria2c": "aria2c downloads from an arbitrary host",
2106
+ "http": "httpie reaches an arbitrary host",
2107
+ "https": "httpie reaches an arbitrary host",
2108
+ "httpie": "httpie reaches an arbitrary host",
2109
+ "xh": "xh reaches an arbitrary host",
2110
+ }
2111
+
2112
+
2113
+ def network_tool_refusal(base):
2114
+ return NETWORK_TOOLS.get(base)
2115
+
2116
+
2117
+ # --- installs -----------------------------------------------------------------
2118
+
2119
+ _GIT_CACHE = {}
2120
+
2121
+
2122
+ def _changed_files(cwd):
2123
+ key = os.path.realpath(cwd or ".")
2124
+ if key in _GIT_CACHE:
2125
+ return _GIT_CACHE[key]
2126
+ try:
2127
+ import subprocess
2128
+
2129
+ out = subprocess.run(["git", "-C", key, "diff", "--name-only", "HEAD"],
2130
+ capture_output=True, text=True, timeout=3)
2131
+ val = None if out.returncode != 0 else {os.path.basename(l.strip()) for l in out.stdout.splitlines() if l.strip()}
2132
+ except Exception:
2133
+ val = None
2134
+ _GIT_CACHE[key] = val
2135
+ return val
2136
+
2137
+
2138
+ def _manifest_changed(names, cwd):
2139
+ changed = _changed_files(cwd)
2140
+ if changed is None:
2141
+ return True
2142
+ return bool(changed & set(names))
2143
+
2144
+
2145
+ NODE_LOCKS = ["package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]
2146
+
2147
+
2148
+ def _local_bin(name, cwd):
2149
+ """node_modules/.bin/<name> in cwd or an ancestor, the way npx looks."""
2150
+ d = os.path.realpath(cwd or ".")
2151
+ while True:
2152
+ if os.path.exists(os.path.join(d, "node_modules", ".bin", name)):
2153
+ return True
2154
+ parent = os.path.dirname(d)
2155
+ if parent == d:
2156
+ return False
2157
+ d = parent
2158
+
2159
+
2160
+ def _npx(tool, args, cwd):
2161
+ """npx / npm exec run a binary; when it is not already installed they
2162
+ fetch the package, and with no TTY they do it without asking. Only a
2163
+ binary present in node_modules/.bin passes."""
2164
+ spec = ""
2165
+ i = 0
2166
+ while i < len(args):
2167
+ a = args[i]
2168
+ if a in ("-y", "--yes") or a.startswith("--package") or a == "-p":
2169
+ return f"{tool} --yes / --package fetches a package that is not in the lockfile"
2170
+ if a in ("-c", "--call") or a.startswith("--call="):
2171
+ return f"{tool} --call runs a shell command the guard cannot see"
2172
+ if a == "--":
2173
+ spec = args[i + 1] if i + 1 < len(args) else ""
2174
+ break
2175
+ if not a.startswith("-"):
2176
+ spec = a
2177
+ break
2178
+ i += 1
2179
+ if not spec:
2180
+ return None
2181
+ name = spec[1:] if spec.startswith("@") else spec
2182
+ if "@" in name or ":" in name or name.startswith((".", "/")) and not spec.startswith("@"):
2183
+ return f"{tool} {spec[:40]} names a version, path or URL: it fetches or runs a package outside node_modules/.bin"
2184
+ if not _local_bin(spec.rsplit("/", 1)[-1], cwd):
2185
+ return f"{tool} {spec[:40]}: not installed in node_modules/.bin, so it would fetch the package"
2186
+ return None
2187
+
2188
+
2189
+ def _install(toks, cwd):
2190
+ tool = os.path.basename(toks[0]).lower()
2191
+ args = toks[1:]
2192
+ plain = [a for a in args if not a.startswith("-")]
2193
+ sub = plain[0] if plain else ""
2194
+ if tool in ("pnpx", "bunx", "uvx") or (tool in ("pnpm", "yarn") and sub == "dlx") or (tool == "bun" and sub == "x"):
2195
+ return f"{tool} {sub if tool in ('pnpm', 'yarn', 'bun') else ''}".rstrip() + " fetches and runs a package"
2196
+ if tool == "yarn" and sub == "global":
2197
+ return "yarn global changes this machine"
2198
+ if tool == "uv" and sub == "tool" and len(plain) > 1 and plain[1] in ("run", "install", "upgrade"):
2199
+ return f"uv tool {plain[1]} fetches a package"
2200
+ if tool == "pipx" and sub in ("run", "install", "inject", "upgrade", "reinstall", "install-all"):
2201
+ return f"pipx {sub} fetches a package"
2202
+ if tool == "npx" or (tool == "npm" and sub in ("exec", "x")):
2203
+ return _npx(tool, args if tool == "npx" else args[args.index(sub) + 1:], cwd)
2204
+ if tool in ("npm", "pnpm", "yarn", "bun"):
2205
+ adds = {"npm": ("install", "i", "in", "add", "isntall", "install-test", "it", "install-ci-test", "cit"),
2206
+ "pnpm": ("install", "i", "add"), "yarn": ("add",), "bun": ("install", "i", "add")}[tool]
2207
+ if sub in adds and (len(plain) > 1 or sub == "add"):
2208
+ return f"{tool} {sub} {plain[1] if len(plain) > 1 else ''}".rstrip() + " adds a package"
2209
+ if sub in ("update", "upgrade", "up", "dedupe", "upgrade-interactive"):
2210
+ return f"{tool} {sub} rewrites the lockfile"
2211
+ if "-g" in args or "--global" in args:
2212
+ return f"{tool} global install changes this machine"
2213
+ restores = sub in ("install", "i", "ci", "install-test", "it", "install-ci-test", "cit") or (tool == "yarn" and sub == "")
2214
+ if restores and _manifest_changed(NODE_LOCKS, cwd):
2215
+ return f"{tool} install after the run changed package.json or a lockfile"
2216
+ return None
2217
+ if tool in ("pip", "pip3") or (tool.startswith("python") and "-m" in args and "pip" in args):
2218
+ rest = args[args.index("pip") + 1:] if "pip" in args and tool.startswith("python") else args
2219
+ if rest and rest[0] == "install":
2220
+ reqs, pkgs, skip = [], [], False
2221
+ for i, a in enumerate(rest[1:]):
2222
+ if skip:
2223
+ skip = False
2224
+ continue
2225
+ if a in ("-r", "--requirement", "-c", "--constraint"):
2226
+ if i + 2 <= len(rest[1:]):
2227
+ reqs.append(rest[1:][i + 1])
2228
+ skip = True
2229
+ continue
2230
+ if a in ("-e", "--editable"):
2231
+ skip = True
2232
+ continue
2233
+ if not a.startswith("-") and a not in (".",):
2234
+ pkgs.append(a)
2235
+ if pkgs:
2236
+ return f"pip install {pkgs[0]} adds a package"
2237
+ if reqs and _manifest_changed([os.path.basename(r) for r in reqs] + ["pyproject.toml"], cwd):
2238
+ return "pip install -r after the run changed the requirements file"
2239
+ return None
2240
+ if tool in ("uv", "poetry", "pdm", "pipenv"):
2241
+ if sub == "add" or (tool == "uv" and sub == "pip" and len(plain) > 1 and plain[1] == "install") or (tool == "pipenv" and sub == "install" and len(plain) > 1):
2242
+ return f"{tool} {sub} adds a package"
2243
+ return None
2244
+ if tool == "pod":
2245
+ if sub == "update":
2246
+ return "pod update rewrites Podfile.lock"
2247
+ if sub == "install" and _manifest_changed(["Podfile", "Podfile.lock"], cwd):
2248
+ return "pod install after the run changed the Podfile"
2249
+ return None
2250
+ if tool == "gem" and sub == "install":
2251
+ return "gem install adds a package"
2252
+ if tool == "bundle" and sub in ("add", "update"):
2253
+ return f"bundle {sub} changes the Gemfile"
2254
+ if tool == "brew" and sub in ("install", "reinstall", "tap", "upgrade"):
2255
+ return f"brew {sub} changes this machine"
2256
+ if tool == "cargo" and sub in ("add", "install", "update"):
2257
+ return f"cargo {sub} adds or updates a package"
2258
+ if tool == "go" and sub == "get":
2259
+ return "go get adds a module"
2260
+ if tool == "go" and sub == "install" and any("@" in a for a in plain[1:]):
2261
+ return "go install <pkg>@<version> fetches a module"
2262
+ if tool == "swift" and sub == "package" and len(plain) > 1 and plain[1] in ("add-dependency", "update"):
2263
+ return f"swift package {plain[1]} changes Package.resolved"
2264
+ return None
2265
+
2266
+
2267
+ SECURITY_READS = re.compile(r"^(find-[a-z-]+|dump-keychain|export|dump-trust-settings|add-[a-z-]+password|set-[a-z-]+|delete-[a-z-]+password|import)$")
2268
+
2269
+
2270
+ WEB_SCHEMES = ("http", "https", "ws", "wss", "ftp")
2271
+
2272
+
2273
+ def _web_url_refusal(url, policy, env, tool):
2274
+ """Host-check a URL that a browser would load; an app's own deep-link
2275
+ scheme opens the app under test and passes."""
2276
+ m = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", url)
2277
+ scheme = m.group(1).lower() if m else ""
2278
+ if scheme and scheme not in WEB_SCHEMES and "://" in url:
2279
+ return None
2280
+ return check_url_host(url, policy, env, tool)
2281
+
2282
+
2283
+ LAUNCHCTL_READS = {"list", "print", "print-cache", "print-disabled", "blame", "help", "version", "error", "examine"}
2284
+ DEFAULTS_READS = {"read", "read-type", "domains", "find", "help"}
2285
+
2286
+
2287
+ def host_command_refusal(base, toks, policy, env):
2288
+ """Commands that change this machine or send outward without a write
2289
+ target the guard models: persistent jobs, preferences, distribution
2290
+ uploads, and URLs opened in a browser."""
2291
+ plains = [a for a in toks[1:] if not a.startswith("-")]
2292
+ if base == "crontab" and toks[1:] != ["-l"]:
2293
+ return "crontab installs a job that runs as the operator"
2294
+ if base == "launchctl" and not (plains and plains[0] in LAUNCHCTL_READS):
2295
+ return "launchctl changes which jobs this machine runs"
2296
+ if base == "defaults" and not (plains and plains[0] in DEFAULTS_READS):
2297
+ return "defaults writes this machine's preferences"
2298
+ if base in ("altool", "notarytool"):
2299
+ return f"{base} uploads to Apple or stores a credential"
2300
+ if base == "simctl" and plains and plains[0] == "openurl":
2301
+ return _web_url_refusal(plains[-1], policy, env, "simctl openurl") if len(plains) >= 3 else None
2302
+ if base == "open":
2303
+ for a in plains:
2304
+ if "://" not in a or not re.match(r"^(https?)://", a, re.I):
2305
+ return "open launches an application or a file the guard cannot judge"
2306
+ r = check_url_host(a, policy, env, "open")
2307
+ if r:
2308
+ return r
2309
+ return None if plains else "open with no URL cannot be judged"
2310
+ return None
2311
+
2312
+
2313
+ def _publisher(toks, policy):
2314
+ scripts = policy.get("publisherScripts", {})
2315
+ for i, t in enumerate(toks[:3]):
2316
+ base = os.path.basename(t)
2317
+ if base in scripts and not base.startswith("$"):
2318
+ subs = scripts[base]
2319
+ rest = [a for a in toks[i + 1:] if not a.startswith("-")]
2320
+ if subs is None or (rest and rest[0] in subs):
2321
+ return f"{base} publishes outward, reads a credential or changes device access; an unattended run may not"
2322
+ if rest and (_has_ph(rest[0]) or rest[0].startswith("$")):
2323
+ return f"{base} with a subcommand built at run time cannot be judged"
2324
+ return None
2325
+
2326
+
2327
+ # --- variables, cwd and write targets -----------------------------------------
2328
+
2329
+ VAR_RX = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
2330
+
2331
+
2332
+ def expand(text, variables, env=None):
2333
+ """text with $NAME / ${NAME} expanded from the variables this command set,
2334
+ then HOME, then the hook's environment. None when any part cannot be
2335
+ resolved: a substitution placeholder, an unknown variable, or a `$` form
2336
+ other than a plain name."""
2337
+ if _has_ph(text):
2338
+ return None
2339
+ env = os.environ if env is None else env
2340
+ unresolved = False
2341
+
2342
+ def sub(m):
2343
+ nonlocal unresolved
2344
+ name = m.group(1) or m.group(2)
2345
+ if name in variables:
2346
+ v = variables[name]
2347
+ elif name == "HOME":
2348
+ v = home()
2349
+ else:
2350
+ v = env.get(name)
2351
+ if v is None:
2352
+ unresolved = True
2353
+ return ""
2354
+ return v
2355
+
2356
+ out = VAR_RX.sub(sub, text)
2357
+ if unresolved or "$" in out:
2358
+ return None
2359
+ return out
2360
+
2361
+
2362
+ class Ctx:
2363
+ """Per-command state: the cwd after any cd (None once it cannot be
2364
+ resolved), and the variables set by earlier simple assignments."""
2365
+
2366
+ def __init__(self, cwd, policy, env, variables=None, inners=None):
2367
+ self.cwd = cwd
2368
+ self.policy = policy
2369
+ self.env = env
2370
+ self.vars = dict(variables or {})
2371
+ self.inners = inners or []
2372
+ # Set per segment: whether the segment has a glob character outside
2373
+ # quotes, so a globbed write target can be told from a quoted one.
2374
+ self.unquoted_glob = False
2375
+
2376
+ def target(self, raw):
2377
+ """Reason a write target is refused, or None."""
2378
+ path = raw.strip("'\"")
2379
+ if path in ("/dev/null", "/dev/stderr", "/dev/stdout") or path.startswith("&"):
2380
+ return None
2381
+ if self.unquoted_glob and any(ch in path for ch in GLOB_CHARS):
2382
+ return f"write target '{path[:60]}' is a glob pattern the shell expands to paths the guard cannot see"
2383
+ e = expand(path, self.vars, self.env)
2384
+ if e is None:
2385
+ return f"write target '{path[:60]}' is built at run time and cannot be checked"
2386
+ e = os.path.expanduser(e)
2387
+ if self.cwd is None and not os.path.isabs(e):
2388
+ return f"write target '{path[:60]}' is relative to a directory the guard could not resolve"
2389
+ return check_path(e, self.cwd, self.policy, self.env)
2390
+
2391
+
2392
+ def check_segment(seg, ctx):
2393
+ policy, env = ctx.policy, ctx.env
2394
+ r = dangerous_assignment(tokenize(seg))
2395
+ if r:
2396
+ return r
2397
+ toks = _strip_wrappers(tokenize(seg))
2398
+ if not toks:
2399
+ return None
2400
+ if "$" in toks[0]:
2401
+ e = expand(toks[0], ctx.vars, env)
2402
+ if e is None:
2403
+ return "the command name is built at run time and cannot be judged"
2404
+ toks = [e] + toks[1:]
2405
+ base = os.path.basename(toks[0]).lower()
2406
+
2407
+ def targets(ts):
2408
+ for t in ts:
2409
+ r = ctx.target(t)
2410
+ if r:
2411
+ return r
2412
+ return None
2413
+
2414
+ if base == "git":
2415
+ r = git_config_refusal(toks) or git_global_flag_refusal(toks)
2416
+ if r:
2417
+ return r
2418
+ sub, rest = _git_sub(toks)
2419
+ if _has_ph(sub) or sub.startswith("$"):
2420
+ return "a git subcommand built at run time cannot be judged"
2421
+ r = git_exec_refusal(sub, rest, toks)
2422
+ if r:
2423
+ return r
2424
+ r = git_url_refusal(sub, rest, toks, policy, env)
2425
+ if r:
2426
+ return r
2427
+ # git apply / am write files a diff names, which the guard cannot
2428
+ # enumerate. The run's own work is committed, not applied from a patch;
2429
+ # refused unattended, and the sandbox confines the paths if it ran.
2430
+ if sub in ("apply", "am") and not any(
2431
+ a.startswith("--directory") for a in rest
2432
+ ):
2433
+ return f"git {sub} applies a diff that names files the guard cannot enumerate; refused unattended"
2434
+ if sub == "push":
2435
+ return "git push: the runner pushes after it verifies pr-request.json"
2436
+ if sub == "send-email":
2437
+ return "git send-email sends mail"
2438
+ if sub == "remote" and rest and rest[0] in ("add", "set-url", "rename", "remove", "rm", "set-head"):
2439
+ return f"git remote {rest[0]} changes where the runner would push"
2440
+ if sub == "config":
2441
+ r = _git_config(rest)
2442
+ if r:
2443
+ return r
2444
+ return targets(extractor_write_targets(toks))
2445
+ if base == "gh":
2446
+ plains = [a for a in toks[1:] if not a.startswith("-")]
2447
+ if any(_has_ph(a) or a.startswith("$") for a in plains[:2]):
2448
+ return "a gh command group or verb built at run time cannot be judged"
2449
+ return _gh(toks, policy, env)
2450
+ if base == "security" and len(toks) > 1:
2451
+ j = 1
2452
+ while j < len(toks) and toks[j].startswith("-"):
2453
+ j += 2 if toks[j] == "-p" else 1
2454
+ sub = toks[j] if j < len(toks) else ""
2455
+ if SECURITY_READS.match(sub):
2456
+ return f"security {sub} reads or writes the Keychain"
2457
+ if _has_ph(sub) or sub.startswith("$"):
2458
+ return "a security subcommand built at run time cannot be judged"
2459
+ r = network_tool_refusal(base)
2460
+ if r:
2461
+ return r
2462
+ if base == "rsync":
2463
+ # rsync src host:path (or host::module) transfers to a remote the
2464
+ # allowlist cannot check; a local `-e` remote shell is the same.
2465
+ for a in toks[1:]:
2466
+ if a.startswith("-"):
2467
+ continue
2468
+ if re.match(r"^[A-Za-z0-9_.-]+@?[A-Za-z0-9_.-]*:", a) and not os.path.isabs(a) and "://" not in a:
2469
+ return "rsync to a remote host cannot be checked against the allowlist; refused unattended"
2470
+ if any(a in ("-e", "--rsh") or a.startswith("--rsh=") or a.startswith("-e") and len(a) > 2 for a in toks[1:]):
2471
+ return "rsync -e runs a remote shell the guard cannot see"
2472
+ r = host_command_refusal(base, toks, policy, env)
2473
+ if r:
2474
+ return r
2475
+ if base in ("curl", "wget"):
2476
+ r = _fetch(toks, policy, env)
2477
+ if r:
2478
+ return r
2479
+ return targets(extractor_write_targets(toks))
2480
+ if base == "patch":
2481
+ return "patch applies a diff that names files the guard cannot enumerate; refused unattended"
2482
+ if base in ("tar", "gtar", "bsdtar"):
2483
+ # An archive extraction whose members carry absolute paths (-P) writes
2484
+ # anywhere; without -P the paths are under the extraction directory,
2485
+ # which -C names and check_path judges (the sandbox confines the rest).
2486
+ if any(_short_cluster_has(a, "P") or a == "--absolute-names" for a in toks[1:]):
2487
+ return "tar -P / --absolute-names extracts to absolute paths the guard cannot enumerate"
2488
+ r = targets(extractor_write_targets(toks))
2489
+ if r:
2490
+ return r
2491
+ if base in ("unzip", "cpio", "pax", "7z", "7za", "diff", "gdiff", "xcodebuild"):
2492
+ r = targets(extractor_write_targets(toks))
2493
+ if r:
2494
+ return r
2495
+ if base == "swift":
2496
+ r = targets(extractor_write_targets(toks))
2497
+ if r:
2498
+ return r
2499
+ plains = [a for a in toks[1:] if not a.startswith("-")]
2500
+ if base in ("npm", "pnpm", "yarn", "bun", "npx", "pip", "pip3", "uv", "poetry", "pdm", "pipenv", "pod", "gem",
2501
+ "bundle", "brew", "cargo", "go", "swift") and plains and _has_ph(plains[0]):
2502
+ return f"{base} with a subcommand built at run time cannot be judged"
2503
+ r = _install(toks, ctx.cwd or os.getcwd())
2504
+ if r:
2505
+ return r
2506
+ r = _publisher(toks, policy)
2507
+ if r:
2508
+ return r
2509
+ return targets(write_targets(toks))
2510
+
2511
+
2512
+ REDIRECT = re.compile(r"(?:^|[^0-9&<>])(?:\d?>>?|&>>?|>\|)\s*([^\s;&|<>]+)")
2513
+ ASSIGN_RX = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.S)
2514
+
2515
+
2516
+ def _cd(stripped, ctx):
2517
+ """New cwd after cd/pushd/popd: a resolved absolute path, or None."""
2518
+ cmd = os.path.basename(stripped[0]).lower()
2519
+ if cmd == "popd":
2520
+ return None
2521
+ # `cd -` returns to $OLDPWD, which the guard does not track: the new cwd is
2522
+ # unknown, so anything after it is judged with no directory (and refused if
2523
+ # it needs one). Caught before the flag filter drops the "-".
2524
+ if any(t == "-" for t in stripped[1:]):
2525
+ return None
2526
+ args = [t for t in stripped[1:] if not t.startswith("-")]
2527
+ if not args:
2528
+ return home() if cmd == "cd" else None
2529
+ e = expand(args[0], ctx.vars, ctx.env)
2530
+ if e is None or any(ch in e for ch in GLOB_CHARS):
2531
+ return None
2532
+ e = os.path.expanduser(e)
2533
+ if not os.path.isabs(e):
2534
+ # With CDPATH set, `cd name` may land in a CDPATH entry instead.
2535
+ cdpath = (os.environ if ctx.env is None else ctx.env).get("CDPATH")
2536
+ if cdpath and not (e == "." or e == ".." or e.startswith("./") or e.startswith("../")):
2537
+ return None
2538
+ if ctx.cwd is None:
2539
+ return None
2540
+ e = os.path.join(ctx.cwd, e)
2541
+ return os.path.normpath(e)
2542
+
2543
+
2544
+ def check_bash(cmd, cwd, policy, env=None, variables=None, nested=False):
2545
+ try:
2546
+ if not nested:
2547
+ cmd = strip_heredocs(cmd)
2548
+ outer, inners = extract_substitutions(cmd)
2549
+ except Refused as e:
2550
+ return str(e)
2551
+ if nested and inners:
2552
+ return "a command substitution nested inside another is not analysed"
2553
+ r = structural_refusal(outer)
2554
+ if r:
2555
+ return r
2556
+ ctx = Ctx(cwd or os.getcwd(), policy, env, variables, inners)
2557
+ for seg in split_ops(outer):
2558
+ # Every substitution in this segment runs first, in the segment's cwd,
2559
+ # and must pass the same checks as if it were typed directly.
2560
+ for m in PLACEHOLDER_RX.finditer(seg):
2561
+ inner = inners[int(m.group(1))]
2562
+ if not inner.strip():
2563
+ return "an empty command substitution"
2564
+ if ctx.cwd is None:
2565
+ return "a command substitution after a cd the guard could not resolve"
2566
+ r = check_bash(inner, ctx.cwd, policy, env, ctx.vars, nested=True)
2567
+ if r:
2568
+ return f"inside $(...): {r}"
2569
+ toks = tokenize(seg)
2570
+ ctx.unquoted_glob = any(ch in _strip_quotes(seg, keep_double=False) for ch in GLOB_CHARS)
2571
+ r = simple_command_refusal(seg, toks, ctx.vars, env, ctx.cwd)
2572
+ if r:
2573
+ return r
2574
+ stripped = _strip_wrappers(toks)
2575
+ if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd") and ctx.unquoted_glob:
2576
+ if any(ch in a for a in stripped[1:] for ch in GLOB_CHARS):
2577
+ return "cd into a glob pattern moves the run to a directory the guard cannot see"
2578
+ try:
2579
+ redirects = redirect_targets(seg)
2580
+ except Refused as e:
2581
+ return str(e)
2582
+ for t in redirects:
2583
+ r = ctx.target(t)
2584
+ if r:
2585
+ return r
2586
+ r = check_segment(seg, ctx)
2587
+ if r:
2588
+ return r
2589
+ stripped = _strip_wrappers(toks)
2590
+ if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd", "popd"):
2591
+ ctx.cwd = _cd(stripped, ctx)
2592
+ elif toks and all(ASSIGN_RX.match(t) for t in toks):
2593
+ # A bare assignment line: remember what it set for later expansion.
2594
+ for t in toks:
2595
+ name, value = ASSIGN_RX.match(t).groups()
2596
+ ctx.vars[name] = expand(value, ctx.vars, env)
2597
+ return None
2598
+
2599
+
2600
+ TOOLKIT_PREFIX = "mcp__multi-agent-toolkit__"
2601
+
2602
+
2603
+ # Keys a toolkit tool uses for a filesystem destination it writes. Checked with
2604
+ # check_path so a toolkit call cannot write a screenshot, recording, build
2605
+ # output or export over a protected path. MCP tools run outside the OS sandbox,
2606
+ # so this is their only path boundary.
2607
+ TOOLKIT_PATH_KEYS = ("path", "filename", "out_dir", "output_path", "output_dir",
2608
+ "export_path", "derived_data_path", "result_bundle_path",
2609
+ "figma_path", "screenshot_path", "video_path", "file_path")
2610
+
2611
+
2612
+ def _toolkit_path_refusal(name, ti, policy, env, cwd):
2613
+ for key, value in ti.items():
2614
+ if not isinstance(value, str) or not value:
2615
+ continue
2616
+ if key in TOOLKIT_PATH_KEYS or key.endswith("_path"):
2617
+ r = check_path(value, cwd, policy, env)
2618
+ if r:
2619
+ return f"{name} {key}: {r}"
2620
+ return None
2621
+
2622
+
2623
+ def check_toolkit(name, ti, policy, env, depth=0, cwd=None):
2624
+ """A multi-agent-toolkit tool call, by its short name (web_goto,
2625
+ ios_open_url, agent_run_steps). agent_run_steps dispatches other toolkit
2626
+ tools, so each step is judged as if it were called directly. Every
2627
+ path-typed argument is checked, including inside a step."""
2628
+ if not isinstance(ti, dict):
2629
+ return f"{name} arguments are not an object the guard can read"
2630
+ if name.startswith("research_"):
2631
+ return f"{name} sends the query to an outside service"
2632
+ if name == "web_eval":
2633
+ return "web_eval runs arbitrary JavaScript that can reach any host; refused unattended"
2634
+ if name == "agent_run_steps":
2635
+ steps = ti.get("steps")
2636
+ if depth or not isinstance(steps, list):
2637
+ return "agent_run_steps without a readable list of steps cannot be judged"
2638
+ for step in steps:
2639
+ if not isinstance(step, dict) or not isinstance(step.get("tool"), str):
2640
+ return "an agent_run_steps step without a tool name cannot be judged"
2641
+ sub = step["tool"]
2642
+ sub = sub[len(TOOLKIT_PREFIX):] if sub.startswith(TOOLKIT_PREFIX) else sub
2643
+ if sub.startswith("agent_"):
2644
+ return "agent_run_steps may not nest another agent_* tool"
2645
+ r = check_toolkit(sub, step.get("args") if step.get("args") is not None else {}, policy, env, depth + 1, cwd)
2646
+ if r:
2647
+ return f"agent_run_steps step {sub}: {r}"
2648
+ return None
2649
+ r = _toolkit_path_refusal(name, ti, policy, env, cwd)
2650
+ if r:
2651
+ return r
2652
+ if "url" in ti and not isinstance(ti["url"], str):
2653
+ # A non-string url (a list, an object) is not something the guard can
2654
+ # host-check; a web tool handed one is refused rather than waved through.
2655
+ return f"{name} was given a url that is not a string; the host cannot be checked"
2656
+ url = ti.get("url")
2657
+ if not isinstance(url, str) or not url:
2658
+ return None
2659
+ if name.startswith("web_"):
2660
+ return check_url_host(url, policy, env, name)
2661
+ return _web_url_refusal(url, policy, env, name)
2662
+
2663
+
2664
+ def check(payload, env=None):
2665
+ """Reason string when the tool call is blocked, None when it is allowed."""
2666
+ policy = load_policy()
2667
+ tool = payload.get("tool_name") or ""
2668
+ ti = payload.get("tool_input") or {}
2669
+ cwd = payload.get("cwd") or os.getcwd()
2670
+ if tool in ("Edit", "Write", "NotebookEdit", "MultiEdit"):
2671
+ return check_path(ti.get("file_path") or ti.get("notebook_path") or "", cwd, policy, env)
2672
+ if tool.startswith(TOOLKIT_PREFIX):
2673
+ return check_toolkit(tool[len(TOOLKIT_PREFIX):], ti, policy, env, cwd=cwd)
2674
+ if tool == "WebFetch":
2675
+ url = ti.get("url")
2676
+ if isinstance(url, str) and url:
2677
+ return check_url_host(url, policy, env, "WebFetch")
2678
+ return None
2679
+ cmd = ti.get("command")
2680
+ if isinstance(cmd, str) and cmd:
2681
+ return check_bash(cmd, cwd, policy, env)
2682
+ return None
2683
+
2684
+
2685
+ # --- git subcommand tokenizer, reused by pre-commit-check.sh ------------------
2686
+
2687
+ GIT_COMMIT_LIKE = {"commit", "commit-tree", "merge", "cherry-pick", "revert", "am"}
2688
+
2689
+
2690
+ def git_subcommands(cmd, depth=0):
2691
+ """Every git subcommand across the simple commands of a shell line,
2692
+ including those inside `$(...)`, using the same wrapper-stripping and
2693
+ casefolding the guard uses. Refuse-structure is NOT applied here: the
2694
+ caller (pre-commit) only needs to know whether a commit happens."""
2695
+ subs = []
2696
+ inners = []
2697
+ if depth == 0:
2698
+ try:
2699
+ cmd = strip_heredocs(cmd)
2700
+ except Refused:
2701
+ pass
2702
+ if depth < 3:
2703
+ try:
2704
+ cmd, inners = extract_substitutions(cmd)
2705
+ except Refused:
2706
+ inners = []
2707
+ for seg in split_ops(cmd):
2708
+ toks = _strip_wrappers(tokenize(seg))
2709
+ if not toks:
2710
+ continue
2711
+ if os.path.basename(toks[0]).lower() == "git":
2712
+ sub, rest = _git_sub(toks)
2713
+ if sub:
2714
+ subs.append(sub)
2715
+ # A git alias could expand to a commit; treat an alias as commit-like.
2716
+ if git_config_refusal(toks):
2717
+ subs.append("commit")
2718
+ for inner in inners:
2719
+ subs.extend(git_subcommands(inner, depth + 1))
2720
+ return subs
2721
+
2722
+
2723
+ COMMIT_WRITERS = ("commit", "commit-tree", "merge", "cherry-pick", "revert", "am")
2724
+
2725
+
2726
+ def _git_dir_opt(toks):
2727
+ """The directory named by git's global `-C`, or None."""
2728
+ i = 1
2729
+ while i < len(toks):
2730
+ t = toks[i]
2731
+ if t == "-C" and i + 1 < len(toks):
2732
+ return toks[i + 1]
2733
+ if t in ("-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
2734
+ i += 2
2735
+ elif t.startswith("-"):
2736
+ i += 1
2737
+ else:
2738
+ return None
2739
+ return None
2740
+
2741
+
2742
+ def _literal_dir(arg, cwd):
2743
+ """`arg` as an absolute directory, expanding a leading `~` / `$HOME` /
2744
+ `${HOME}` from the environment; "" when anything else would need the shell
2745
+ (a variable, a substitution, a glob, `~user`, `-`) or the base is unknown."""
2746
+ home = os.environ.get("HOME", "")
2747
+ for prefix in ("${HOME}", "$HOME", "~"):
2748
+ if home and (arg == prefix or arg.startswith(prefix + "/")):
2749
+ arg = home + arg[len(prefix):]
2750
+ break
2751
+ if not arg or arg == "-" or any(ch in arg for ch in "$`~*?"):
2752
+ return ""
2753
+ if os.path.isabs(arg):
2754
+ return os.path.normpath(arg)
2755
+ if cwd == "":
2756
+ return ""
2757
+ return os.path.normpath(os.path.join(cwd, arg))
2758
+
2759
+
2760
+ def git_commit_dirs(cmd, base):
2761
+ """The directory each commit-writing git command on a shell line runs in,
2762
+ following `cd`/`pushd` and git's `-C` from `base`. "" marks a directory the
2763
+ line names in a way that cannot be read literally (`cd "$X"`, `cd -`)."""
2764
+ try:
2765
+ cmd = strip_heredocs(cmd)
2766
+ except Refused:
2767
+ pass
2768
+ cwd = base
2769
+ dirs = []
2770
+ for seg in split_ops(cmd):
2771
+ toks = _strip_wrappers(tokenize(seg))
2772
+ if not toks:
2773
+ continue
2774
+ head = os.path.basename(toks[0]).lower()
2775
+ if head in ("cd", "pushd"):
2776
+ args = [t for t in toks[1:] if t == "-" or not t.startswith("-")]
2777
+ cwd = _literal_dir(args[0], cwd) if args else ""
2778
+ continue
2779
+ if head != "git":
2780
+ continue
2781
+ sub, _ = _git_sub(toks)
2782
+ if sub not in COMMIT_WRITERS:
2783
+ continue
2784
+ opt = _git_dir_opt(toks)
2785
+ dirs.append(cwd if opt is None else _literal_dir(opt, cwd))
2786
+ return dirs