@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,1308 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import picomatch from 'picomatch';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { SmithError } from './errors.js';
6
+ import { runGit } from './git.js';
7
+ import { GUARDRAILS_POLICY_PATH, REPO_ROOT } from './paths.js';
8
+ /**
9
+ * Blacksmith's guard hook, moved off bash-and-regex-in-a-shell-script and
10
+ * onto data (factory/policies/guardrails.yml) plus tested TypeScript
11
+ * (here). `.claude/hooks/guard.sh` used to hold both: the rule content
12
+ * (branch names, allowed roots, the message an agent sees) tangled together
13
+ * with the matcher that decided whether a given command tripped it, and
14
+ * neither half had a test. This module is the matcher half — every function
15
+ * below is a direct, behaviour-preserving port of one piece of guard.sh, and
16
+ * each one is exercised in test/policy.test.ts. Two things bash could not do
17
+ * well are fixed in the port, not added to it: path normalization for the
18
+ * `rm -rf` rule uses `path.resolve`/`path.relative` instead of shelling out
19
+ * to `realpath -m` (which does not exist on every platform this runs on),
20
+ * and that same normalization is `path.sep`-aware rather than bash's
21
+ * `/`-only `case` glob — see `normalizeRemovalPath`.
22
+ *
23
+ * `smith policy check` and `smith policy hook` (cli.ts) are the two CLI
24
+ * callers, both routed through `hookDecision.ts`; `.claude/hooks/guard.sh`
25
+ * execs `dist/policyHook.js`, an entry point over that same decision whose
26
+ * import graph is only what deciding needs.
27
+ */
28
+ export class PolicyError extends SmithError {
29
+ }
30
+ /**
31
+ * The six rules guard.sh has always enforced, plus the three the judge
32
+ * sandbox adds. `parseGuardrailPolicy` rejects a guardrails.yml missing any
33
+ * of these outright, rather than silently loading a policy with fewer rules
34
+ * than the file that shipped it — a guard-hook rule that fails to load is
35
+ * not "off by default", it is a hole. The judge rules are required on the
36
+ * same terms even though they fire only under a lease: a policy file that
37
+ * cannot name them is a policy file that would run judges unguarded and
38
+ * report nothing wrong. `role-write-scope` is required for the mirror of
39
+ * that reason: it is the mechanism that keeps a tester out of the code it is
40
+ * grading, and a policy that cannot name it either blocks the tester
41
+ * entirely or lets it mark its own homework.
42
+ */
43
+ const REQUIRED_RULE_IDS = [
44
+ 'push-to-protected',
45
+ 'force-push',
46
+ 'merge-into-protected',
47
+ 'deploy-command',
48
+ 'history-rewrite-on-protected',
49
+ 'unbounded-rm',
50
+ 'judge-network',
51
+ 'judge-write',
52
+ 'judge-sandbox-escape',
53
+ 'role-write-scope',
54
+ ];
55
+ function isStringArray(value) {
56
+ return Array.isArray(value) && value.every((v) => typeof v === 'string');
57
+ }
58
+ function requireStringArray(value, key) {
59
+ if (!isStringArray(value)) {
60
+ throw new PolicyError('policy.invalid-document', `guardrails.yml is missing ${key} (a string list).`, { key });
61
+ }
62
+ return value;
63
+ }
64
+ /**
65
+ * `deploy_commands` and `judge_sandbox.network_subcommands` are the same
66
+ * shape — a command word plus the subcommands that make it matter — so they
67
+ * parse through one function rather than two copies that drift.
68
+ */
69
+ function parseCommandSpecs(raw, key) {
70
+ if (!Array.isArray(raw)) {
71
+ throw new PolicyError('policy.invalid-document', `guardrails.yml is missing ${key} (a list).`, {
72
+ key,
73
+ });
74
+ }
75
+ return raw.map((entry, index) => {
76
+ const e = entry;
77
+ if (typeof e.command !== 'string' || !isStringArray(e.subcommands)) {
78
+ throw new PolicyError('policy.invalid-document', `guardrails.yml ${key}[${index}] needs a string "command" and a string-list "subcommands".`, { key, index });
79
+ }
80
+ return { command: e.command, subcommands: e.subcommands };
81
+ });
82
+ }
83
+ /** Fold one `rules:` list into the shared id-keyed map. Used for the top-level list and the judge_sandbox one. */
84
+ function collectRules(raw, into, key) {
85
+ if (!Array.isArray(raw)) {
86
+ throw new PolicyError('policy.invalid-document', `guardrails.yml is missing ${key} (a list).`, {
87
+ key,
88
+ });
89
+ }
90
+ for (const entry of raw) {
91
+ const r = entry;
92
+ if (typeof r.id !== 'string' ||
93
+ typeof r.severity !== 'string' ||
94
+ typeof r.reason !== 'string') {
95
+ throw new PolicyError('policy.invalid-document', `guardrails.yml has a rule in ${key} missing a string id/severity/reason.`, { key, rule: entry });
96
+ }
97
+ into.set(r.id, {
98
+ id: r.id,
99
+ severity: r.severity,
100
+ reason: r.reason,
101
+ reasonOnCurrentBranch: typeof r.reason_on_current_branch === 'string' ? r.reason_on_current_branch : null,
102
+ });
103
+ }
104
+ }
105
+ /**
106
+ * Parse guardrails.yml. Throws `PolicyError` on anything short of the full
107
+ * shape — a guard-hook policy file with a typo'd key must fail loudly, not
108
+ * load as a policy with an empty `rules` map that denies nothing.
109
+ */
110
+ /**
111
+ * `role_write_scopes.scopes` — one entry per role that writes under a lease.
112
+ *
113
+ * A role declared here must not also be a judge role: the two rule sets are
114
+ * selected by role, so an overlap is a question with no answer, and guessing
115
+ * one silently is exactly the kind of quiet mis-enforcement this whole
116
+ * module exists to end.
117
+ */
118
+ function parseRoleWriteScopes(raw, judgeRoles) {
119
+ if (!Array.isArray(raw)) {
120
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing role_write_scopes.scopes (a list of role scopes).');
121
+ }
122
+ const scopes = [];
123
+ for (const [index, entry] of raw.entries()) {
124
+ const key = `role_write_scopes.scopes[${index}]`;
125
+ if (typeof entry !== 'object' || entry === null) {
126
+ throw new PolicyError('policy.invalid-document', `guardrails.yml ${key} is not a mapping.`);
127
+ }
128
+ const item = entry;
129
+ const role = item.role;
130
+ if (typeof role !== 'string' || role === '') {
131
+ throw new PolicyError('policy.invalid-document', `guardrails.yml ${key} is missing role.`);
132
+ }
133
+ if (judgeRoles.includes(role)) {
134
+ throw new PolicyError('policy.invalid-document', `guardrails.yml declares "${role}" as both a judge role and a write-scoped role; a role gets one rule set, not two.`, { role });
135
+ }
136
+ if (scopes.some((s) => s.role === role)) {
137
+ throw new PolicyError('policy.invalid-document', `guardrails.yml declares role "${role}" twice under role_write_scopes.scopes.`, { role });
138
+ }
139
+ const writeGlobs = requireStringArray(item.write_globs, `${key}.write_globs`);
140
+ if (writeGlobs.length === 0) {
141
+ throw new PolicyError('policy.invalid-document', `guardrails.yml ${key}.write_globs is empty; a scope that permits nothing blocks the role instead of bounding it.`, { role });
142
+ }
143
+ scopes.push({
144
+ role,
145
+ writeGlobs,
146
+ refusedCommands: requireStringArray(item.refused_commands, `${key}.refused_commands`),
147
+ refusedGitSubcommands: requireStringArray(item.refused_git_subcommands, `${key}.refused_git_subcommands`),
148
+ });
149
+ }
150
+ return scopes;
151
+ }
152
+ /** What `catch_up_remotes` means when guardrails.yml does not say — the strict reading. */
153
+ const DEFAULT_CATCH_UP_REMOTES = ['origin'];
154
+ export function parseGuardrailPolicy(yamlText) {
155
+ const doc = (parseYaml(yamlText) ?? {});
156
+ const names = doc.protected_branches?.names;
157
+ if (!isStringArray(names)) {
158
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing protected_branches.names (a string list).');
159
+ }
160
+ const patterns = doc.protected_branches?.patterns;
161
+ if (!isStringArray(patterns)) {
162
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing protected_branches.patterns (a string list).');
163
+ }
164
+ // The one optional key in this file. Every other field throws when it is
165
+ // missing, because a policy that silently loses a rule is worse than one
166
+ // that refuses to load — but this key only ever *narrows* rule 3, and a
167
+ // guardrails.yml written before it existed should keep working. Absent
168
+ // means the strict reading, not the permissive one.
169
+ const catchUpRemotes = doc.catch_up_remotes ?? DEFAULT_CATCH_UP_REMOTES;
170
+ if (!isStringArray(catchUpRemotes)) {
171
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml has a catch_up_remotes that is not a string list.');
172
+ }
173
+ const allowedRoots = doc.destructive_removal?.allowed_roots;
174
+ if (!isStringArray(allowedRoots)) {
175
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing destructive_removal.allowed_roots (a string list).');
176
+ }
177
+ const deployCommands = parseCommandSpecs(doc.deploy_commands, 'deploy_commands');
178
+ const rawSandbox = doc.judge_sandbox;
179
+ if (rawSandbox === undefined || rawSandbox === null) {
180
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing judge_sandbox (the judge sandbox rule data).');
181
+ }
182
+ const judgeSandbox = {
183
+ roles: requireStringArray(rawSandbox.roles, 'judge_sandbox.roles'),
184
+ writeRoots: requireStringArray(rawSandbox.write_roots, 'judge_sandbox.write_roots'),
185
+ networkCommands: requireStringArray(rawSandbox.network_commands, 'judge_sandbox.network_commands'),
186
+ networkSubcommands: parseCommandSpecs(rawSandbox.network_subcommands, 'judge_sandbox.network_subcommands'),
187
+ targetWriteCommands: requireStringArray(rawSandbox.target_write_commands, 'judge_sandbox.target_write_commands'),
188
+ refusedWriteCommands: requireStringArray(rawSandbox.refused_write_commands, 'judge_sandbox.refused_write_commands'),
189
+ gitWriteSubcommands: requireStringArray(rawSandbox.git_write_subcommands, 'judge_sandbox.git_write_subcommands'),
190
+ };
191
+ const rawScopes = doc.role_write_scopes;
192
+ if (rawScopes === undefined || rawScopes === null) {
193
+ throw new PolicyError('policy.invalid-document', 'guardrails.yml is missing role_write_scopes (the per-role write bounds).');
194
+ }
195
+ const roleWriteScopes = parseRoleWriteScopes(rawScopes.scopes, judgeSandbox.roles);
196
+ const rules = new Map();
197
+ collectRules(doc.rules, rules, 'rules');
198
+ collectRules(rawSandbox.rules, rules, 'judge_sandbox.rules');
199
+ collectRules(rawScopes.rules, rules, 'role_write_scopes.rules');
200
+ for (const requiredId of REQUIRED_RULE_IDS) {
201
+ if (!rules.has(requiredId)) {
202
+ throw new PolicyError('policy.invalid-document', `guardrails.yml is missing required rule "${requiredId}".`, { rule: requiredId });
203
+ }
204
+ }
205
+ return {
206
+ protectedBranchNames: names,
207
+ protectedBranchPatterns: patterns,
208
+ catchUpRemotes,
209
+ allowedRemovalRoots: allowedRoots,
210
+ deployCommands,
211
+ judgeSandbox,
212
+ roleWriteScopes,
213
+ rules,
214
+ };
215
+ }
216
+ /**
217
+ * Every role `smith sandbox open --role` accepts — the judge roles plus the
218
+ * write-scoped ones. A role outside this list is a typo or an attempt to
219
+ * pick a rule set by naming one that does not exist; sandbox.ts refuses it
220
+ * at lease time so the mistake surfaces there rather than as silently
221
+ * stricter enforcement three commands later.
222
+ */
223
+ export function sandboxRoles(policy = loadGuardrailPolicy()) {
224
+ return [...policy.judgeSandbox.roles, ...policy.roleWriteScopes.map((s) => s.role)];
225
+ }
226
+ export function loadGuardrailPolicy(filePath = GUARDRAILS_POLICY_PATH) {
227
+ return parseGuardrailPolicy(readFileSync(filePath, 'utf8'));
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // bash-equivalent matchers — one function per guard.sh helper, same name in
231
+ // spirit, same behaviour. See guard.sh's own comments for the trade-offs
232
+ // repeated here; they are not re-argued twice.
233
+ // ---------------------------------------------------------------------------
234
+ function escapeRegExp(text) {
235
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
236
+ }
237
+ /**
238
+ * Chain-aware subcommand detector, ported from guard.sh's `is_git_subcmd`.
239
+ * Global flags (including value-taking ones like `-C <path>`) sit between
240
+ * `git` and the subcommand, so this does not try to match position — it
241
+ * requires `git` and the subcommand word to appear in the same chain segment
242
+ * (no `;`, `&`, `|` between them). This is intentionally loose (same
243
+ * trade-off as the `rm -rf` detector below): it can over-match a subcommand
244
+ * word appearing elsewhere in the same segment (e.g. a commit message), but
245
+ * it never under-matches on global-flag placement, which is the
246
+ * security-relevant direction for a deny gate.
247
+ *
248
+ * The one place it is deliberately tight is the hyphen. `\b` treats `-` as a
249
+ * boundary, so a bare `\bmerge\b` matches `git merge-base` — a read-only
250
+ * plumbing command that cannot merge anything, and one this repo's own tooling
251
+ * runs constantly. Every hyphenated `<subcommand>-*` in git is a separate
252
+ * command from `<subcommand>`, so requiring the word to be neither preceded
253
+ * nor followed by a hyphen costs no coverage on `push`, `merge` or `rebase`
254
+ * and removes a class of false deny that teaches operators to route around
255
+ * the gate.
256
+ */
257
+ function isGitSubcommand(command, subcommand) {
258
+ return new RegExp(`\\bgit\\b[^;&|]*${bareWord(subcommand)}`, 'i').test(command);
259
+ }
260
+ /** `subcommand` as a whole word, excluding a hyphenated neighbour — see `isGitSubcommand`. */
261
+ function bareWord(word) {
262
+ return `(?<!-)\\b${word}\\b(?!-)`;
263
+ }
264
+ /**
265
+ * EVERY chain segment containing `git ... <word>`, used to inspect the
266
+ * destination ref precisely instead of loose substring matching. Ported from
267
+ * guard.sh's `git_segment_for`, with the same deliberate behaviour change
268
+ * `checkUnboundedRm` makes below: bash returned the *first* matching segment
269
+ * and stopped, so `git push origin feat/x; git push origin main` was read as
270
+ * a push to `feat/x`, allowed, and the second half never looked at. A chain
271
+ * is one tool call and every command in it runs, so every command in it gets
272
+ * inspected.
273
+ */
274
+ function gitSegmentsFor(command, word) {
275
+ const re = new RegExp(`\\bgit\\b.*${bareWord(word)}`, 'i');
276
+ return splitChainSegments(command).filter((s) => re.test(s));
277
+ }
278
+ /**
279
+ * The refs a `git push` names: every token after the `push` word that is not a
280
+ * flag, quotes stripped, so `isProtectedRef` can read a destination precisely
281
+ * rather than by a substring match that would false-block `git push origin
282
+ * fix-main-config`.
283
+ *
284
+ * Replaces guard.sh's `last_token`, which this file ported and which read the
285
+ * destination as the segment's *last* whitespace-separated token. That is the
286
+ * ref only when the segment ends at the push, and a segment ends at the push
287
+ * only when nothing follows it — no redirect, no comment, no closing `)` of a
288
+ * substitution. `git push origin main > /dev/null` handed the rule
289
+ * `/dev/null`, and the most ordinary way to write a quiet push turned the gate
290
+ * off. Reading every operand instead needs no guess about which one is the
291
+ * destination: if any of them is a protected ref, the push is refused.
292
+ *
293
+ * Flags are dropped rather than paired with their values, so a flag that takes
294
+ * one (`git push --repo main ...`) leaves that value in the list. That
295
+ * over-refuses on a value spelled exactly `main`, which is the direction this
296
+ * file errs in everywhere else.
297
+ */
298
+ function pushOperands(segment) {
299
+ return gitOperands(segment, 'push');
300
+ }
301
+ /**
302
+ * The same read, for any subcommand: every token after the subcommand word
303
+ * that is not a flag, quotes stripped. `pushOperands` is this function under
304
+ * the name rule 1 reads it by, and rule 3 reads a catch-up's source with it.
305
+ * One parser, because a second copy of a rule's reading is a copy that drifts
306
+ * out of step with the first.
307
+ */
308
+ function gitOperands(segment, word) {
309
+ return gitTokens(segment, word)
310
+ .filter((t) => !t.startsWith('-'))
311
+ .map((t) => t.replace(/^["']/, '').replace(/["']$/, ''));
312
+ }
313
+ /**
314
+ * Every token after the subcommand word, flags included and quotes intact.
315
+ * Rule 3's exception needs to see the flags, not only the operands: a flag it
316
+ * does not recognise is a command doing something the exception has not
317
+ * proved.
318
+ */
319
+ function gitTokens(segment, word) {
320
+ const match = new RegExp(`${bareWord(word)}(.*)$`, 'i').exec(segment);
321
+ if (!match)
322
+ return [];
323
+ return (match[1] ?? '')
324
+ .trim()
325
+ .split(/\s+/)
326
+ .filter((t) => t !== '');
327
+ }
328
+ /**
329
+ * Ported from guard.sh's `is_main_or_master_ref` `case` statement, but
330
+ * data-driven off `policy.protectedBranchNames` instead of the two names
331
+ * bash had hardcoded — same behaviour today (names is `[main, master]`),
332
+ * but a rename doesn't need a second code change to take effect.
333
+ */
334
+ function isProtectedRef(ref, policy) {
335
+ return policy.protectedBranchNames.some((name) => ref === name || ref.endsWith(`:${name}`) || ref.endsWith(`:refs/heads/${name}`));
336
+ }
337
+ function isProtectedBranchName(branch, policy) {
338
+ return policy.protectedBranchNames.includes(branch);
339
+ }
340
+ /**
341
+ * Rule 5's protected-branch test: main/master by name, or a shared
342
+ * `smith/<epic>/integration` branch by pattern (task branches
343
+ * `smith/<epic>/<task-id>` are exempt — rebase/amend there is the sanctioned
344
+ * pre-merge-queue flow, worktree.yml). Ported from guard.sh's inline
345
+ * `^smith/[^/]+/integration$|^(main|master)$`, generalized to whatever
346
+ * `protected_branches.patterns` says instead of one hardcoded shape.
347
+ */
348
+ function isProtectedForHistoryRewrite(branch, policy) {
349
+ if (isProtectedBranchName(branch, policy))
350
+ return true;
351
+ return policy.protectedBranchPatterns.some((pattern) => picomatch(pattern)(branch));
352
+ }
353
+ /**
354
+ * `replaceAll`, not `replace`: a template author writing `{branch}` twice
355
+ * should get the branch twice, not the branch and then a literal `{branch}`
356
+ * in the operator's face. Every message in guardrails.yml used it once, so
357
+ * this changes no text today and stops the second use being a trap.
358
+ */
359
+ function renderBranch(template, branch) {
360
+ return template.replaceAll('{branch}', branch);
361
+ }
362
+ /**
363
+ * Tools whose `command` string this policy inspects. Only `Bash` is in scope
364
+ * today, but that is a fact about which tools *have* a shell command to
365
+ * inspect, not a hardcoded branch in `evaluateCommand` — a second Bash-like
366
+ * tool added later is a one-line change here, not new control flow.
367
+ */
368
+ const INSPECTED_TOOLS = ['Bash'];
369
+ /**
370
+ * Tools whose `file_path` this policy inspects — the write path that does
371
+ * not go through a shell. Nothing here has a command to match, so only the
372
+ * lease rules can fire on them; the base six are all about shell commands
373
+ * and no-op on an empty one.
374
+ */
375
+ const INSPECTED_FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
376
+ function requireRule(policy, id) {
377
+ const rule = policy.rules.get(id);
378
+ if (!rule) {
379
+ // Unreachable once a policy has passed parseGuardrailPolicy (which
380
+ // requires every id in REQUIRED_RULE_IDS) — this is a defensive
381
+ // safety-net, not a real code path.
382
+ throw new PolicyError('policy.missing-rule', `guardrails.yml has no rule "${id}".`, {
383
+ rule: id,
384
+ });
385
+ }
386
+ return rule;
387
+ }
388
+ function violation(rule, reason = rule.reason) {
389
+ return { ruleId: rule.id, severity: rule.severity, reason };
390
+ }
391
+ /**
392
+ * Blank the payload of git's message flags before the six rules below scan a
393
+ * command for refs and command words.
394
+ *
395
+ * A commit or merge message is git's own free-text field: its words are prose,
396
+ * never a ref and never a command. Scanning them anyway is how a commit whose
397
+ * message *described a rule* came to be refused for breaking it — five of the
398
+ * six rules could be tripped by a sentence alone, with no push, merge, deploy
399
+ * or removal anywhere in the command. That is the false deny this file already
400
+ * names twice as the one worth engineering against (see `isGitSubcommand`'s
401
+ * hyphen carve-out and `stripQuotedSpans`): the kind that teaches an agent to
402
+ * route around the gate rather than trust it.
403
+ *
404
+ * Deliberately narrower than `stripQuotedSpans`, which blanks every quoted
405
+ * span. Only a message payload goes, so a quoted *ref* is still read: `git
406
+ * push origin "main"` is still a push to main and `wrangler "deploy"` is still
407
+ * a deploy. Hiding a real target in here would mean passing it where git reads
408
+ * prose, which costs the target its meaning — so the blanking buys no way past
409
+ * any rule.
410
+ *
411
+ * That last sentence holds only for a payload the shell hands over as it
412
+ * stands, which is why `shellExpandsPayload` below gets to veto the blanking.
413
+ *
414
+ * Narrow in four more ways, each one a place where `-m` is not prose:
415
+ *
416
+ * - **Only where git spends `-m` on a message**, named subcommand by
417
+ * subcommand. `git rebase -m <upstream>` spells `--merge` and `git revert -m
418
+ * <parent-number>` picks a mainline, so their argument is a ref or a number
419
+ * that later scans must still see; `mkdir -m 755 <dir>` and `gh pr merge -m
420
+ * <branch>` are not git at all. A subcommand missing from this list is only
421
+ * scanned as it is today — the failure is a false deny, which is the
422
+ * direction this file errs in on purpose.
423
+ * - **Segment by segment**, so `git commit -m "…" && git push origin main`
424
+ * blanks the message and still reads the push.
425
+ * - **Only where the shell hands the payload over untouched** — see
426
+ * `shellExpandsPayload`. A payload the shell expands first is not the
427
+ * message, it is a command that has already run.
428
+ *
429
+ * It is deliberately *not* narrow about how `-m` was spelled. Git takes the
430
+ * message from `-m x`, `-m=x`, `-mx` and `-amx` alike, so a scanner that only
431
+ * knows the spaced form calls the other three prose-that-wasn't-exempted and
432
+ * denies a commit for the words in its message. `git commit -am "…"` is the
433
+ * common one, and it was denied. The letters before the `m` are matched as a
434
+ * cluster and the `m` must end it, which is git's own rule: in `-am` the value
435
+ * follows, in `-ma` the value *is* `a`.
436
+ *
437
+ * The double-quoted form counts an escaped quote as part of the payload, since
438
+ * a shell does. Stopping at the `\"` in `-m "he said \" then ran it"` left the
439
+ * tail exposed and the commit denied for it. Single quotes are left exact:
440
+ * inside them a backslash is a backslash, so `'[^']*'` is already right and
441
+ * teaching it about escapes would be teaching it a falsehood.
442
+ *
443
+ * The value is blanked rather than deleted, so token boundaries survive for
444
+ * the scans that read the last token of a segment.
445
+ */
446
+ const MESSAGE_FLAG_RE = /(^|\s)(-[a-zA-Z]*m|--message)(=|\s+|)("(?:\\.|[^"\\])*"|'[^']*'|\S+)/g;
447
+ /** The git subcommands whose `-m`/`--message` takes free text, and nothing else. */
448
+ const MESSAGE_SUBCOMMANDS = ['commit', 'merge', 'tag', 'stash', 'notes'];
449
+ /**
450
+ * Does the payload open and close with this quote character?
451
+ *
452
+ * First and last character only. That is enough for the payloads
453
+ * `MESSAGE_FLAG_RE` hands over and no more: it cannot tell a true pair from
454
+ * one whose closing quote is a backslash-escaped literal, and it does not try.
455
+ * Being wrong there costs a false deny, never a false allow, which is the
456
+ * direction this file errs in everywhere else too.
457
+ */
458
+ function isQuotedWith(payload, quote) {
459
+ return payload.length >= 2 && payload.startsWith(quote) && payload.endsWith(quote);
460
+ }
461
+ /**
462
+ * Every opener that forks a command and substitutes a path to its stream. The
463
+ * first two are bash's and zsh's alike; `=(` is zsh's own, and zsh is the shell
464
+ * behind the agent's Bash tool, so it is the one most likely to be typed here.
465
+ */
466
+ const PROCESS_SUBSTITUTIONS = ['<(', '>(', '=('];
467
+ /**
468
+ * True when the shell runs part of this payload *before* handing the rest
469
+ * over — the one case where blanking a payload hides a command rather than
470
+ * prose.
471
+ *
472
+ * A message payload holding a command substitution does not *describe* that
473
+ * command, it runs it: bash expands `$( )` inside double quotes before git is
474
+ * executed at all, so the substituted command happens and its *output* is what
475
+ * finally becomes the message. Blanking such a payload deleted the only real
476
+ * command on the line and left the rules reading the part that was genuinely
477
+ * prose. Backticks are the same expansion in an older spelling, and an
478
+ * unquoted payload is expanded too.
479
+ *
480
+ * Process substitution is a third spelling and runs a command just as eagerly.
481
+ * `git commit -m <(wrangler deploy)` forks the deploy, hands git the path
482
+ * `/dev/fd/63`, and the deploy has already happened by the time git has a
483
+ * message to read. What was blanked there is one whitespace-delimited token —
484
+ * `<(wrangler`, the binary — leaving ` deploy)` behind, so rule 4 went looking
485
+ * for a binary that had been deleted out from under it. Rules 1, 2, 3 and 5
486
+ * came through the same deletion intact for the unremarkable reason that what
487
+ * each of them keys on — a ref, a `--force` flag, a `merge`/`rebase`
488
+ * subcommand — happened not to be the token that got eaten. That is a fact
489
+ * about this payload's word order, not a property any of them hold.
490
+ *
491
+ * All three openers are listed in `PROCESS_SUBSTITUTIONS`, because which ones
492
+ * exist is a fact about the shell that will run the command, not about bash.
493
+ * The agent's shell here is zsh, which has bash's `<( )` and `>( )` and one
494
+ * more of its own: `=(cmd)` runs cmd, writes its output to a temp file and
495
+ * substitutes *that* path. It is the spelling most likely to be reached on
496
+ * this machine and the one a bash-only reading of the problem misses.
497
+ *
498
+ * Quoting decides both kinds, and not along the same line, so the two are
499
+ * asked separately. Single quotes are every shell's own: no expansion of any
500
+ * kind happens inside them, so the payload is exactly the text it looks like.
501
+ * Double quotes are narrower — a command substitution still happens inside
502
+ * them, a process substitution does not — so `"$( )"` is a command while
503
+ * `"<( )"`, `">( )"` and `"=( )"` are the prose they appear to be. Asking
504
+ * "will the shell run this" with the shell's own rule is what keeps the answer
505
+ * from being a guess layered on top of it.
506
+ *
507
+ * Two deliberate imprecisions, both erring toward reading more rather than
508
+ * less, which is the direction the six rules are safe in:
509
+ *
510
+ * - `${VAR}` is left alone. A parameter expansion substitutes a value and runs
511
+ * no command, so it is prose with a hole in it.
512
+ * - An escaped `\$(` inside double quotes is a literal `$(` to the shell, and
513
+ * is read here as live anyway. The cost is a false deny on a message that
514
+ * both escapes a substitution and spells out a guarded command, and the fix
515
+ * for it is the single quotes that were already the clearer way to write it.
516
+ *
517
+ * Reading a payload is not refusing it. Whatever the expansion contains is
518
+ * judged by the same six rules as any other command, so a message that
519
+ * substitutes `date` stays allowed: `date` is nobody's guarded command.
520
+ */
521
+ function shellExpandsPayload(payload) {
522
+ // Single-quoted: literal to every shell, so nothing in it can run.
523
+ if (isQuotedWith(payload, "'"))
524
+ return false;
525
+ // Command substitution, which double quotes do not suppress.
526
+ if (payload.includes('$(') || payload.includes('`'))
527
+ return true;
528
+ // Process substitution, which they do.
529
+ if (isQuotedWith(payload, '"'))
530
+ return false;
531
+ return PROCESS_SUBSTITUTIONS.some((opener) => payload.includes(opener));
532
+ }
533
+ function stripMessageFlagValues(command) {
534
+ // Split keeping the separators, so the segments can be rejoined untouched.
535
+ return command
536
+ .split(/([;&|])/)
537
+ .map((segment) => MESSAGE_SUBCOMMANDS.some((sub) => isGitSubcommand(segment, sub))
538
+ ? segment.replace(MESSAGE_FLAG_RE, (match, lead, flag, sep, payload) => shellExpandsPayload(payload) ? match : `${lead}${flag}${sep}""`)
539
+ : segment)
540
+ .join('');
541
+ }
542
+ /**
543
+ * `smith policy check --command '<cmd>'` is how anyone asks what these rules
544
+ * say about a command *without running it*. `guardrails.md` leads with it, and
545
+ * the six rules refused it: the hook scans the whole Bash string, so asking
546
+ * about a force push read as a force push and asking about a protected push
547
+ * read as a protected push. Every command worth asking about was a command you
548
+ * could not ask about. A gate whose own dry run is unreachable does not teach
549
+ * caution, it teaches an agent to find out by doing.
550
+ *
551
+ * The exemption rests on one fact and no other: `policy check` parses,
552
+ * evaluates and prints. It has no path that executes what it was handed — see
553
+ * its handler in cli.ts. That is a narrower claim than "quoted text is data",
554
+ * and it is the reason this cannot be widened to any other flag or binary.
555
+ *
556
+ * Narrow in four ways, each closing a way the exemption could be borrowed:
557
+ *
558
+ * - **Quoted payloads only, and only the quoting that leaves them inert.**
559
+ * `--command "$(…)"` is expanded by the shell before this binary is reached,
560
+ * so the command in it really runs and the question is not hypothetical.
561
+ * That is a fact about the payload, not about which quote surrounds it:
562
+ * double quotes stop a *process* substitution and not a *command* one, so
563
+ * the same predicate that rules on a message payload rules here —
564
+ * `shellExpandsPayload`. Keying on the single quote instead was a stand-in
565
+ * for that predicate, and it refused `--command "<(…)"`, which runs nothing:
566
+ * an over-refusal on the one command whose whole job is answering without
567
+ * running.
568
+ * - **Segment by segment**, so a real command chained after the question is
569
+ * read as itself. Whatever shares the segment is read too: the payload is
570
+ * blanked, not the segment.
571
+ * - **On the `policy check` subcommand pair, not on a binary name.** The
572
+ * caller's spelling is `smith`, a package-manager script, or the built entry
573
+ * point directly, and a public repo cannot assume which — or that an
574
+ * installation has not aliased it. Keying on the pair also keeps `--command`
575
+ * from meaning anything here when some other tool spells it.
576
+ * - **An unquoted payload is left alone**, since where it ends is the caller's
577
+ * shell's business rather than this scanner's. Over-refusing is the
578
+ * direction this file errs in on purpose, and the fix is the quoting the
579
+ * docs already show.
580
+ */
581
+ const POLICY_CHECK_RE = /(^|\s)policy\s+check(\s|$)/;
582
+ /** Only `--command` carries a command; `--file` carries a path and `--sandbox` a role name. */
583
+ const POLICY_CHECK_PAYLOAD_RE = /(^|\s)(--command)(=|\s+)('[^']*'|"(?:\\.|[^"\\])*")/g;
584
+ function stripPolicyCheckPayloads(command) {
585
+ // Split keeping the separators, so the segments can be rejoined untouched.
586
+ return command
587
+ .split(/([;&|])/)
588
+ .map((segment) => POLICY_CHECK_RE.test(segment)
589
+ ? segment.replace(POLICY_CHECK_PAYLOAD_RE, (match, lead, flag, sep, payload) => shellExpandsPayload(payload) ? match : `${lead}${flag}${sep}''`)
590
+ : segment)
591
+ .join('');
592
+ }
593
+ /**
594
+ * `git stash push` writes to the stash. It reaches no remote, so nothing about
595
+ * it is a push to a protected branch — but the word is right there, and the
596
+ * loose `isGitSubcommand` read that this file wants everywhere else found it.
597
+ *
598
+ * Matched as the adjacent pair only, never as "a segment mentioning stash", so
599
+ * `git push origin stash` is still read as the push it is. Filtering happens
600
+ * per segment after the chain is split, so the second half of `git stash push
601
+ * && git push` is untouched by this and still denied.
602
+ */
603
+ const STASH_PUSH_RE = /\bgit\b[^;&|]*\bstash\s+push\b/i;
604
+ /** Rule 1: push to main/master — destination ref, checked precisely; or a bare push while already on a protected branch. */
605
+ function checkPushToProtected(command, branch, policy) {
606
+ if (!isGitSubcommand(command, 'push'))
607
+ return null;
608
+ const rule = requireRule(policy, 'push-to-protected');
609
+ const pushSegments = gitSegmentsFor(command, 'push').filter((s) => !STASH_PUSH_RE.test(s));
610
+ if (pushSegments.some((segment) => pushOperands(segment).some((ref) => isProtectedRef(ref, policy)))) {
611
+ return violation(rule);
612
+ }
613
+ if (isProtectedBranchName(branch, policy) &&
614
+ pushSegments.some((segment) => /push(\s+(origin|upstream))?\s*$/.test(segment))) {
615
+ return violation(rule, renderBranch(rule.reasonOnCurrentBranch ?? rule.reason, branch));
616
+ }
617
+ return null;
618
+ }
619
+ /** Rule 2: force push (`--force` / `--force-with-lease` / `-f`). Case-sensitive, same as guard.sh (its `grep -Eq` has no `-i`). */
620
+ const FORCE_PUSH_RE = /(--force(-with-lease)?\b|(^|\s)-[a-zA-Z]*f[a-zA-Z]*(\s|$))/;
621
+ function checkForcePush(command, policy) {
622
+ if (!isGitSubcommand(command, 'push'))
623
+ return null;
624
+ if (!FORCE_PUSH_RE.test(command))
625
+ return null;
626
+ return violation(requireRule(policy, 'force-push'));
627
+ }
628
+ /** Rule 3: `git merge` or `git pull` while standing on a protected branch — the only place a merge can land. */
629
+ function checkMergeIntoProtected(command, branch, policy) {
630
+ // A merge has exactly one destination and it is never on the command line:
631
+ // it is wherever HEAD is. Every ref you name is a source. So the only
632
+ // question this rule can answer honestly is which branch you are standing
633
+ // on. Reading an operand as the destination refused the routine
634
+ // `git merge <base>` that refreshes a stale side branch — the opposite act.
635
+ //
636
+ // `git pull` is `git fetch` followed by that same merge, and it lands in the
637
+ // same place, so it is the same rule. `bareWord` keeps `git merge-base`,
638
+ // `git merge-tree` and `git pull-request` out: hyphenated names are
639
+ // different commands, and the plumbing ones cannot merge anything.
640
+ if (!isGitSubcommand(command, 'merge') && !isGitSubcommand(command, 'pull'))
641
+ return null;
642
+ // Names only, deliberately, not `protected_branches.patterns`: landing task
643
+ // branches on `smith/<epic>/integration` is the merge queue's whole job.
644
+ if (!isProtectedBranchName(branch, policy))
645
+ return null;
646
+ // One exception, and it is the one act of merging that lands nothing:
647
+ // fast-forwarding this branch to its own copy on a remote. No commit is
648
+ // created and no work arrives that was not already published, so refusing
649
+ // it protected nothing and cost the operator the ordinary refresh of a
650
+ // local `main` — the only way to do it was outside the gate entirely.
651
+ //
652
+ // Judged per segment, because every command in a chain runs: an allowed
653
+ // catch-up in front of `&& git merge feature-y` buys the merge nothing.
654
+ const segments = catchUpSegments(command);
655
+ if (segments.length > 0 &&
656
+ segments.every((segment) => isUpstreamCatchUp(segment, branch, policy))) {
657
+ return null;
658
+ }
659
+ const rule = requireRule(policy, 'merge-into-protected');
660
+ return violation(rule, renderBranch(rule.reasonOnCurrentBranch ?? rule.reason, branch));
661
+ }
662
+ /**
663
+ * The only flag rule 3's exception knows how to read. Everything else — even
664
+ * something harmless — falls back to the deny, because the exception's claim
665
+ * is that the command lands nothing, and a flag it has never seen is a claim
666
+ * it cannot make. `--no-ff` is the reason this is an allowlist and not a
667
+ * `--ff-only` presence test: `git pull --ff-only origin main --no-ff` has the
668
+ * exception's exact spelling in it and still writes a merge commit, since the
669
+ * later flag wins.
670
+ */
671
+ const CATCH_UP_FLAGS = ['--ff-only'];
672
+ /** `git merge` / `git pull` and nothing in between — no `git -c ...`, no env prefix, no wrapper. */
673
+ const CATCH_UP_SHAPE_RE = /^git\s+(merge|pull)(?:\s|$)/;
674
+ /**
675
+ * Is this segment a fast-forward of the branch you are standing on to its own
676
+ * copy on a remote?
677
+ *
678
+ * The rule reads what the command line says, so the exception does too, and it
679
+ * allows a segment only where the text itself proves every part: the command
680
+ * is a plain `git merge`/`git pull` with nothing spliced in front of the
681
+ * subcommand; its only flag is `--ff-only`; its source names *this* branch on
682
+ * a remote the policy lists; and there is no other operand, since a second
683
+ * source is a second thing landing. Anything the exception has not read is a
684
+ * denial, not a shrug — that is the difference between a proof and a guess.
685
+ *
686
+ * What it deliberately leaves out is a bare `git pull --ff-only`, whose source
687
+ * is `branch.<name>.merge` in a config file this rule cannot read. It is
688
+ * almost always the same catch-up, and "almost always" is the reasoning rule 3
689
+ * was just corrected out of — the operator gets a spelling that is true on its
690
+ * face instead.
691
+ *
692
+ * And what it cannot prove even so: a remote is a name here, and `git remote
693
+ * set-url origin <anywhere>` makes that name point wherever it likes. The
694
+ * allowlist buys the operator a name they control, not a URL this gate has
695
+ * verified — see docs/standards/guardrails.md.
696
+ */
697
+ function isUpstreamCatchUp(segment, branch, policy) {
698
+ const subcommand = CATCH_UP_SHAPE_RE.exec(segment.trim())?.[1];
699
+ if (!subcommand)
700
+ return false;
701
+ const tokens = gitTokens(segment, subcommand);
702
+ const flags = tokens.filter((t) => t.startsWith('-'));
703
+ if (!flags.includes('--ff-only'))
704
+ return false;
705
+ if (flags.some((flag) => !CATCH_UP_FLAGS.includes(flag)))
706
+ return false;
707
+ const operands = tokens
708
+ .filter((t) => !t.startsWith('-'))
709
+ .map((t) => t.replace(/^["']/, '').replace(/["']$/, ''));
710
+ if (subcommand === 'pull') {
711
+ // `git pull <remote> <branch>`: the source is two operands, spelled apart.
712
+ return operands.length === 2 && isCatchUpRemote(operands[0], policy) && operands[1] === branch;
713
+ }
714
+ // `git merge <remote>/<branch>`: the same source, spelled as one ref. A bare
715
+ // `main` is not it — that is a local ref that merely shares the name.
716
+ if (operands.length !== 1)
717
+ return false;
718
+ const [remote, ...rest] = (operands[0] ?? '').split('/');
719
+ return isCatchUpRemote(remote, policy) && rest.join('/') === branch;
720
+ }
721
+ function isCatchUpRemote(token, policy) {
722
+ return token !== undefined && policy.catchUpRemotes.includes(token);
723
+ }
724
+ /** Rule 4: deploy/publish commands (`deploy_commands` in guardrails.yml — wrangler deploy/publish, pages publish today). */
725
+ function checkDeployCommand(command, policy) {
726
+ const rule = requireRule(policy, 'deploy-command');
727
+ for (const spec of policy.deployCommands) {
728
+ const subcommandAlt = spec.subcommands.map(escapeRegExp).join('|');
729
+ const re = new RegExp(`\\b${escapeRegExp(spec.command)}\\b.*\\b(${subcommandAlt})\\b`, 'i');
730
+ if (re.test(command)) {
731
+ return violation(rule);
732
+ }
733
+ }
734
+ return null;
735
+ }
736
+ /** Rule 5: history rewrite (rebase / commit --amend / filter-branch|filter-repo) on a protected branch. */
737
+ function checkHistoryRewriteOnProtected(command, branch, policy) {
738
+ if (!isProtectedForHistoryRewrite(branch, policy))
739
+ return null;
740
+ const rewrites = isGitSubcommand(command, 'rebase') ||
741
+ /\bcommit\b.*--amend\b/i.test(command) ||
742
+ /\bfilter-(branch|repo)\b/i.test(command);
743
+ if (!rewrites)
744
+ return null;
745
+ const rule = requireRule(policy, 'history-rewrite-on-protected');
746
+ return violation(rule, renderBranch(rule.reason, branch));
747
+ }
748
+ /** Rule 6 detection, half one: is `rm` invoked as a command in this segment? Looser than "first token", so `sudo rm …` still matches; stricter than a bare substring, so the `rm` inside `charm`/`confirm` is not this rule's business. */
749
+ const RM_INVOKE_RE = /(^|[;&|]|\s)rm\s+/;
750
+ /** A bundled short-flag token (`-rf`, `-Rvf`) as opposed to a long flag (`--force`) or a path. */
751
+ const RM_SHORT_FLAG_RE = /^-[a-zA-Z]+$/;
752
+ /**
753
+ * Rule 6 detection, half two: does this `rm` carry both recursive and force?
754
+ *
755
+ * Read across the invocation's flag tokens rather than out of a single one,
756
+ * because the spellings that mean the same removal are one case, not four:
757
+ * bundled (`-rf`), split (`-r -f`), long (`--recursive --force`), and mixed
758
+ * (`--recursive -f`). The predecessor of this function matched the flags as a
759
+ * literal run directly after `rm`, which read `-rf` and `-fr` and nothing
760
+ * else — so `rm -Rf src` and `rm -r -f src` were recursive-force removals the
761
+ * gate did not see. `-R` counts: it is POSIX's own recursive spelling and
762
+ * does exactly what `-r` does.
763
+ *
764
+ * `-i` is still ignored, matching guard.sh — an interactive prompt is not a
765
+ * bound the factory can hold an agent to.
766
+ */
767
+ function hasRecursiveForce(tokens) {
768
+ let recursive = false;
769
+ let force = false;
770
+ for (const token of tokens) {
771
+ if (token === '--recursive')
772
+ recursive = true;
773
+ else if (token === '--force')
774
+ force = true;
775
+ else if (RM_SHORT_FLAG_RE.test(token)) {
776
+ if (token.includes('r') || token.includes('R'))
777
+ recursive = true;
778
+ if (token.includes('f'))
779
+ force = true;
780
+ }
781
+ }
782
+ return recursive && force;
783
+ }
784
+ /**
785
+ * The characters that separate one command in a chain from the next. `;&|`
786
+ * are the three guard.sh knew, and for a long time this file knew no others
787
+ * — which left the rules that read a segment's *operands* (rule 1's
788
+ * destination ref, rule 6's paths) reading whatever the next piece of shell
789
+ * syntax put in front of them:
790
+ * - `(` `)` open and close a substitution or subshell — `echo $(git push
791
+ * origin main)` is a real push in a real tool call, and the `)` stuck to
792
+ * the ref made it `main)`, which is nothing's name.
793
+ * - A backtick is the older spelling of the same thing.
794
+ * - `#` starts a comment; everything after it is not a command at all.
795
+ * - A newline separates two commands exactly as `;` does. A multi-line Bash
796
+ * payload is one tool call, and every line in it runs.
797
+ * - `{` `}` group commands.
798
+ *
799
+ * Splitting stays naive about quoting, the same trade-off guard.sh made: a
800
+ * separator inside a quoted string splits anyway. That direction over-refuses,
801
+ * which is the one this file chooses everywhere.
802
+ */
803
+ const COMMAND_SEPARATOR_CHARS = ';&|\\n(){}#`';
804
+ /**
805
+ * `>` `<` start a redirection, whose target is a file the command writes,
806
+ * never one of its operands. This is the one that mattered: `git push
807
+ * origin main > /dev/null` is not an evasion, it is how a quiet push is
808
+ * written, and it was allowed.
809
+ *
810
+ * They are kept apart from the separators above because cutting a command
811
+ * here does not separate two commands, it *truncates* one — and the two
812
+ * cuts differ on whether that is safe. A rule that refuses on what it sees
813
+ * refuses more when it sees less, so every deny rule may cut here freely.
814
+ * `checkMergeIntoProtected`'s exception *allows* on what it sees, and text
815
+ * it never read is a claim it must not make: see `stripRedirections`.
816
+ */
817
+ const REDIRECTION_CHARS = '<>';
818
+ /** Every character that ends the command a segment is about — what the deny rules split on. */
819
+ const SEPARATOR_CHARS = `${COMMAND_SEPARATOR_CHARS}${REDIRECTION_CHARS}`;
820
+ /**
821
+ * Every separated segment of a command, so a chain can be inspected one
822
+ * invocation at a time — and so a rule reading a segment's operands is looking
823
+ * at that command's operands and nothing else.
824
+ */
825
+ function splitChainSegments(command) {
826
+ return command.split(new RegExp(`[${SEPARATOR_CHARS}]`));
827
+ }
828
+ /**
829
+ * A redirection, with its target: an optional file descriptor or `&`, one of
830
+ * the redirection operators, and then either a descriptor to duplicate
831
+ * (`2>&1`, `<&-`) or a word to redirect to, glued or spaced (`>/dev/null`,
832
+ * `> out`). The target stops at a separator so that `>out; git merge x` gives
833
+ * back the `;` it was written with.
834
+ *
835
+ * Longest operators first: `>>` before `>`, `<<<` before `<<` before `<`.
836
+ */
837
+ const REDIRECTION_RE = new RegExp(`(?:&|\\d*)(?:>>|>|<<<|<<-?|<>|<)(?:&\\d*-?|\\s*[^\\s${COMMAND_SEPARATOR_CHARS}${REDIRECTION_CHARS}]*)`, 'g');
838
+ /**
839
+ * The command with its plumbing taken out — `git pull --ff-only origin main
840
+ * 2>&1 | tail` becomes `git pull --ff-only origin main | tail`.
841
+ *
842
+ * Removing redirections *before* splitting is the point, and the order is what
843
+ * makes it correct: the `&` in `2>&1` belongs to the redirection, and only a
844
+ * reader that has already recognised it can tell it from the `&&` that really
845
+ * does start a second command.
846
+ *
847
+ * This is the exception's reading and nothing else's. Every deny rule keeps
848
+ * splitting on `<` and `>`, where truncating a command is the safe direction.
849
+ */
850
+ function stripRedirections(command) {
851
+ return command.replace(REDIRECTION_RE, ' ');
852
+ }
853
+ /**
854
+ * The `git merge`/`git pull` commands in a chain, read for rule 3's exception:
855
+ * plumbing removed, then split on the characters that actually separate two
856
+ * commands.
857
+ *
858
+ * `gitSegmentsFor` is the same read for every rule that denies, and it would
859
+ * hand this one a lie: `git pull --ff-only origin main >/dev/null --no-ff`
860
+ * splits at the `>` into a segment that ends at `main`, which is exactly the
861
+ * catch-up the exception allows — while the command git actually runs has
862
+ * `--no-ff` on it and writes a merge commit.
863
+ */
864
+ function catchUpSegments(command) {
865
+ const re = new RegExp(`\\bgit\\b.*(?:${bareWord('merge')}|${bareWord('pull')})`, 'i');
866
+ return stripRedirections(command)
867
+ .split(new RegExp(`[${COMMAND_SEPARATOR_CHARS}]`))
868
+ .filter((s) => re.test(s));
869
+ }
870
+ /** The `rm <args>` run up to the next separator, ported from guard.sh's `grep -Eo 'rm[[:space:]]+[^;&|]*'` and widened with it. Applied per chain segment (see `checkUnboundedRm`), so "up to the next separator" and "to the end of the string" mean the same thing by the time this runs — the negated class is belt and braces, kept in step with the split so the two can never disagree about where a command ends. */
871
+ const RM_ARGS_RE = new RegExp(`rm\\s+([^${SEPARATOR_CHARS}]*)`);
872
+ function extractRmArgs(segment) {
873
+ const match = RM_ARGS_RE.exec(segment);
874
+ if (!match)
875
+ return [];
876
+ return (match[1] ?? '')
877
+ .trim()
878
+ .split(/\s+/)
879
+ .filter((t) => t !== '');
880
+ }
881
+ /**
882
+ * Normalize an `rm` argument to a repo-root-relative path — the portable
883
+ * replacement for guard.sh's `normalize_path`, which shelled out to
884
+ * `realpath -m` when present and fell back to the raw string otherwise (no
885
+ * `realpath` on a stock Windows install, which is one of the three named
886
+ * reasons the guard hook needed WSL2). `path.resolve` needs no external
887
+ * binary, and `path.relative` is `path.sep`-aware — unlike bash's `case
888
+ * "$p" in "$REPO_ROOT"/*) … esac`, which only ever recognised `/` — so this
889
+ * is also the fix for guard.sh's Windows-path-separator gap, not just its
890
+ * missing-binary one.
891
+ */
892
+ function normalizeRemovalPath(token, repoRoot) {
893
+ if (repoRoot === null) {
894
+ // No repo root to make this relative to (guard.sh: `git rev-parse
895
+ // --show-toplevel` failed, so `REPO_ROOT` was `''` and the `-n
896
+ // "$REPO_ROOT"` guard skipped the prefix strip entirely). Absolutize
897
+ // instead of returning the token as-is, so a bare relative token like
898
+ // `workspaces` can never coincidentally read as an allowed root — the
899
+ // same fail-closed behaviour bash got for free from comparing an
900
+ // un-prefix-stripped absolute path against a bare `workspaces/*` glob.
901
+ return path.resolve(token);
902
+ }
903
+ const absolute = path.resolve(repoRoot, token);
904
+ const relative = path.relative(repoRoot, absolute);
905
+ return relative === '' ? '.' : relative;
906
+ }
907
+ function isAllowedRemovalPath(normalizedPath, policy) {
908
+ const first = normalizedPath.split(path.sep)[0];
909
+ // `path.relative` produces a leading `..` segment when the path climbs
910
+ // above repoRoot, and an empty leading segment for an absolute path
911
+ // (posix `/x` splits to `['', 'x']`) — neither is an allowed root name.
912
+ return (first !== undefined &&
913
+ first !== '' &&
914
+ first !== '..' &&
915
+ policy.allowedRemovalRoots.includes(first));
916
+ }
917
+ /**
918
+ * Rule 6: a recursive-force `rm` outside `allowed_roots`, however the two
919
+ * flags are spelled (see `hasRecursiveForce`).
920
+ *
921
+ * Checks every chain segment independently, not just the first `rm` in the
922
+ * whole command. guard.sh's own `grep -Eo 'rm[[:space:]]+[^;&|]*'` — and this
923
+ * function, until this fix — stopped at the first match in the *whole*
924
+ * command string, so `rm -rf workspaces/a; rm -rf src/important` read
925
+ * `workspaces/a` (allowed), decided the command was fine, and never looked
926
+ * at `src/important` at all. That is a deliberate behaviour change from a
927
+ * straight port: closing a chained-command bypass that guard.sh always had,
928
+ * not carrying it forward with new spelling.
929
+ */
930
+ function checkUnboundedRm(command, repoRoot, policy) {
931
+ const outOfBounds = splitChainSegments(command).some((segment) => {
932
+ if (!RM_INVOKE_RE.test(segment))
933
+ return false;
934
+ const args = extractRmArgs(segment);
935
+ if (!hasRecursiveForce(args))
936
+ return false;
937
+ return args.some((tok) => {
938
+ if (tok.startsWith('-'))
939
+ return false;
940
+ return !isAllowedRemovalPath(normalizeRemovalPath(tok, repoRoot), policy);
941
+ });
942
+ });
943
+ if (!outOfBounds)
944
+ return null;
945
+ return violation(requireRule(policy, 'unbounded-rm'));
946
+ }
947
+ // ---------------------------------------------------------------------------
948
+ // judge sandbox — rules that apply only while a lease is open (sandbox.ts)
949
+ // ---------------------------------------------------------------------------
950
+ /**
951
+ * Does `segment` invoke `word` as a command?
952
+ *
953
+ * Looser than "first token" (so `xargs curl …` and `sudo rm …` still match)
954
+ * but stricter than the bare `\b<word>\b` the git matchers use, because
955
+ * these lists contain two-letter words: `\bnc\b` matches inside `src/nc/`,
956
+ * and a judge running `grep -r x src/nc/` being told it has no network
957
+ * access is the kind of false refusal that gets a guard switched off.
958
+ * Requiring whitespace or a chain separator on the left, and whitespace or
959
+ * end-of-segment on the right, keeps path components out while keeping every
960
+ * real invocation in.
961
+ */
962
+ function hasCommandWord(segment, word) {
963
+ return new RegExp(`(^|[;&|(]|\\s)${escapeRegExp(word)}(\\s|$)`, 'i').test(segment);
964
+ }
965
+ /** `<command> … <one of subcommands>` within a single chain segment. */
966
+ function hasCommandSubcommand(segment, spec) {
967
+ if (!hasCommandWord(segment, spec.command))
968
+ return false;
969
+ return spec.subcommands.some((sub) => new RegExp(`\\b${escapeRegExp(sub)}\\b`, 'i').test(segment));
970
+ }
971
+ /**
972
+ * Remove heredoc bodies before looking for redirections.
973
+ *
974
+ * A judge's one legitimate write is its verdict, and the way an agent with
975
+ * no `Write` tool produces a JSON file is `cat > state/results/x.json
976
+ * <<'JSON' … JSON`. The body of that heredoc is prose the judge wrote, and
977
+ * prose contains `>`. Without this, a finding that says "expected > 0"
978
+ * parses as a redirection to a file named `0`, which is not under a write
979
+ * root, and the judge is refused permission to file its own report. Strips
980
+ * from the line introducing the delimiter to the line that closes it, or to
981
+ * the end when it is never closed.
982
+ */
983
+ function stripHeredocBodies(command) {
984
+ const lines = command.split('\n');
985
+ const kept = [];
986
+ let delimiter = null;
987
+ for (const line of lines) {
988
+ if (delimiter !== null) {
989
+ if (line.trim() === delimiter)
990
+ delimiter = null;
991
+ continue;
992
+ }
993
+ kept.push(line);
994
+ const match = /<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/.exec(line);
995
+ if (match)
996
+ delimiter = match[1] ?? match[2] ?? match[3] ?? null;
997
+ }
998
+ return kept.join('\n');
999
+ }
1000
+ /**
1001
+ * Targets of `>`/`>>` redirections. `2>&1` and friends are excluded by the
1002
+ * `(?!&)` lookahead — a duplicated file descriptor is not a path.
1003
+ */
1004
+ const REDIRECT_TARGET_RE = />>?\s*(?!&)([^\s;&|<>]+)/g;
1005
+ function redirectTargets(command) {
1006
+ const targets = [];
1007
+ for (const match of stripHeredocBodies(command).matchAll(REDIRECT_TARGET_RE)) {
1008
+ const token = (match[1] ?? '').replace(/^["']/, '').replace(/["']$/, '');
1009
+ if (token !== '')
1010
+ targets.push(token);
1011
+ }
1012
+ return targets;
1013
+ }
1014
+ /** Non-flag arguments following `word` in `segment`, i.e. the paths a `mkdir`/`tee`/`touch` will write. */
1015
+ function argumentsAfter(segment, word) {
1016
+ const tokens = segment.trim().split(/\s+/);
1017
+ const index = tokens.findIndex((t) => t.toLowerCase() === word.toLowerCase());
1018
+ if (index === -1)
1019
+ return [];
1020
+ return tokens
1021
+ .slice(index + 1)
1022
+ .filter((t) => t !== '' && !t.startsWith('-'))
1023
+ .map((t) => t.replace(/^["']/, '').replace(/["']$/, ''));
1024
+ }
1025
+ /**
1026
+ * Is a repo-root-relative path inside one of `write_roots`?
1027
+ *
1028
+ * Segment-wise rather than string-prefix, so `state/results-of-mine` does
1029
+ * not read as inside `state/results`. Equality counts (`mkdir -p
1030
+ * state/results` is a judge preparing to write its verdict), and anything
1031
+ * that climbed above the repo root — `path.relative` renders that with a
1032
+ * leading `..`, and an absolute path with a leading empty segment — does
1033
+ * not.
1034
+ */
1035
+ function isUnderWriteRoot(normalizedPath, roots) {
1036
+ const parts = normalizedPath.split(path.sep);
1037
+ if (parts[0] === undefined || parts[0] === '' || parts[0] === '..')
1038
+ return false;
1039
+ return roots.some((root) => {
1040
+ const rootParts = root.split('/').filter((p) => p !== '');
1041
+ return parts.length >= rootParts.length && rootParts.every((p, i) => parts[i] === p);
1042
+ });
1043
+ }
1044
+ /**
1045
+ * A probe segment appended to a path to ask "would anything *inside* this
1046
+ * directory be in scope?" — see `isWithinWriteScope`.
1047
+ */
1048
+ const SCOPE_PROBE = '__scope_probe__';
1049
+ /** Repo-root-relative, posix-separated — the shape `write_globs` are written in. */
1050
+ function toRepoRelativePosix(token, repoRoot) {
1051
+ return normalizeRemovalPath(token, repoRoot).split(path.sep).join('/');
1052
+ }
1053
+ /**
1054
+ * Is a repo-root-relative path inside a role's write scope?
1055
+ *
1056
+ * Globs rather than `write_roots`' path prefixes, because a tester's scope is
1057
+ * not a directory: a double-star test-file pattern is a shape, and the files
1058
+ * it names sit beside the implementation they cover.
1059
+ *
1060
+ * A directory counts as in scope when something inside it would be, which is
1061
+ * what the probe asks: `mkdir -p factory/orchestrator/test/fixtures` is a
1062
+ * tester preparing to write fixtures, and refusing it would be a false deny
1063
+ * — the kind that teaches an agent to route around the gate rather than
1064
+ * trust it. A path that climbed above the repo root (`path.relative` renders
1065
+ * that with a leading `..`, an absolute path with a leading empty segment)
1066
+ * is never in scope, whatever the globs say.
1067
+ */
1068
+ function isWithinWriteScope(normalizedPath, globs) {
1069
+ const first = normalizedPath.split('/')[0];
1070
+ if (first === undefined || first === '' || first === '..' || normalizedPath === '.')
1071
+ return false;
1072
+ const isMatch = picomatch([...globs], { dot: true });
1073
+ return isMatch(normalizedPath) || isMatch(`${normalizedPath}/${SCOPE_PROBE}`);
1074
+ }
1075
+ /** `{role}` and `{write_roots}` in a judge rule's reason, same placeholder convention as `{branch}`. */
1076
+ function renderSandboxReason(template, lease, policy) {
1077
+ return template
1078
+ .replace('{role}', lease.role)
1079
+ .replace('{write_roots}', policy.judgeSandbox.writeRoots.join(' or '));
1080
+ }
1081
+ function sandboxViolation(policy, id, lease) {
1082
+ const rule = requireRule(policy, id);
1083
+ return violation(rule, renderSandboxReason(rule.reason, lease, policy));
1084
+ }
1085
+ /** Judge rule 1: no outbound access — network clients, remote git, package installs. */
1086
+ function checkJudgeNetwork(command, lease, policy) {
1087
+ const { networkCommands, networkSubcommands } = policy.judgeSandbox;
1088
+ const reaches = splitChainSegments(command).some((segment) => networkCommands.some((word) => hasCommandWord(segment, word)) ||
1089
+ networkSubcommands.some((spec) => hasCommandSubcommand(segment, spec)));
1090
+ return reaches ? sandboxViolation(policy, 'judge-network', lease) : null;
1091
+ }
1092
+ /**
1093
+ * Judge rule 2: the only write a judge may make is a redirection into a
1094
+ * write root.
1095
+ *
1096
+ * Four shapes, deliberately not unified. A file tool names its target
1097
+ * outright, so it is checked directly (judges hold no `Edit`/`Write` today,
1098
+ * but the rule answers for the role, not for the tool it reached for). A redirection has one readable
1099
+ * target, so it is checked against the allowlist. `mkdir`/`tee`/`touch` take
1100
+ * nothing but targets, so every non-flag argument is checked. Everything
1101
+ * else that writes is refused outright rather than path-checked, because
1102
+ * deciding which token of `cp a b` is the target — or of `install -m 644 a b
1103
+ * c` — is more machinery than the zero legitimate cases justify.
1104
+ */
1105
+ function checkJudgeWrite(command, filePath, lease, repoRoot, policy) {
1106
+ const { writeRoots, targetWriteCommands, refusedWriteCommands, gitWriteSubcommands } = policy.judgeSandbox;
1107
+ const outOfBounds = (token) => !isUnderWriteRoot(normalizeRemovalPath(token, repoRoot), writeRoots);
1108
+ if (filePath !== null && filePath !== '' && outOfBounds(filePath)) {
1109
+ return sandboxViolation(policy, 'judge-write', lease);
1110
+ }
1111
+ if (redirectTargets(command).some(outOfBounds)) {
1112
+ return sandboxViolation(policy, 'judge-write', lease);
1113
+ }
1114
+ const stripped = stripHeredocBodies(command);
1115
+ const writes = splitChainSegments(stripped).some((segment) => {
1116
+ if (refusedWriteCommands.some((word) => hasCommandWord(segment, word)))
1117
+ return true;
1118
+ if (/\bsed\b[^;&|]*\s-i(\.[^\s]*)?(\s|$)/i.test(segment))
1119
+ return true;
1120
+ if (hasCommandWord(segment, 'git') &&
1121
+ gitWriteSubcommands.some((sub) => new RegExp(`\\b${escapeRegExp(sub)}\\b`, 'i').test(segment))) {
1122
+ return true;
1123
+ }
1124
+ return targetWriteCommands.some((word) => hasCommandWord(segment, word) && argumentsAfter(segment, word).some(outOfBounds));
1125
+ });
1126
+ return writes ? sandboxViolation(policy, 'judge-write', lease) : null;
1127
+ }
1128
+ /**
1129
+ * Blank out quoted spans before scanning a segment for command words.
1130
+ *
1131
+ * Only the write-scope rule needs this *blanket* form — the six base rules
1132
+ * take the narrower `stripMessageFlagValues` above, which spares a quoted ref.
1133
+ * The write-scope rule needs the wider net because the role it governs
1134
+ * commits: `git commit -m "test: cover the restore path"` is a tester doing
1135
+ * exactly what its output contract asks, and refusing it because the message
1136
+ * says "restore" is the same class of false deny as refusing `git merge-base`
1137
+ * — the kind that teaches an agent to route around the gate rather than
1138
+ * trust it. A quoted span is an argument, never a command word.
1139
+ *
1140
+ * Replaced rather than deleted, so token boundaries survive. Targets keep
1141
+ * being read off the unstripped segment, since `tee "state/results/x"` is a
1142
+ * write whose target happens to be quoted.
1143
+ */
1144
+ function stripQuotedSpans(segment) {
1145
+ return segment.replace(/"[^"]*"|'[^']*'/g, '""');
1146
+ }
1147
+ /** `{role}` and `{write_globs}` in the role-write-scope rule's reason. */
1148
+ function scopeViolation(policy, lease, scope) {
1149
+ const rule = requireRule(policy, 'role-write-scope');
1150
+ return violation(rule, rule.reason.replace('{role}', lease.role).replace('{write_globs}', scope.writeGlobs.join(', ')));
1151
+ }
1152
+ /**
1153
+ * Role rule: a write-scoped role writes inside its globs and nowhere else.
1154
+ *
1155
+ * The shapes mirror `checkJudgeWrite`, with two differences that follow from
1156
+ * the role actually being allowed to write. `rm` is path-checked rather than
1157
+ * refused, because deleting a stale test is ordinary tester work and its
1158
+ * arguments are all targets. `git add`/`commit` stay allowed, because the
1159
+ * tester's output contract *requires* a commit on the task branch — only the
1160
+ * git subcommands that change tracked content without passing a path check
1161
+ * (`apply`, `checkout`, `reset`, …) are refused, since those would put the
1162
+ * implementation back under the tester's control by the back door.
1163
+ *
1164
+ * `sed -i` and the verbs in `refused_commands` are refused outright for the
1165
+ * same reason the judge refuses them: deciding which token of `cp a b` is
1166
+ * the target is more machinery than the legitimate cases justify, and here
1167
+ * the role has `Edit` and `Write`, which *are* path-checked, as the better
1168
+ * route to the same edit.
1169
+ *
1170
+ * The known limit is the judge sandbox's, unchanged: this reads command
1171
+ * text, not intent. A write smuggled through an interpreter is invisible to
1172
+ * it, which is why `smith worktree fingerprint`/`verify` stays behind it.
1173
+ */
1174
+ function checkRoleWriteScope(command, filePath, lease, repoRoot, policy, scope) {
1175
+ const outOfScope = (token) => !isWithinWriteScope(toRepoRelativePosix(token, repoRoot), scope.writeGlobs);
1176
+ if (filePath !== null && filePath !== '' && outOfScope(filePath)) {
1177
+ return scopeViolation(policy, lease, scope);
1178
+ }
1179
+ if (redirectTargets(command).some(outOfScope)) {
1180
+ return scopeViolation(policy, lease, scope);
1181
+ }
1182
+ // Which verbs take nothing but targets is a fact about the verbs, not
1183
+ // about the role, so the judge sandbox's list is reused rather than
1184
+ // restated once per scope. `rm` joins them here only.
1185
+ const targetVerbs = [...policy.judgeSandbox.targetWriteCommands, 'rm'];
1186
+ const stripped = stripHeredocBodies(command);
1187
+ const writes = splitChainSegments(stripped).some((segment) => {
1188
+ const words = stripQuotedSpans(segment);
1189
+ if (scope.refusedCommands.some((word) => hasCommandWord(words, word)))
1190
+ return true;
1191
+ if (/\bsed\b[^;&|]*\s-i(\.[^\s]*)?(\s|$)/i.test(words))
1192
+ return true;
1193
+ if (scope.refusedGitSubcommands.some((sub) => isGitSubcommand(words, sub)))
1194
+ return true;
1195
+ return targetVerbs.some((word) => hasCommandWord(segment, word) && argumentsAfter(segment, word).some(outOfScope));
1196
+ });
1197
+ return writes ? scopeViolation(policy, lease, scope) : null;
1198
+ }
1199
+ /**
1200
+ * Judge rule 3: a judge does not touch its own lease.
1201
+ *
1202
+ * Without this the sandbox would be one `smith sandbox close` away from
1203
+ * being off, and `rm state/sandboxes/<hash>.json` is a removal under
1204
+ * `state/`, which the `unbounded-rm` rule above explicitly permits. The
1205
+ * refusal is unconditional on the lease directory rather than scoped to
1206
+ * *this* lease: reading another judge's lease is no more a judge's business
1207
+ * than deleting its own.
1208
+ */
1209
+ function checkJudgeSandboxEscape(command, lease, policy) {
1210
+ const escapes = splitChainSegments(command).some((segment) => {
1211
+ if (/state[\\/]sandboxes/i.test(segment))
1212
+ return true;
1213
+ if (!/\bsandbox\b/i.test(segment))
1214
+ return false;
1215
+ return (hasCommandWord(segment, 'smith') ||
1216
+ hasCommandWord(segment, 'node') ||
1217
+ /\bcli\.js\b/i.test(segment));
1218
+ });
1219
+ return escapes ? sandboxViolation(policy, 'judge-sandbox-escape', lease) : null;
1220
+ }
1221
+ /**
1222
+ * The guard hook's decision for one tool call: every guardrails.yml rule
1223
+ * that fires, not just the first (an agent — or an operator reading the
1224
+ * denial — should see every reason at once, the same way `smith check` does
1225
+ * for gate failures). Only tools in `INSPECTED_TOOLS` (a shell command) or
1226
+ * `INSPECTED_FILE_TOOLS` (a target path) are evaluated; every other tool
1227
+ * call, an empty command and an absent path all allow trivially.
1228
+ */
1229
+ export function evaluateCommand(context, policy = loadGuardrailPolicy()) {
1230
+ const inspectsCommand = INSPECTED_TOOLS.includes(context.toolName);
1231
+ const inspectsPath = INSPECTED_FILE_TOOLS.includes(context.toolName);
1232
+ const filePath = inspectsPath ? (context.filePath ?? null) : null;
1233
+ if (!inspectsCommand && !inspectsPath)
1234
+ return { allowed: true, violations: [] };
1235
+ if (inspectsCommand && context.command.trim() === '') {
1236
+ return { allowed: true, violations: [] };
1237
+ }
1238
+ if (inspectsPath && (filePath === null || filePath.trim() === '')) {
1239
+ return { allowed: true, violations: [] };
1240
+ }
1241
+ const { command, branch, repoRoot } = context;
1242
+ // The six base rules look for refs and command words, so they read the
1243
+ // command with its message payloads blanked — see `stripMessageFlagValues` —
1244
+ // and with the payload of a `policy check` question blanked too, since
1245
+ // asking what a rule says is not doing the thing it says no to.
1246
+ // The lease rules below keep the raw string: they read *targets* off the
1247
+ // command, and `-m` is not always prose down there (`mkdir -m 755 <dir>`
1248
+ // spends it on a mode), so blanking it could put an empty token where a
1249
+ // path belongs.
1250
+ // Outermost quoting wins, so the question is blanked before the message
1251
+ // payloads inside it are looked at: `policy check --command 'git commit -m
1252
+ // "…"'` is one inert span, not a commit with a message.
1253
+ const scanned = stripMessageFlagValues(stripPolicyCheckPayloads(command));
1254
+ const checks = [
1255
+ () => checkPushToProtected(scanned, branch, policy),
1256
+ () => checkForcePush(scanned, policy),
1257
+ () => checkMergeIntoProtected(scanned, branch, policy),
1258
+ () => checkDeployCommand(scanned, policy),
1259
+ () => checkHistoryRewriteOnProtected(scanned, branch, policy),
1260
+ () => checkUnboundedRm(scanned, repoRoot, policy),
1261
+ ];
1262
+ // The judge rules are additive: a judge is bound by everything an ordinary
1263
+ // session is bound by, and by these on top. They are appended rather than
1264
+ // substituted so there is no arrangement of leases under which a command
1265
+ // is checked by fewer rules than it would have been with no lease at all.
1266
+ const lease = context.sandbox ?? null;
1267
+ if (lease !== null) {
1268
+ // One rule set per lease, selected by role. A role with no declared
1269
+ // write scope gets the judge rules — that covers the six judge roles
1270
+ // and, deliberately, anything unrecognised: `--role coder` is either a
1271
+ // typo or an attempt to pick a rule set, and the strictest set is the
1272
+ // safe answer to both. sandbox.ts refuses the unknown role outright at
1273
+ // lease time; this is the second line, for a lease file written by hand.
1274
+ const scope = policy.roleWriteScopes.find((s) => s.role === lease.role) ?? null;
1275
+ if (scope === null) {
1276
+ checks.push(() => checkJudgeNetwork(command, lease, policy), () => checkJudgeWrite(command, filePath, lease, repoRoot, policy));
1277
+ }
1278
+ else {
1279
+ checks.push(() => checkRoleWriteScope(command, filePath, lease, repoRoot, policy, scope));
1280
+ }
1281
+ // Not role-dependent: a lease a leaseholder can lift is not a lease,
1282
+ // whichever rule set the role draws the rest of its bounds from.
1283
+ checks.push(() => checkJudgeSandboxEscape(command, lease, policy));
1284
+ }
1285
+ const violations = checks.map((check) => check()).filter((v) => v !== null);
1286
+ return { allowed: violations.length === 0, violations };
1287
+ }
1288
+ // ---------------------------------------------------------------------------
1289
+ // git-derived context — used by `smith policy check` when --branch is not given
1290
+ // ---------------------------------------------------------------------------
1291
+ /** `git rev-parse --abbrev-ref HEAD`, or `''` outside a git repo / on a fresh repo with no commits — mirrors guard.sh's `current_branch()`. */
1292
+ export function detectCurrentBranch(cwd = REPO_ROOT) {
1293
+ try {
1294
+ return runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
1295
+ }
1296
+ catch {
1297
+ return '';
1298
+ }
1299
+ }
1300
+ /** `git rev-parse --show-toplevel`, or `null` outside a git repo — mirrors guard.sh's inline `REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || printf '')"`. */
1301
+ export function detectRepoRoot(cwd = REPO_ROOT) {
1302
+ try {
1303
+ return runGit(cwd, ['rev-parse', '--show-toplevel']);
1304
+ }
1305
+ catch {
1306
+ return null;
1307
+ }
1308
+ }