@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.
@@ -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: { deny: "native", channel: "permissionDecision", failClosed: false },
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
- ? { ok: true, event: { kind: "shell.pre", command, toolUseId, dialect: dialectOf(value) } }
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
- /** Never emits `allow`: that would bypass the user's own permission prompt. */
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: {
@@ -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: { deny: "native", channel: "permissionDecision", failClosed: false },
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",
@@ -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,
@@ -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";
@@ -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 };
@@ -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
- function walk(command: string, dialect: ShellDialect, depth: number): RawInvocation[] {
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
- for (const invocation of rawInvocations(script, inner, depth + 1))
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
- import { dispatchHook } from "./handle";
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
- let text = "";
30
- for await (const chunk of stdin) text += String(chunk);
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 = dispatchHook(adapter, raw, env);
38
- if (raw === undefined || result.error)
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 process.exitCode = await runHookProcess(host, process.stdin, process.stdout);
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
  }
@@ -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 = (command: string): { writes: boolean; targets: string[] | null } => {
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 = (cwd: string, command: string, dialect: ShellDialect): string[] => {
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);