@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|