@brainervirus/workit-core 8.22.3 → 8.24.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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +5 -5
- package/skills/workit-fanout/references/brief.md +17 -11
- package/skills/workit-fanout/references/orchestration.md +6 -6
- package/skills/workit-implement/SKILL.md +7 -5
- package/skills/workit-review/SKILL.md +3 -0
- package/skills/workit-ship/SKILL.md +1 -2
- package/src/autonomy.ts +22 -11
- package/src/check-config.ts +9 -2
- package/src/core/methods.ts +3 -3
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +513 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/workspaces.ts +1 -1
- package/src/fanout-brief.ts +23 -15
- package/src/fanout-worktree.ts +20 -6
- package/src/forge/pr-ops.ts +54 -12
- package/src/forge/report.ts +4 -2
- package/src/git/rev.ts +133 -0
- package/src/hooks/handle.ts +29 -8
- package/src/hooks/raw-git.ts +19 -0
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +363 -0
- package/src/hooks/write-gate.ts +189 -35
- package/src/ledger.ts +7 -2
- package/src/stack.ts +54 -12
- package/src/worktree-deps.ts +142 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Worktree confinement for implementer subagents (#310). Without host
|
|
2
|
+
// worktree isolation (Claude Code's refused every command it could not
|
|
3
|
+
// verify, `workit git` included), the lead makes the implementer's worktree
|
|
4
|
+
// with `workit fanout worktree create` and Workit's own hooks keep the
|
|
5
|
+
// implementer in it:
|
|
6
|
+
//
|
|
7
|
+
// - binding: the first edit or mutating git command an implementer makes
|
|
8
|
+
// inside a worktree that `workit fanout worktree create` made (its admin
|
|
9
|
+
// dir holds the fanout token) binds that agent to it, in
|
|
10
|
+
// `<state dir>/worktree-bind/<session+agent>.json`, with a ledger row;
|
|
11
|
+
// - after binding, an edit or mutating git command outside that worktree is
|
|
12
|
+
// denied naming it; the host's temp dir stays writable;
|
|
13
|
+
// - before binding (ruling: the safe default), an edit outside every Workit
|
|
14
|
+
// worktree is denied too, so an implementer never edits the lead's checkout;
|
|
15
|
+
// - always, what the lead owns is denied: worktree, ref and config changes
|
|
16
|
+
// (`git worktree add|remove`, `branch -D`, `update-ref`, a delete push…)
|
|
17
|
+
// and `workit fanout plan|worktree`.
|
|
18
|
+
//
|
|
19
|
+
// An agent the host isolated itself (a real linked worktree at Claude Code's
|
|
20
|
+
// `<repo>/.claude/worktrees/agent-<id>`) is left to the host. Reads only the
|
|
21
|
+
// filesystem; fails open on any error.
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { resolveStateDir } from "../core/logger";
|
|
26
|
+
import { appendHookObserved } from "../ledger";
|
|
27
|
+
import type { HookDecision, HookInput } from "./protocol";
|
|
28
|
+
import { inWorkitWorkspace, rawInvocations, skipWorkitGlobals, workitInvocations } from "./raw-git";
|
|
29
|
+
import type { ShellDialect } from "./shell-words";
|
|
30
|
+
import { shellMutationTargets, UNKNOWN_DIR } from "./write-gate";
|
|
31
|
+
|
|
32
|
+
/** Written by `workit fanout worktree create` into git's admin dir for the worktree. */
|
|
33
|
+
export const FANOUT_TOKEN_FILE = "workit-fanout-token";
|
|
34
|
+
|
|
35
|
+
const NONE: HookDecision = { kind: "none" };
|
|
36
|
+
const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
|
|
37
|
+
|
|
38
|
+
/** The real path of `file`'s nearest existing ancestor, plus the rest. */
|
|
39
|
+
const canonical = (file: string): string => {
|
|
40
|
+
let rest = "";
|
|
41
|
+
for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
|
|
42
|
+
try {
|
|
43
|
+
return path.join(fs.realpathSync.native(dir), rest);
|
|
44
|
+
} catch {
|
|
45
|
+
if (path.dirname(dir) === dir) return path.resolve(file);
|
|
46
|
+
rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const within = (child: string, parent: string): boolean => {
|
|
52
|
+
const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
|
|
53
|
+
const relative = path.relative(fold(parent), fold(child));
|
|
54
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The git checkout `file` is in (the nearest directory above it with a
|
|
59
|
+
* `.git`), and whether `workit fanout worktree create` made it: its `.git`
|
|
60
|
+
* file names an admin dir that holds the fanout token. Null outside any
|
|
61
|
+
* repository.
|
|
62
|
+
*/
|
|
63
|
+
const checkoutOf = (file: string): { root: string; workit: boolean } | null => {
|
|
64
|
+
for (let dir = canonical(file); ; dir = path.dirname(dir)) {
|
|
65
|
+
const dotGit = path.join(dir, ".git");
|
|
66
|
+
if (fs.existsSync(dotGit)) {
|
|
67
|
+
if (!fs.statSync(dotGit).isFile()) return { root: dir, workit: false };
|
|
68
|
+
const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
69
|
+
return {
|
|
70
|
+
root: dir,
|
|
71
|
+
workit:
|
|
72
|
+
Boolean(admin) && fs.existsSync(path.join(path.resolve(dir, admin!), FANOUT_TOKEN_FILE)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (path.dirname(dir) === dir) return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The root of the Workit-made worktree `file` is in, else null. */
|
|
80
|
+
const workitWorktreeOf = (file: string): string | null => {
|
|
81
|
+
try {
|
|
82
|
+
const checkout = checkoutOf(file);
|
|
83
|
+
return checkout?.workit ? checkout.root : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type Binding = { worktree: string; session: string; agentId: string; at: string };
|
|
90
|
+
|
|
91
|
+
// Bindings live in Workit's machine state dir, keyed by session and agent, so
|
|
92
|
+
// one is kept wherever the hook's cwd is (even outside every repository).
|
|
93
|
+
const bindingDir = (): string => path.join(resolveStateDir(), "worktree-bind");
|
|
94
|
+
|
|
95
|
+
const bindingFile = (session: string, agentId: string): string =>
|
|
96
|
+
path.join(
|
|
97
|
+
bindingDir(),
|
|
98
|
+
`${createHash("sha256").update(`${session}\0${agentId}`).digest("hex").slice(0, 32)}.json`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const readBinding = (file: string): Binding | null => {
|
|
102
|
+
try {
|
|
103
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Binding>;
|
|
104
|
+
return typeof value.worktree === "string" ? (value as Binding) : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The worktree `input`'s agent is bound to, while that worktree is still a Workit one. */
|
|
111
|
+
const boundWorktree = (input: HookInput): string | null => {
|
|
112
|
+
const agentId = input.session.agentId;
|
|
113
|
+
if (!agentId) return null;
|
|
114
|
+
const binding = readBinding(bindingFile(input.session.id, agentId));
|
|
115
|
+
return binding && workitWorktreeOf(binding.worktree) === binding.worktree
|
|
116
|
+
? binding.worktree
|
|
117
|
+
: null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const bind = (input: HookInput, worktree: string): void => {
|
|
121
|
+
const agentId = input.session.agentId!;
|
|
122
|
+
const file = bindingFile(input.session.id, agentId);
|
|
123
|
+
const binding: Binding = {
|
|
124
|
+
worktree,
|
|
125
|
+
session: input.session.id,
|
|
126
|
+
agentId,
|
|
127
|
+
at: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify(binding));
|
|
131
|
+
try {
|
|
132
|
+
if (inWorkitWorkspace(input.cwd))
|
|
133
|
+
appendHookObserved(input.cwd, {
|
|
134
|
+
type: "worktree.bound",
|
|
135
|
+
actor: { host: input.host, session: input.session.id, agentId },
|
|
136
|
+
agentType: input.session.agentType,
|
|
137
|
+
worktree,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// The binding file is what the hook reads; the row is evidence only.
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Claude Code's own isolation: the agent runs in a linked worktree (its `.git`
|
|
146
|
+
* is a file) whose root is exactly `<repo>/.claude/worktrees/agent-<its id>`.
|
|
147
|
+
* A directory that only looks like one is not.
|
|
148
|
+
*/
|
|
149
|
+
export const hostIsolated = (cwd: string, agentId: string | null): boolean => {
|
|
150
|
+
try {
|
|
151
|
+
if (agentId === null) return false;
|
|
152
|
+
const checkout = checkoutOf(cwd);
|
|
153
|
+
if (!checkout) return false;
|
|
154
|
+
const dotGit = path.join(checkout.root, ".git");
|
|
155
|
+
if (!fs.statSync(dotGit).isFile()) return false;
|
|
156
|
+
const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
157
|
+
if (!admin) return false;
|
|
158
|
+
const adminDir = path.resolve(checkout.root, admin);
|
|
159
|
+
// The admin dir must point back at this checkout: a forged `.git` file
|
|
160
|
+
// naming a sibling's admin dir does not make a directory that sibling's.
|
|
161
|
+
const back = fs.readFileSync(path.join(adminDir, "gitdir"), "utf8").trim();
|
|
162
|
+
if (path.relative(canonical(path.resolve(adminDir, back)), canonical(dotGit)) !== "")
|
|
163
|
+
return false;
|
|
164
|
+
const common = path.join(adminDir, "commondir");
|
|
165
|
+
const gitDir = fs.existsSync(common)
|
|
166
|
+
? path.resolve(adminDir, fs.readFileSync(common, "utf8").trim())
|
|
167
|
+
: adminDir;
|
|
168
|
+
const expected = path.join(
|
|
169
|
+
path.dirname(canonical(gitDir)),
|
|
170
|
+
".claude",
|
|
171
|
+
"worktrees",
|
|
172
|
+
`agent-${agentId}`,
|
|
173
|
+
);
|
|
174
|
+
return path.relative(canonical(expected), checkout.root) === "";
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const shown = (target: string): string =>
|
|
181
|
+
path.basename(target) === ".workit-live-target" ? path.dirname(target) : target;
|
|
182
|
+
|
|
183
|
+
const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
184
|
+
const unblock =
|
|
185
|
+
"Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit --cwd <worktree> git commit -m … -- <paths>`, `workit --cwd <worktree> check <name>`). If your brief names no worktree, stop and report that to the lead.";
|
|
186
|
+
return {
|
|
187
|
+
kind: "deny",
|
|
188
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} is not working in a Workit worktree yet, and ${shown(target)} is outside every worktree \`workit fanout worktree create\` made, so it would change the lead's checkout. ${unblock}`,
|
|
189
|
+
unblock,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const outsideDeny = (input: HookInput, worktree: string, target: string): HookDecision => {
|
|
194
|
+
const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit --cwd ${worktree} git commit -m … -- <paths>\`, \`workit --cwd ${worktree} check <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
|
|
195
|
+
return {
|
|
196
|
+
kind: "deny",
|
|
197
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
|
|
198
|
+
unblock,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
|
|
203
|
+
const wt = worktree ?? "<worktree>";
|
|
204
|
+
const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit --cwd ${wt}\`.`;
|
|
205
|
+
return {
|
|
206
|
+
kind: "deny",
|
|
207
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
|
|
208
|
+
unblock,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Judge an implementer's edit or mutating command by the absolute paths it
|
|
214
|
+
* changes (`targets`). Only targets inside a git checkout, or Workit's
|
|
215
|
+
* binding records, are judged (scratch and temp files elsewhere are host
|
|
216
|
+
* policy). Binds on the first target inside a Workit worktree; denies a
|
|
217
|
+
* target outside the bound worktree (or, unbound, outside every Workit
|
|
218
|
+
* worktree). Deny reasons name a target as the agent spelled it.
|
|
219
|
+
*/
|
|
220
|
+
export function confineImplementer(input: HookInput, targets: readonly string[]): HookDecision {
|
|
221
|
+
try {
|
|
222
|
+
if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
|
|
223
|
+
if (targets.some((target) => within(path.resolve(target), UNKNOWN_DIR)))
|
|
224
|
+
return unknownDirDeny(input, boundWorktree(input));
|
|
225
|
+
const bindings = canonical(bindingDir());
|
|
226
|
+
const judged = targets
|
|
227
|
+
.map((named) => ({ named, real: canonical(named) }))
|
|
228
|
+
.filter(({ real }) => within(real, bindings) || checkoutOf(real) !== null);
|
|
229
|
+
if (judged.length === 0) return NONE;
|
|
230
|
+
let worktree = boundWorktree(input);
|
|
231
|
+
if (!worktree) {
|
|
232
|
+
for (const { real } of judged) {
|
|
233
|
+
worktree = within(real, bindings) ? null : workitWorktreeOf(real);
|
|
234
|
+
if (worktree) break;
|
|
235
|
+
}
|
|
236
|
+
if (!worktree) return unboundDeny(input, judged[0].named);
|
|
237
|
+
bind(input, worktree);
|
|
238
|
+
}
|
|
239
|
+
const root = canonical(worktree);
|
|
240
|
+
const outside = judged.find(({ real }) => !within(real, root) || within(real, bindings));
|
|
241
|
+
return outside ? outsideDeny(input, worktree, outside.named) : NONE;
|
|
242
|
+
} catch {
|
|
243
|
+
return NONE;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const MUTATING_REMOTE = new Set([
|
|
248
|
+
"add",
|
|
249
|
+
"remove",
|
|
250
|
+
"rm",
|
|
251
|
+
"rename",
|
|
252
|
+
"set-url",
|
|
253
|
+
"set-head",
|
|
254
|
+
"set-branches",
|
|
255
|
+
"prune",
|
|
256
|
+
]);
|
|
257
|
+
const CONFIG_READ = /^(?:--get(?:-all|-regexp|-urlmatch)?|--list|-l|get|list)$/;
|
|
258
|
+
const CONFIG_WRITE =
|
|
259
|
+
/^(?:--(?:unset(?:-all)?|add|replace-all|rename-section|remove-section|edit)|-e|set|unset|rename-section|remove-section|edit)$/;
|
|
260
|
+
const CONFIG_VALUE = /^(?:--file|-f|--blob|--type|--default|--comment)$/;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether git `args` (the subcommand first) change what the lead owns: the
|
|
264
|
+
* repository's worktrees, refs other than by commit, the shared stash, or
|
|
265
|
+
* its configuration and remotes.
|
|
266
|
+
*/
|
|
267
|
+
const leadOwnedGit = (args: readonly string[]): boolean => {
|
|
268
|
+
const [sub = "", ...rest] = args;
|
|
269
|
+
if (rest.some((arg) => arg === "--help" || arg === "-h")) return false;
|
|
270
|
+
const names = rest.filter((arg) => !arg.startsWith("-"));
|
|
271
|
+
const flag = (pattern: RegExp) => rest.some((arg) => pattern.test(arg));
|
|
272
|
+
switch (sub) {
|
|
273
|
+
case "worktree":
|
|
274
|
+
return names[0] !== undefined && names[0] !== "list";
|
|
275
|
+
case "update-ref":
|
|
276
|
+
return true;
|
|
277
|
+
case "symbolic-ref":
|
|
278
|
+
return names.length > 1 || flag(/^(?:-d|--delete)$/);
|
|
279
|
+
case "branch":
|
|
280
|
+
return flag(/^-[a-zA-Z]*[dDfmMcC]|^--(?:delete|force|move|copy)$/);
|
|
281
|
+
case "tag":
|
|
282
|
+
return flag(/^-[a-zA-Z]*[df]|^--(?:delete|force)$/);
|
|
283
|
+
case "push": {
|
|
284
|
+
if (flag(/^(?:-d|--delete|--mirror|--prune)$/)) return true;
|
|
285
|
+
// names[0] is the remote; the rest are refspecs. `git push [-u] [remote]` and
|
|
286
|
+
// `git push --force-with-lease [remote]` (the current branch) stay the implementer's.
|
|
287
|
+
const refspecs = names.slice(1);
|
|
288
|
+
return (
|
|
289
|
+
refspecs.some((name) => name.includes(":") || name.startsWith("+")) ||
|
|
290
|
+
(refspecs.length > 0 && flag(/^-[a-zA-Z]*f|^--force/))
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
case "replace":
|
|
294
|
+
case "filter-branch":
|
|
295
|
+
case "gc":
|
|
296
|
+
case "prune":
|
|
297
|
+
case "repack":
|
|
298
|
+
case "sparse-checkout":
|
|
299
|
+
return true;
|
|
300
|
+
case "notes":
|
|
301
|
+
case "reflog":
|
|
302
|
+
case "submodule":
|
|
303
|
+
return !/^(?:list|show|status|exists)$/.test(names[0] ?? "list");
|
|
304
|
+
case "stash":
|
|
305
|
+
return names[0] === "drop" || names[0] === "clear";
|
|
306
|
+
case "remote":
|
|
307
|
+
return MUTATING_REMOTE.has(names[0] ?? "");
|
|
308
|
+
case "config": {
|
|
309
|
+
if (rest.some((arg) => CONFIG_READ.test(arg))) return false;
|
|
310
|
+
if (rest.some((arg) => CONFIG_WRITE.test(arg))) return true;
|
|
311
|
+
let operands = 0;
|
|
312
|
+
for (let index = 0; index < rest.length; index++)
|
|
313
|
+
if (CONFIG_VALUE.test(rest[index])) index++;
|
|
314
|
+
else if (!rest[index].startsWith("-")) operands++;
|
|
315
|
+
return operands > 1;
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** A `workit [globals] fanout plan|worktree …` in `args` (also behind `check --`). */
|
|
323
|
+
const leadOwnedWorkit = (args: readonly string[]): boolean =>
|
|
324
|
+
!args.includes("--help") &&
|
|
325
|
+
args.some((_, start) => {
|
|
326
|
+
if (start > 0 && !/(?:^|[\\/])workit(?:\.exe|\.cmd)?$/.test(args[start - 1])) return false;
|
|
327
|
+
const [verb, sub] = skipWorkitGlobals(args.slice(start)).rest;
|
|
328
|
+
return verb === "fanout" && /^(?:plan|worktree)$/.test(sub ?? "");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const leadOwnedDeny = (input: HookInput, command: string): HookDecision => {
|
|
332
|
+
const unblock =
|
|
333
|
+
"Worktrees, branches other than your own, refs, tags, remotes, git config, the shared stash and the fan-out plan belong to the lead: report what you need changed as a follow-up instead.";
|
|
334
|
+
return {
|
|
335
|
+
kind: "deny",
|
|
336
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} may not run \`${command.slice(0, 160)}\`. ${unblock}`,
|
|
337
|
+
unblock,
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Judge an implementer's shell command: deny what the lead owns, then confine
|
|
343
|
+
* every path it may change (the strict analysis, see `shellMutationTargets`).
|
|
344
|
+
*/
|
|
345
|
+
export function confineImplementerShell(
|
|
346
|
+
input: HookInput,
|
|
347
|
+
command: string,
|
|
348
|
+
dialect: ShellDialect = "posix",
|
|
349
|
+
): HookDecision {
|
|
350
|
+
try {
|
|
351
|
+
if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
|
|
352
|
+
if (
|
|
353
|
+
rawInvocations(command, dialect).some(
|
|
354
|
+
(invocation) => invocation.tool === "git" && leadOwnedGit(invocation.args),
|
|
355
|
+
) ||
|
|
356
|
+
workitInvocations(command, dialect).some(leadOwnedWorkit)
|
|
357
|
+
)
|
|
358
|
+
return leadOwnedDeny(input, command);
|
|
359
|
+
} catch {
|
|
360
|
+
// An unanalysable command falls through to the path confinement.
|
|
361
|
+
}
|
|
362
|
+
return confineImplementer(input, shellMutationTargets(input.cwd, command, dialect, true));
|
|
363
|
+
}
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// unblock. History moves (commit, merge, rebase, stash pop) are not edits.
|
|
5
5
|
// Reads only the task index, one task snapshot and the ledger; never
|
|
6
6
|
// migrates, never captures a candidate, and fails open on any state error.
|
|
7
|
-
import { realpathSync } from "node:fs";
|
|
7
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { fileSignature, TaskStore } from "../core/task-store";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
inWorkitWorkspace,
|
|
26
26
|
rawInvocations,
|
|
27
27
|
workitCheckTail,
|
|
28
|
+
skipWorkitGlobals,
|
|
28
29
|
workitInvocations,
|
|
29
30
|
type RawInvocation,
|
|
30
31
|
} from "./raw-git";
|
|
@@ -70,7 +71,7 @@ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<stri
|
|
|
70
71
|
);
|
|
71
72
|
};
|
|
72
73
|
|
|
73
|
-
const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs"]);
|
|
74
|
+
const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs", "busybox"]);
|
|
74
75
|
const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
|
|
75
76
|
|
|
76
77
|
const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
@@ -86,12 +87,9 @@ const wrappedScript = (input: string[]): string | null => {
|
|
|
86
87
|
const tail = workitCheckTail(words);
|
|
87
88
|
if (!tail) return null;
|
|
88
89
|
if ("script" in tail) return tail.script;
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
/^-[a-z]*c$/.test(flag ?? "")
|
|
93
|
-
)
|
|
94
|
-
return script ?? null;
|
|
90
|
+
const script = shellScript(tail.argv);
|
|
91
|
+
if (SCRIPT_SHELLS.has((tail.argv[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase()) && script)
|
|
92
|
+
return script;
|
|
95
93
|
return tail.argv.map(quoteWord).join(" ");
|
|
96
94
|
};
|
|
97
95
|
|
|
@@ -167,6 +165,112 @@ const editsOf = (input: string[]): string[] | null => {
|
|
|
167
165
|
}
|
|
168
166
|
};
|
|
169
167
|
|
|
168
|
+
/** A `bash -c '<script>'` (any SCRIPT_SHELLS) script, else null. */
|
|
169
|
+
const shellScript = (words: readonly string[]): string | null => {
|
|
170
|
+
if (!SCRIPT_SHELLS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) return null;
|
|
171
|
+
// Options may come before `-c` (`bash --norc -c`, `sh -e -c`); the script follows it.
|
|
172
|
+
for (let index = 1; index < words.length; index++) {
|
|
173
|
+
const word = words[index];
|
|
174
|
+
if (/^-[a-z]*c$/.test(word)) return words[index + 1] ?? null;
|
|
175
|
+
if (/^[-+][oO]$/.test(word)) index++;
|
|
176
|
+
else if (!/^[-+]/.test(word)) return null;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** xargs options that take the next word as their value. */
|
|
182
|
+
const XARGS_VALUE = /^-[IinLPdsEa]$/;
|
|
183
|
+
|
|
184
|
+
/** The command `xargs` (or `find -exec`) runs with `{}` filled in by a path. */
|
|
185
|
+
const runsWriter = (words: readonly string[]): boolean => {
|
|
186
|
+
const filled = [...words.filter((word) => word !== "{}" && word !== ";" && word !== "+"), "x"];
|
|
187
|
+
const edits = editsOf(filled);
|
|
188
|
+
return edits === null || edits.length > 0 || opaqueWrites(filled) !== null;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* For an implementer's confinement: commands that may write where the edit
|
|
193
|
+
* analysis cannot see (interpreters, chmod, rsync, `find -delete`, `xargs rm`,
|
|
194
|
+
* `awk -i`, a `-c` shell script): the paths they may change, "." standing for
|
|
195
|
+
* the directory they run in; null when the command is none of them.
|
|
196
|
+
*/
|
|
197
|
+
const opaqueWrites = (input: readonly string[]): string[] | null => {
|
|
198
|
+
let words = input;
|
|
199
|
+
while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) {
|
|
200
|
+
if (words[0] === "xargs") {
|
|
201
|
+
let at = 1;
|
|
202
|
+
while (words[at]?.startsWith("-")) at += XARGS_VALUE.test(words[at]) ? 2 : 1;
|
|
203
|
+
return runsWriter(words.slice(at)) ? ["."] : null;
|
|
204
|
+
}
|
|
205
|
+
words = words.slice(1);
|
|
206
|
+
}
|
|
207
|
+
const [verb, ...args] = words;
|
|
208
|
+
const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
209
|
+
const files = operands(args);
|
|
210
|
+
const has = (pattern: RegExp) => args.some((arg) => pattern.test(arg));
|
|
211
|
+
const listed = (named: string[]) => (named.length ? named : ["."]);
|
|
212
|
+
if (name === "unlink" || name === "shred") return listed(files);
|
|
213
|
+
// The mode or owner comes first, unless `--reference` names it.
|
|
214
|
+
if (/^(?:chmod|chown|chgrp|chattr)$/.test(name))
|
|
215
|
+
return listed(has(/^--reference/) ? files : files.slice(1));
|
|
216
|
+
if (name === "rsync") {
|
|
217
|
+
const dest = files.at(-1);
|
|
218
|
+
// `host:path` is remote; a drive letter (`C:/…`, `C:\…`) is a local path.
|
|
219
|
+
const remote = /^[\w.@-]*:/.test(dest ?? "") && !/^[A-Za-z]:[\\/]/.test(dest ?? "");
|
|
220
|
+
return dest === undefined ? ["."] : remote ? [] : [dest];
|
|
221
|
+
}
|
|
222
|
+
if (name === "find") {
|
|
223
|
+
const exec = args.findIndex((arg) => /^-(?:exec|execdir|ok|okdir)$/.test(arg));
|
|
224
|
+
const writes =
|
|
225
|
+
has(/^-(?:delete|fprint\w*|fls)$/) || (exec >= 0 && runsWriter(args.slice(exec + 1)));
|
|
226
|
+
const end = args.findIndex((arg) => /^[-(!]/.test(arg));
|
|
227
|
+
return writes ? listed(end < 0 ? args : args.slice(0, end)) : null;
|
|
228
|
+
}
|
|
229
|
+
if (/^g?awk$/.test(name)) {
|
|
230
|
+
if (!has(/^-i|^--include/)) return null;
|
|
231
|
+
const named: string[] = [];
|
|
232
|
+
let program = !has(/^-f$|^--file/);
|
|
233
|
+
for (let index = 0; index < args.length; index++)
|
|
234
|
+
if (/^(?:-[ifvF]|--(?:include|file|assign|field-separator))$/.test(args[index])) index++;
|
|
235
|
+
else if (args[index].startsWith("-")) continue;
|
|
236
|
+
else if (program) program = false;
|
|
237
|
+
else named.push(args[index]);
|
|
238
|
+
return listed(named);
|
|
239
|
+
}
|
|
240
|
+
if (name === "perl") return has(/^-[a-zA-Z]*[eE]/) ? ["."] : null;
|
|
241
|
+
if (/^(?:python[\d.]*|node|bun|deno|ruby|php)$/.test(name))
|
|
242
|
+
return has(/^-[a-zA-Z]*[ce]$|^--eval$|^-p$/) ? ["."] : null;
|
|
243
|
+
return shellScript(words) === null ? null : ["."];
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `ln`'s link sources, resolved as the link will: a relative symbolic source
|
|
248
|
+
* against the directory the link lands in. Writes through such a link change
|
|
249
|
+
* its source, so an implementer's links are judged by where they point.
|
|
250
|
+
*/
|
|
251
|
+
const linkSources = (args: readonly string[], at: (file: string) => string | null): string[] => {
|
|
252
|
+
const names = operands([...args]);
|
|
253
|
+
const into = args.indexOf("-t");
|
|
254
|
+
const dest = into >= 0 ? args[into + 1] : names.length > 1 ? names.at(-1) : ".";
|
|
255
|
+
const sources = names.filter((name, index) =>
|
|
256
|
+
into >= 0 ? name !== dest : index < names.length - 1 || names.length === 1,
|
|
257
|
+
);
|
|
258
|
+
const target = dest === undefined ? null : at(dest);
|
|
259
|
+
const symbolic = args.some((arg) => /^-[a-zA-Z]*s|^--symbolic$/.test(arg));
|
|
260
|
+
const base =
|
|
261
|
+
target === null
|
|
262
|
+
? null
|
|
263
|
+
: into >= 0 || sources.length > 1 || existsSync(target) || /[\\/]$/.test(dest ?? "")
|
|
264
|
+
? target
|
|
265
|
+
: path.dirname(target);
|
|
266
|
+
return sources.flatMap((source) => {
|
|
267
|
+
if (symbolic && base !== null && !path.isAbsolute(homeExpanded(source)))
|
|
268
|
+
return [path.resolve(base, source)];
|
|
269
|
+
const resolved = at(source);
|
|
270
|
+
return resolved ? [resolved] : [];
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
|
|
170
274
|
/**
|
|
171
275
|
* Whether a shell command recognizably edits files (redirects, tee, touch,
|
|
172
276
|
* mkdir, cp/mv/rm, `sed -i`/`perl -i`, dd, and working-tree git: apply,
|
|
@@ -325,13 +429,25 @@ const CD = new Set(["cd", "chdir", "set-location", "sl"]);
|
|
|
325
429
|
const PUSHD = new Set(["pushd", "push-location"]);
|
|
326
430
|
const POPD = new Set(["popd", "pop-location"]);
|
|
327
431
|
|
|
328
|
-
/**
|
|
329
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Stands for a directory an implementer's command moved to but the analysis
|
|
434
|
+
* cannot name (`cd -`, `cd "$X"`, a bare `popd`): a write that lands under it
|
|
435
|
+
* is denied by `confineImplementer`, as the shell may be anywhere.
|
|
436
|
+
*/
|
|
437
|
+
export const UNKNOWN_DIR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-dir");
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A `cd` operand resolved against `dir`. A target that cannot be known is
|
|
441
|
+
* undefined, or `UNKNOWN_DIR` when `strict`.
|
|
442
|
+
*/
|
|
443
|
+
const cdInto = (dir: string | undefined, words: string[], strict = false): string | undefined => {
|
|
444
|
+
const lost = strict ? UNKNOWN_DIR : undefined;
|
|
330
445
|
const named = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
|
|
331
446
|
// Only a leading `~` or `$HOME` expands; a mid-path `~` is literal (Windows 8.3
|
|
332
447
|
// short names like RUNNER~1).
|
|
333
448
|
const target = named === undefined ? undefined : homeExpanded(named);
|
|
334
|
-
if (
|
|
449
|
+
if (target === "-" || (target && /^~|[$`*?]/.test(target))) return lost;
|
|
450
|
+
if (dir === undefined) return lost;
|
|
335
451
|
return target === undefined ? os.homedir() : path.resolve(dir, target);
|
|
336
452
|
};
|
|
337
453
|
|
|
@@ -355,6 +471,7 @@ const segmentTargets = (
|
|
|
355
471
|
command: string,
|
|
356
472
|
dialect: ShellDialect,
|
|
357
473
|
depth = 0,
|
|
474
|
+
strict = false,
|
|
358
475
|
): string[] => {
|
|
359
476
|
const targets: string[] = [];
|
|
360
477
|
let dir: string | undefined = cwd;
|
|
@@ -378,21 +495,34 @@ const segmentTargets = (
|
|
|
378
495
|
const words = lead < 0 ? [] : segment.words.slice(lead);
|
|
379
496
|
const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
|
|
380
497
|
if (CD.has(name)) {
|
|
381
|
-
dir = cdInto(dir, words);
|
|
498
|
+
dir = cdInto(dir, words, strict);
|
|
382
499
|
continue;
|
|
383
500
|
}
|
|
384
501
|
if (PUSHD.has(name)) {
|
|
385
502
|
stack.push(dir);
|
|
386
|
-
dir = cdInto(dir, words);
|
|
503
|
+
dir = cdInto(dir, words, strict);
|
|
387
504
|
continue;
|
|
388
505
|
}
|
|
389
506
|
if (POPD.has(name)) {
|
|
390
|
-
dir = stack.length ? stack.pop() : undefined;
|
|
507
|
+
dir = stack.length ? stack.pop() : strict ? UNKNOWN_DIR : undefined;
|
|
391
508
|
continue;
|
|
392
509
|
}
|
|
393
|
-
const inner = depth < 4 ? wrappedScript(words) : null;
|
|
510
|
+
const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
|
|
394
511
|
if (inner !== null && dir !== undefined)
|
|
395
|
-
targets.push(...segmentTargets(dir, inner, "posix", depth + 1));
|
|
512
|
+
targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
|
|
513
|
+
if (strict && dir !== undefined) {
|
|
514
|
+
const runIn = dir;
|
|
515
|
+
for (const sub of depth < 4 ? (segment.subs ?? []) : [])
|
|
516
|
+
targets.push(...segmentTargets(runIn, sub, dialect, depth + 1, true));
|
|
517
|
+
const opaque = segment.unanalyzable ? ["."] : inner === null ? opaqueWrites(words) : null;
|
|
518
|
+
for (const file of opaque ?? []) {
|
|
519
|
+
const resolved = at(file);
|
|
520
|
+
if (resolved) targets.push(file === "." ? path.join(resolved, DIR_TARGET) : resolved);
|
|
521
|
+
}
|
|
522
|
+
const ln = words.findIndex((word) => /^ln$/i.test(word.split(/[\\/]/).at(-1) ?? ""));
|
|
523
|
+
if (ln >= 0 && words.slice(0, ln).every((word) => PREFIXES.has(word)))
|
|
524
|
+
targets.push(...linkSources(words.slice(ln + 1), at));
|
|
525
|
+
}
|
|
396
526
|
for (const file of segment.redirects)
|
|
397
527
|
if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) {
|
|
398
528
|
const resolved = at(file);
|
|
@@ -406,18 +536,51 @@ const segmentTargets = (
|
|
|
406
536
|
const resolved = at(file);
|
|
407
537
|
if (resolved) targets.push(resolved);
|
|
408
538
|
}
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
(
|
|
413
|
-
args[0] === "git" &&
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
539
|
+
if (dir !== undefined)
|
|
540
|
+
for (const invocation of workitInvocations(segment.words.map(quoteWord).join(" "), "posix")) {
|
|
541
|
+
const { at: runsIn, args } = workitCwd(dir, invocation);
|
|
542
|
+
if (
|
|
543
|
+
args[0] === "git" &&
|
|
544
|
+
MUTATING_WORKIT_GIT.has(args[1] ?? "") &&
|
|
545
|
+
!inert(args.slice(2), "")
|
|
546
|
+
)
|
|
547
|
+
targets.push(path.join(runsIn, DIR_TARGET));
|
|
548
|
+
}
|
|
417
549
|
}
|
|
418
550
|
return targets;
|
|
419
551
|
};
|
|
420
552
|
|
|
553
|
+
/** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
|
|
554
|
+
const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
|
|
555
|
+
const { rest, cwd } = skipWorkitGlobals(args);
|
|
556
|
+
return { at: cwd === null ? dir : path.resolve(dir, homeExpanded(cwd)), args: rest };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Where one shell command changes files: the files it writes and, as
|
|
561
|
+
* `<dir>/.workit-live-target`, each directory a mutating git or
|
|
562
|
+
* `workit git commit|branch` runs in or an unknown write happens in.
|
|
563
|
+
* `strict` (an implementer's confinement) also reads `-c` shell scripts and
|
|
564
|
+
* `$(…)`, counts writers the analysis cannot see in the directory they run
|
|
565
|
+
* in, and judges `ln` sources. Absolute paths; [] when the command cannot be
|
|
566
|
+
* analysed (fail open).
|
|
567
|
+
*/
|
|
568
|
+
export const shellMutationTargets = (
|
|
569
|
+
cwd: string,
|
|
570
|
+
command: string,
|
|
571
|
+
dialect: ShellDialect = "posix",
|
|
572
|
+
strict = false,
|
|
573
|
+
): string[] => {
|
|
574
|
+
try {
|
|
575
|
+
return [
|
|
576
|
+
...segmentTargets(cwd, command, dialect, 0, strict),
|
|
577
|
+
...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
|
|
578
|
+
];
|
|
579
|
+
} catch {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
421
584
|
/** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
|
|
422
585
|
const overridden = (command: string, dialect: ShellDialect): boolean => {
|
|
423
586
|
const segments = segmentsOf(command, dialect);
|
|
@@ -482,7 +645,7 @@ const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps)
|
|
|
482
645
|
};
|
|
483
646
|
|
|
484
647
|
/** The absolute paths an edit touches: its files, or cwd itself when unknown. */
|
|
485
|
-
const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
|
|
648
|
+
export const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
|
|
486
649
|
paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
|
|
487
650
|
|
|
488
651
|
// --- the issued-registry guard (#292) ---------------------------------------
|
|
@@ -593,16 +756,7 @@ export function shellGate(
|
|
|
593
756
|
}
|
|
594
757
|
const writes = shellWrites(command);
|
|
595
758
|
let targets: string[] = [];
|
|
596
|
-
if (owner)
|
|
597
|
-
try {
|
|
598
|
-
targets = [
|
|
599
|
-
...segmentTargets(cwd, command, dialect),
|
|
600
|
-
...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
|
|
601
|
-
];
|
|
602
|
-
} catch {
|
|
603
|
-
targets = [];
|
|
604
|
-
}
|
|
605
|
-
}
|
|
759
|
+
if (owner) targets = shellMutationTargets(cwd, command, dialect);
|
|
606
760
|
if (owner && targets.length) {
|
|
607
761
|
const live = liveGate(owner, targets, deps);
|
|
608
762
|
if (live.kind !== "none") {
|