@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.
@@ -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
+ }
@@ -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 [shell, flag, script] = tail.argv;
90
- if (
91
- SCRIPT_SHELLS.has((shell.split(/[\\/]/).at(-1) ?? "").toLowerCase()) &&
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
- /** A `cd` operand resolved against `dir`; undefined when it cannot be known. */
329
- const cdInto = (dir: string | undefined, words: string[]): string | undefined => {
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 (dir === undefined || target === "-" || (target && /^~|[$`*?]/.test(target))) return undefined;
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
- dir !== undefined &&
411
- workitInvocations(segment.words.map(quoteWord).join(" "), "posix").some(
412
- (args) =>
413
- args[0] === "git" && MUTATING_WORKIT_GIT.has(args[1] ?? "") && !inert(args.slice(2), ""),
414
- )
415
- )
416
- targets.push(path.join(dir, DIR_TARGET));
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") {