@brainervirus/workit-core 8.16.0 → 8.17.1
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 +7 -7
- package/skills/workit-fanout/references/brief.md +3 -1
- package/skills/workit-implement/SKILL.md +8 -1
- package/skills/workit-review/SKILL.md +2 -1
- package/skills/workit-verify-app/SKILL.md +2 -0
- package/src/check-config.ts +21 -1
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +92 -0
- package/src/core/heavy.ts +542 -0
- package/src/core/resources.ts +32 -76
- package/src/core/workspaces.ts +22 -0
- package/src/fanout-brief.ts +2 -0
- package/src/fanout.ts +26 -2
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/descriptor.ts +2 -0
- package/src/hooks/handle.ts +32 -3
- package/src/hooks/heavy-gate.ts +91 -0
- package/src/hooks/hosts/claude-code.ts +34 -3
- package/src/hooks/hosts/codex.ts +6 -1
- package/src/hooks/hosts/cursor.ts +1 -1
- package/src/hooks/hosts/opencode.ts +1 -1
- package/src/hooks/hosts/pi.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -0
- package/src/hooks/raw-git.ts +104 -2
- package/src/hooks/run.ts +34 -9
- package/src/hooks/write-gate.ts +42 -2
|
@@ -54,7 +54,12 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
54
54
|
// `stop_hook_active` and `last_assistant_message` (claude 2.1.295).
|
|
55
55
|
stop: { support: "native", native: "Stop" },
|
|
56
56
|
},
|
|
57
|
-
shellPolicy: {
|
|
57
|
+
shellPolicy: {
|
|
58
|
+
deny: "native",
|
|
59
|
+
channel: "permissionDecision",
|
|
60
|
+
failClosed: false,
|
|
61
|
+
rewrite: "native",
|
|
62
|
+
},
|
|
58
63
|
context: {
|
|
59
64
|
sessionStart: "native",
|
|
60
65
|
perTurn: "native",
|
|
@@ -185,7 +190,16 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
185
190
|
return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
|
|
186
191
|
const command = toolCommand(value);
|
|
187
192
|
return command
|
|
188
|
-
? {
|
|
193
|
+
? {
|
|
194
|
+
ok: true,
|
|
195
|
+
event: {
|
|
196
|
+
kind: "shell.pre",
|
|
197
|
+
command,
|
|
198
|
+
toolUseId,
|
|
199
|
+
dialect: dialectOf(value),
|
|
200
|
+
toolInput: value.tool_input as Record<string, unknown>,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
189
203
|
: { ok: false, error: `tool_input.command is required for ${value.tool_name}` };
|
|
190
204
|
}
|
|
191
205
|
case "PostToolUse": {
|
|
@@ -254,8 +268,25 @@ const CONTEXT_EVENTS = new Set([
|
|
|
254
268
|
"PostToolUse",
|
|
255
269
|
]);
|
|
256
270
|
|
|
257
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Never emits `allow`: that would bypass the user's own permission prompt. A
|
|
273
|
+
* rewrite is `updatedInput` with no permissionDecision: Claude Code applies it
|
|
274
|
+
* and runs its normal permission flow on the rewritten command (claude
|
|
275
|
+
* 2.1.306: a hook result with updatedInput and no permissionBehavior is
|
|
276
|
+
* yielded as the input to run).
|
|
277
|
+
*/
|
|
258
278
|
const render = (decision: HookDecision, native: string | null) => {
|
|
279
|
+
if (decision.kind === "rewrite" && native === "PreToolUse" && decision.input)
|
|
280
|
+
return {
|
|
281
|
+
json: {
|
|
282
|
+
hookSpecificOutput: {
|
|
283
|
+
hookEventName: "PreToolUse",
|
|
284
|
+
updatedInput: decision.input,
|
|
285
|
+
...(decision.text ? { additionalContext: decision.text } : {}),
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
exitCode: 0,
|
|
289
|
+
};
|
|
259
290
|
if (decision.kind === "deny" && native === "PreToolUse")
|
|
260
291
|
return {
|
|
261
292
|
json: {
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -88,7 +88,12 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
88
88
|
// last_assistant_message, turn_id; output `{"decision":"block","reason"}`.
|
|
89
89
|
stop: { support: "native", native: "Stop" },
|
|
90
90
|
},
|
|
91
|
-
shellPolicy: {
|
|
91
|
+
shellPolicy: {
|
|
92
|
+
deny: "native",
|
|
93
|
+
channel: "permissionDecision",
|
|
94
|
+
failClosed: false,
|
|
95
|
+
rewrite: "none",
|
|
96
|
+
},
|
|
92
97
|
context: {
|
|
93
98
|
sessionStart: "native",
|
|
94
99
|
perTurn: "native",
|
|
@@ -65,7 +65,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
65
65
|
},
|
|
66
66
|
// Fails open like every host: a payload Workit cannot parse (a renamed
|
|
67
67
|
// field in a Cursor release) must not block every shell command.
|
|
68
|
-
shellPolicy: { deny: "native", channel: "exit2+json", failClosed: false },
|
|
68
|
+
shellPolicy: { deny: "native", channel: "exit2+json", failClosed: false, rewrite: "none" },
|
|
69
69
|
context: {
|
|
70
70
|
sessionStart: "native",
|
|
71
71
|
perTurn: "none",
|
|
@@ -25,7 +25,7 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
|
25
25
|
// session went idle, and resume semantics are inferred from the schema.
|
|
26
26
|
stop: { support: "partial", native: 'event("session.idle")' },
|
|
27
27
|
},
|
|
28
|
-
shellPolicy: { deny: "native", channel: "effect", failClosed: false },
|
|
28
|
+
shellPolicy: { deny: "native", channel: "effect", failClosed: false, rewrite: "native" },
|
|
29
29
|
context: {
|
|
30
30
|
sessionStart: "none",
|
|
31
31
|
perTurn: "native",
|
package/src/hooks/hosts/pi.ts
CHANGED
|
@@ -24,7 +24,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
|
|
|
24
24
|
// no loop guard: the extension continues at most once per run.
|
|
25
25
|
stop: { support: "native", native: "agent_end" },
|
|
26
26
|
},
|
|
27
|
-
shellPolicy: { deny: "native", channel: "block", failClosed: false },
|
|
27
|
+
shellPolicy: { deny: "native", channel: "block", failClosed: false, rewrite: "native" },
|
|
28
28
|
context: {
|
|
29
29
|
sessionStart: "native",
|
|
30
30
|
// before_agent_start runs every turn; its message stays in the session,
|
package/src/hooks/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
type SessionHandle,
|
|
20
20
|
} from "./context";
|
|
21
21
|
export { shellPolicy } from "./policy";
|
|
22
|
+
export { heavyDenial, heavyGate, splitDenial } from "./heavy-gate";
|
|
22
23
|
export { shellGate, shellWrites, writeGate } from "./write-gate";
|
|
23
24
|
export { liveOwner } from "../core/live-session";
|
|
24
25
|
export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type HookEvent =
|
|
|
27
27
|
toolUseId: string | null;
|
|
28
28
|
/** The shell that runs `command`; posix when absent. */
|
|
29
29
|
dialect?: "posix" | "powershell";
|
|
30
|
+
/** The host's whole tool input, where a rewrite must hand it back (Claude Code). */
|
|
31
|
+
toolInput?: Record<string, unknown>;
|
|
30
32
|
}
|
|
31
33
|
/** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
|
|
32
34
|
| {
|
|
@@ -95,6 +97,12 @@ export type HookDecision =
|
|
|
95
97
|
| { kind: "context"; text: string }
|
|
96
98
|
/** The reason text always carries its own correction (principle 3). */
|
|
97
99
|
| { kind: "deny"; reason: string; unblock: string | null }
|
|
100
|
+
/**
|
|
101
|
+
* shell.pre: run `command` instead (the heavy-command lease rewrite).
|
|
102
|
+
* `input` is the host's tool input with the command replaced, where the
|
|
103
|
+
* host takes the whole input back; `text` is model-visible context.
|
|
104
|
+
*/
|
|
105
|
+
| { kind: "rewrite"; command: string; input: Record<string, unknown> | null; text: string | null }
|
|
98
106
|
/** Stop/SubagentStop: keep working. */
|
|
99
107
|
| { kind: "continue"; reason: string }
|
|
100
108
|
| { kind: "notice"; userMessage: string };
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -544,7 +544,29 @@ export function rawInvocations(
|
|
|
544
544
|
/** The invocations of the last few commands parsed (pure: same text, same answer). */
|
|
545
545
|
const RECENT = new Map<string, readonly RawInvocation[]>();
|
|
546
546
|
|
|
547
|
-
|
|
547
|
+
/**
|
|
548
|
+
* Every command line a shell command runs, past the same wrappers, nesting
|
|
549
|
+
* and chains rawInvocations reads (`bash -c '…'`, `flock f -c '…'`, `xargs`,
|
|
550
|
+
* `timeout 30`, env prefixes): the words after the prefixes, outermost first.
|
|
551
|
+
*/
|
|
552
|
+
export function commandLines(command: string, dialect: ShellDialect = "posix"): string[][] {
|
|
553
|
+
const lines: string[][] = [];
|
|
554
|
+
try {
|
|
555
|
+
walk(command, dialect, 0, lines);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
throw error instanceof ShellAnalysisError
|
|
558
|
+
? error
|
|
559
|
+
: new ShellAnalysisError(error instanceof Error ? error.message : String(error));
|
|
560
|
+
}
|
|
561
|
+
return lines;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function walk(
|
|
565
|
+
command: string,
|
|
566
|
+
dialect: ShellDialect,
|
|
567
|
+
depth: number,
|
|
568
|
+
seen?: string[][],
|
|
569
|
+
): RawInvocation[] {
|
|
548
570
|
const found: RawInvocation[] = [];
|
|
549
571
|
let dir: Dir = null;
|
|
550
572
|
const groups: Array<{ dir: Dir; stack: Dir[] }> = [];
|
|
@@ -556,7 +578,10 @@ function walk(command: string, dialect: ShellDialect, depth: number): RawInvocat
|
|
|
556
578
|
const sessionConfig: string[] = [];
|
|
557
579
|
const nested = (script: string | null | undefined, inner: ShellDialect) => {
|
|
558
580
|
if (!script || depth >= MAX_DEPTH) return;
|
|
559
|
-
|
|
581
|
+
const inside = seen
|
|
582
|
+
? walk(script, inner, depth + 1, seen)
|
|
583
|
+
: rawInvocations(script, inner, depth + 1);
|
|
584
|
+
for (const invocation of inside)
|
|
560
585
|
found.push({ ...invocation, dir: rebase(dir, invocation.dir) });
|
|
561
586
|
};
|
|
562
587
|
const visit = (input: string[], stdin: string | undefined, hops: number) => {
|
|
@@ -567,9 +592,18 @@ function walk(command: string, dialect: ShellDialect, depth: number): RawInvocat
|
|
|
567
592
|
const { words, env } = stripPrefixes(line);
|
|
568
593
|
const name = commandName(words[0]);
|
|
569
594
|
if (!name) return;
|
|
595
|
+
seen?.push(words);
|
|
596
|
+
// `flock <file> -c '…'`: the wrapper's script.
|
|
597
|
+
if (name === "-c" || name === "--command") return nested(words[1], "posix");
|
|
570
598
|
const runs = (lines: string[][]) => {
|
|
571
599
|
if (hops < MAX_DEPTH) for (const next of lines) visit(next, undefined, hops + 1);
|
|
572
600
|
};
|
|
601
|
+
// The gates judge what `workit check … -- <cmd>` runs as if it ran raw.
|
|
602
|
+
// (The heavy-command reader, `seen`, stops at workit: a wrapped command
|
|
603
|
+
// already runs under the lease.)
|
|
604
|
+
const wrapped = seen ? null : workitCheckTail(words);
|
|
605
|
+
if (wrapped)
|
|
606
|
+
return "script" in wrapped ? nested(wrapped.script, "posix") : runs([wrapped.argv]);
|
|
573
607
|
if (CD.has(name)) {
|
|
574
608
|
const target = cdTarget(words);
|
|
575
609
|
dir = target === null ? os.homedir() : joinDir(dir, target);
|
|
@@ -659,6 +693,74 @@ const LAUNCHERS = new Set([
|
|
|
659
693
|
"command",
|
|
660
694
|
]);
|
|
661
695
|
const WORKIT_BIN = /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/;
|
|
696
|
+
const WORKIT_PACKAGE = /^@brainervirus\/workit-cli(?:@\S*)?$/;
|
|
697
|
+
const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* What a `workit check [flags] -- <cmd…>` (or `workit lock run -- <cmd…>`,
|
|
701
|
+
* also through `npx @brainervirus/workit-cli`) runs: the script of `--shell`,
|
|
702
|
+
* else the argv. Null for any other command.
|
|
703
|
+
*/
|
|
704
|
+
export function workitCheckTail(
|
|
705
|
+
words: readonly string[],
|
|
706
|
+
): { script: string } | { argv: string[] } | null {
|
|
707
|
+
let at = 0;
|
|
708
|
+
if (PACKAGE_RUNNERS.has(commandName(words[0]))) {
|
|
709
|
+
at = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
|
|
710
|
+
if (at < 0 || !WORKIT_PACKAGE.test(words[at])) return null;
|
|
711
|
+
} else if (!/^workit(?:\.exe|\.cmd)?$/.test(commandName(words[0]))) return null;
|
|
712
|
+
const args = words.slice(at + 1);
|
|
713
|
+
if (args[0] !== "check" && !(args[0] === "lock" && args[1] === "run")) return null;
|
|
714
|
+
const split = args.indexOf("--");
|
|
715
|
+
const tail = split < 0 ? [] : args.slice(split + 1);
|
|
716
|
+
if (!tail.length) return null;
|
|
717
|
+
return args.slice(0, split).includes("--shell") ? { script: tail.join(" ") } : { argv: tail };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The one command `command` runs after its wrappers (env prefixes, `timeout`,
|
|
722
|
+
* `nice`, `flock`, `bash -c '<one command>'`), or null when it is a chain,
|
|
723
|
+
* pipe, redirect, substitution, group or heredoc anywhere.
|
|
724
|
+
*/
|
|
725
|
+
export function simpleCommand(
|
|
726
|
+
command: string,
|
|
727
|
+
dialect: ShellDialect = "posix",
|
|
728
|
+
depth = 0,
|
|
729
|
+
): string[] | null {
|
|
730
|
+
let segments: Segment[];
|
|
731
|
+
try {
|
|
732
|
+
segments = segmentsOf(command, dialect);
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
if (segments.length !== 1) return null;
|
|
737
|
+
const [segment] = segments;
|
|
738
|
+
if (
|
|
739
|
+
segment.group ||
|
|
740
|
+
segment.pipe ||
|
|
741
|
+
segment.unanalyzable ||
|
|
742
|
+
segment.redirects.length ||
|
|
743
|
+
segment.subs?.length ||
|
|
744
|
+
segment.stdin?.length
|
|
745
|
+
)
|
|
746
|
+
return null;
|
|
747
|
+
const { words } = stripPrefixes(segment.words);
|
|
748
|
+
const name = commandName(words[0]);
|
|
749
|
+
if (!name || depth >= MAX_DEPTH) return null;
|
|
750
|
+
if (SHELLS.has(name)) {
|
|
751
|
+
const script = shellScript(words, undefined);
|
|
752
|
+
return script ? simpleCommand(script, "posix", depth + 1) : null;
|
|
753
|
+
}
|
|
754
|
+
if (name === "-c" || name === "--command")
|
|
755
|
+
return words[1] ? simpleCommand(words[1], "posix", depth + 1) : null;
|
|
756
|
+
if (
|
|
757
|
+
EVALS.has(name) ||
|
|
758
|
+
POWERSHELLS.has(name) ||
|
|
759
|
+
["xargs", "find", "parallel", "script", "cmd", "source", "."].includes(name)
|
|
760
|
+
)
|
|
761
|
+
return null;
|
|
762
|
+
return words;
|
|
763
|
+
}
|
|
662
764
|
|
|
663
765
|
/**
|
|
664
766
|
* The arguments of every workit CLI invocation in a shell command: past env
|
package/src/hooks/run.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
// Hook-process entry: stdin JSON → parse → handle → render → stdout
|
|
2
|
-
|
|
1
|
+
// Hook-process entry: stdin JSON → parse → handle → render → stdout, under
|
|
2
|
+
// the hook's own deadline (deadline.ts).
|
|
3
|
+
import {
|
|
4
|
+
dispatchWithin,
|
|
5
|
+
hookBudget,
|
|
6
|
+
PROCESS_START,
|
|
7
|
+
readWithin,
|
|
8
|
+
skippedHook,
|
|
9
|
+
type HookDeadline,
|
|
10
|
+
} from "./deadline";
|
|
3
11
|
import { claudeCodeAdapter } from "./hosts/claude-code";
|
|
4
12
|
import { codexAdapter } from "./hosts/codex";
|
|
5
13
|
import { cursorAdapter } from "./hosts/cursor";
|
|
@@ -17,25 +25,31 @@ export const HOOK_ADAPTERS: Record<ProcessHost, HostAdapter> = {
|
|
|
17
25
|
type Sink = { write(chunk: string): unknown };
|
|
18
26
|
|
|
19
27
|
/** Runs one hook invocation and returns the process exit code. A broken
|
|
20
|
-
* payload never throws: the host's fail policy decides what is written.
|
|
28
|
+
* payload never throws: the host's fail policy decides what is written. Past
|
|
29
|
+
* the deadline the host gets a minimal result naming the skip. */
|
|
21
30
|
export async function runHookProcess(
|
|
22
31
|
host: ProcessHost | HostAdapter,
|
|
23
32
|
stdin: AsyncIterable<unknown> | Iterable<unknown>,
|
|
24
33
|
stdout: Sink,
|
|
25
34
|
stderr: Sink = process.stderr,
|
|
26
35
|
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
deadline: HookDeadline = {},
|
|
27
37
|
): Promise<number> {
|
|
28
38
|
const adapter = typeof host === "string" ? HOOK_ADAPTERS[host] : host;
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
const budget = hookBudget(env, deadline, adapter.descriptor.host);
|
|
40
|
+
const text = await readWithin(stdin, budget);
|
|
31
41
|
let raw: unknown;
|
|
32
42
|
try {
|
|
33
|
-
raw = JSON.parse(text || "{}");
|
|
43
|
+
raw = text === null ? undefined : JSON.parse(text || "{}");
|
|
34
44
|
} catch {
|
|
35
45
|
raw = undefined;
|
|
36
46
|
}
|
|
37
|
-
const result =
|
|
38
|
-
|
|
47
|
+
const result =
|
|
48
|
+
text === null
|
|
49
|
+
? skippedHook(adapter, undefined, env, budget, "input")
|
|
50
|
+
: await dispatchWithin(adapter, raw, env, budget, deadline.dispatch);
|
|
51
|
+
if (result.skipped) stderr.write(`[workit] ${result.error}\n`);
|
|
52
|
+
else if (raw === undefined || result.error)
|
|
39
53
|
stderr.write(
|
|
40
54
|
`[workit] hook input rejected: ${raw === undefined ? "invalid JSON hook input" : result.error}\n`,
|
|
41
55
|
);
|
|
@@ -48,5 +62,16 @@ if (import.meta.main) {
|
|
|
48
62
|
if (!Object.hasOwn(HOOK_ADAPTERS, host)) {
|
|
49
63
|
process.stderr.write(`usage: run.ts <${Object.keys(HOOK_ADAPTERS).join("|")}>\n`);
|
|
50
64
|
process.exitCode = 64;
|
|
51
|
-
} else
|
|
65
|
+
} else {
|
|
66
|
+
process.exitCode = await runHookProcess(
|
|
67
|
+
host,
|
|
68
|
+
process.stdin,
|
|
69
|
+
process.stdout,
|
|
70
|
+
process.stderr,
|
|
71
|
+
process.env,
|
|
72
|
+
{ startedAt: PROCESS_START },
|
|
73
|
+
);
|
|
74
|
+
// A payload that never ended must not keep the process alive past its answer.
|
|
75
|
+
process.stdin.destroy();
|
|
76
|
+
}
|
|
52
77
|
}
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { HookDecision } from "./protocol";
|
|
|
23
23
|
import {
|
|
24
24
|
inWorkitWorkspace,
|
|
25
25
|
rawInvocations,
|
|
26
|
+
workitCheckTail,
|
|
26
27
|
workitInvocations,
|
|
27
28
|
type RawInvocation,
|
|
28
29
|
} from "./raw-git";
|
|
@@ -70,6 +71,28 @@ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<stri
|
|
|
70
71
|
const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs"]);
|
|
71
72
|
const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
|
|
72
73
|
|
|
74
|
+
const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The script `workit check … -- <cmd>` runs, so the gates judge it as if it
|
|
78
|
+
* ran raw: the `--shell` string, a `bash -c` script, or the argv. Other
|
|
79
|
+
* workit verbs stay exempt (null).
|
|
80
|
+
*/
|
|
81
|
+
const wrappedScript = (input: string[]): string | null => {
|
|
82
|
+
let words = input;
|
|
83
|
+
while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) words = words.slice(1);
|
|
84
|
+
const tail = workitCheckTail(words);
|
|
85
|
+
if (!tail) return null;
|
|
86
|
+
if ("script" in tail) return tail.script;
|
|
87
|
+
const [shell, flag, script] = tail.argv;
|
|
88
|
+
if (
|
|
89
|
+
SCRIPT_SHELLS.has((shell.split(/[\\/]/).at(-1) ?? "").toLowerCase()) &&
|
|
90
|
+
/^-[a-z]*c$/.test(flag ?? "")
|
|
91
|
+
)
|
|
92
|
+
return script ?? null;
|
|
93
|
+
return tail.argv.map(quoteWord).join(" ");
|
|
94
|
+
};
|
|
95
|
+
|
|
73
96
|
/** The files one simple command edits: [] none, null unknown. */
|
|
74
97
|
const editsOf = (input: string[]): string[] | null => {
|
|
75
98
|
let words = input;
|
|
@@ -149,10 +172,19 @@ const editsOf = (input: string[]): string[] | null => {
|
|
|
149
172
|
* known, else null. Interpreters and formatters are not detected; Workit's
|
|
150
173
|
* own commands never count.
|
|
151
174
|
*/
|
|
152
|
-
export const shellWrites = (
|
|
175
|
+
export const shellWrites = (
|
|
176
|
+
command: string,
|
|
177
|
+
depth = 0,
|
|
178
|
+
): { writes: boolean; targets: string[] | null } => {
|
|
153
179
|
const targets: string[] = [];
|
|
154
180
|
let unknown = false;
|
|
155
181
|
for (const segment of segmentsOf(command)) {
|
|
182
|
+
const inner = depth < 4 ? wrappedScript(segment.words) : null;
|
|
183
|
+
if (inner !== null) {
|
|
184
|
+
const nested = shellWrites(inner, depth + 1);
|
|
185
|
+
if (nested.writes && nested.targets === null) unknown = true;
|
|
186
|
+
else if (nested.targets) targets.push(...nested.targets);
|
|
187
|
+
}
|
|
156
188
|
targets.push(
|
|
157
189
|
...segment.redirects.filter((item) => !/^\/dev\/(?:null|stdout|stderr|tty)$/.test(item)),
|
|
158
190
|
);
|
|
@@ -309,7 +341,12 @@ const quoteWord = (word: string): string =>
|
|
|
309
341
|
* directory that segment runs in (`cd`, `pushd`/`popd`, `( … )` groups). A
|
|
310
342
|
* relative target in an unknown directory is skipped.
|
|
311
343
|
*/
|
|
312
|
-
const segmentTargets = (
|
|
344
|
+
const segmentTargets = (
|
|
345
|
+
cwd: string,
|
|
346
|
+
command: string,
|
|
347
|
+
dialect: ShellDialect,
|
|
348
|
+
depth = 0,
|
|
349
|
+
): string[] => {
|
|
313
350
|
const targets: string[] = [];
|
|
314
351
|
let dir: string | undefined = cwd;
|
|
315
352
|
let stack: Array<string | undefined> = [];
|
|
@@ -342,6 +379,9 @@ const segmentTargets = (cwd: string, command: string, dialect: ShellDialect): st
|
|
|
342
379
|
dir = stack.length ? stack.pop() : undefined;
|
|
343
380
|
continue;
|
|
344
381
|
}
|
|
382
|
+
const inner = depth < 4 ? wrappedScript(words) : null;
|
|
383
|
+
if (inner !== null && dir !== undefined)
|
|
384
|
+
targets.push(...segmentTargets(dir, inner, "posix", depth + 1));
|
|
345
385
|
for (const file of segment.redirects)
|
|
346
386
|
if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) {
|
|
347
387
|
const resolved = at(file);
|