@brainervirus/workit-core 8.24.0 → 8.26.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 +4 -4
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +26 -17
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +4 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/methods.ts +2 -2
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/lane.ts +63 -14
- package/src/core/role-models.ts +209 -0
- package/src/core/workspaces.ts +56 -35
- package/src/fanout-brief.ts +26 -10
- package/src/fanout-status.ts +3 -3
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +11 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +111 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +148 -50
- package/src/stack.ts +14 -4
- package/src/verdict-cover.ts +151 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The Workit verbs a host may run without asking (issue #297, decision
|
|
2
|
+
// mvk5q3io): delivery and state verbs whose own gates (grants, non-author
|
|
3
|
+
// verdicts, branch policy) decide. One list for every host's permission
|
|
4
|
+
// block: Claude Code `Bash(workit <verb> *)`, Codex `prefix_rule`, Cursor
|
|
5
|
+
// `Shell(workit <verb>)`, and the OpenCode plugin's ask-to-allow.
|
|
6
|
+
//
|
|
7
|
+
// Never listed: verbs that run an arbitrary command (`check -- <cmd>`,
|
|
8
|
+
// `lock run -- <cmd>`, `test-audit`, `fanout check`), change authority
|
|
9
|
+
// (`grant`), install or remove host config (`init`, `upgrade`, `setup`,
|
|
10
|
+
// `uninstall`, `launch`), delete data (`gc`), import state (`state`) or write
|
|
11
|
+
// outside the forge flow (`youtrack`, `changelog`, `knowledge`, `handoff`).
|
|
12
|
+
// Pure string work: the OpenCode plugin bundles it.
|
|
13
|
+
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
14
|
+
|
|
15
|
+
export const WORKIT_ALLOWED_VERBS = [
|
|
16
|
+
"git",
|
|
17
|
+
"pr",
|
|
18
|
+
"ci",
|
|
19
|
+
"stack",
|
|
20
|
+
"release",
|
|
21
|
+
"task",
|
|
22
|
+
"ledger",
|
|
23
|
+
"evidence",
|
|
24
|
+
"finding",
|
|
25
|
+
"decision",
|
|
26
|
+
"worker",
|
|
27
|
+
"verify-delivery",
|
|
28
|
+
"policy",
|
|
29
|
+
"doctor",
|
|
30
|
+
"help",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Flags that override a Workit gate: a host never allows them unasked.
|
|
35
|
+
* Workit has no `--admin` flag and refuses a bare `--force` push itself.
|
|
36
|
+
*/
|
|
37
|
+
export const WORKIT_BYPASS_FLAGS = ["--unverified", "--overwrite-unintegrated"] as const;
|
|
38
|
+
|
|
39
|
+
const ALLOWED = new Set<string>(WORKIT_ALLOWED_VERBS);
|
|
40
|
+
|
|
41
|
+
const isBypass = (word: string): boolean =>
|
|
42
|
+
WORKIT_BYPASS_FLAGS.some((flag) => word === flag || word.startsWith(`${flag}=`));
|
|
43
|
+
|
|
44
|
+
/** `workit`, `workit-cli`, or either by path. */
|
|
45
|
+
const isWorkitWord = (word: string): boolean => /^(?:.*[\\/])?workit(?:-cli)?$/u.test(word);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is `words` one Workit call of an allowed verb? Global `--json` and
|
|
49
|
+
* `--cwd <dir>` may come first (they mean the same anywhere before `--`);
|
|
50
|
+
* `--session` may not (it changes the acting identity). A bypass flag before
|
|
51
|
+
* `--` disqualifies it, and so does a word the shell expands there (`expands`,
|
|
52
|
+
* indexes into `words`): `${F:---unverified}` could become one.
|
|
53
|
+
*/
|
|
54
|
+
export const isAllowedWorkitCall = (
|
|
55
|
+
words: readonly string[],
|
|
56
|
+
expands: readonly number[] = [],
|
|
57
|
+
): boolean => {
|
|
58
|
+
if (words[0] !== "workit") return false;
|
|
59
|
+
let at = 1;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const word = words[at];
|
|
62
|
+
if (word === "--json") at += 1;
|
|
63
|
+
else if (word === "--cwd") at += 2;
|
|
64
|
+
else if (word?.startsWith("--cwd=")) at += 1;
|
|
65
|
+
else break;
|
|
66
|
+
}
|
|
67
|
+
const verb = words[at];
|
|
68
|
+
if (verb === undefined) return false;
|
|
69
|
+
if (verb === "--version") return at === words.length - 1;
|
|
70
|
+
if (!ALLOWED.has(verb)) return false;
|
|
71
|
+
const end = words.indexOf("--");
|
|
72
|
+
const limit = end < 0 ? words.length : end;
|
|
73
|
+
return !words.slice(0, limit).some(isBypass) && !expands.some((index) => index < limit);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const bypassReason = (flag: string) =>
|
|
77
|
+
`Workit: \`${flag}\` overrides a Workit gate, so the user decides this call (no allow rule covers it).`;
|
|
78
|
+
const EXPANSION_REASON =
|
|
79
|
+
"Workit: an expansion before `--` could become a bypass flag, so the user decides this call (no allow rule covers it).";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The ask reason for a Workit call in `command` that carries a bypass flag
|
|
83
|
+
* before its `--` or a word the shell expands there, or null. Words are read after shell quoting, so `"--unverified"`,
|
|
84
|
+
* `--unverif''ied` or a tab-separated flag are found where a host's string
|
|
85
|
+
* rule could miss them. A command the tokenizer cannot analyze is judged on
|
|
86
|
+
* its text with quotes and backslashes removed.
|
|
87
|
+
*/
|
|
88
|
+
export const workitBypassFlag = (
|
|
89
|
+
command: string,
|
|
90
|
+
dialect: ShellDialect = "posix",
|
|
91
|
+
): string | null => {
|
|
92
|
+
for (const segment of segmentsOf(command, dialect)) {
|
|
93
|
+
if (segment.unanalyzable) {
|
|
94
|
+
const text = command.replaceAll(/['"\\`]/gu, "");
|
|
95
|
+
if (!/\bworkit\b/u.test(text)) return null;
|
|
96
|
+
const found = WORKIT_BYPASS_FLAGS.find((flag) => text.includes(flag));
|
|
97
|
+
return found ? bypassReason(found) : null;
|
|
98
|
+
}
|
|
99
|
+
const start = segment.words.findIndex(isWorkitWord);
|
|
100
|
+
if (start < 0) continue;
|
|
101
|
+
const words = segment.words.slice(start + 1);
|
|
102
|
+
const end = words.indexOf("--");
|
|
103
|
+
const flag = (end < 0 ? words : words.slice(0, end)).find(isBypass);
|
|
104
|
+
if (flag) return bypassReason(flag.split("=")[0] ?? flag);
|
|
105
|
+
const limit = start + 1 + (end < 0 ? words.length : end);
|
|
106
|
+
if (segment.expands?.some((index) => index > start && index < limit)) return EXPANSION_REASON;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Is the whole shell `command` allowed Workit calls only? Every segment must
|
|
113
|
+
* be one, with no substitution, redirect, group or heredoc anywhere.
|
|
114
|
+
*/
|
|
115
|
+
export const isAllowedWorkitCommand = (command: string): boolean => {
|
|
116
|
+
const segments = segmentsOf(command);
|
|
117
|
+
return (
|
|
118
|
+
segments.length > 0 &&
|
|
119
|
+
segments.every(
|
|
120
|
+
(s) =>
|
|
121
|
+
!s.unanalyzable &&
|
|
122
|
+
!s.group &&
|
|
123
|
+
!s.subs?.length &&
|
|
124
|
+
!s.stdin?.length &&
|
|
125
|
+
!s.redirects.length &&
|
|
126
|
+
isAllowedWorkitCall(s.words, s.expands),
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
};
|
package/src/hooks/handle.ts
CHANGED
|
@@ -9,7 +9,8 @@ import type { HostDescriptor, Support } from "./descriptor";
|
|
|
9
9
|
import { branchGuidance } from "../core/branch";
|
|
10
10
|
import { workspaceTrailers } from "../core/workspaces";
|
|
11
11
|
import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
|
|
12
|
-
import { cwdRewrite } from "./cwd-normalize";
|
|
12
|
+
import { cwdRewrite, verbFirst } from "./cwd-normalize";
|
|
13
|
+
import { workitBypassFlag } from "./delivery-verbs";
|
|
13
14
|
import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
|
|
14
15
|
import { shellPolicy } from "./policy";
|
|
15
16
|
import {
|
|
@@ -121,7 +122,7 @@ const subagentStartText = (
|
|
|
121
122
|
if (role === "implementer")
|
|
122
123
|
// No host isolation: the lead makes the worktree, Workit's hooks confine
|
|
123
124
|
// the implementer to it (worktree-confine.ts), and workit git works there.
|
|
124
|
-
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit --cwd <worktree>
|
|
125
|
+
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit git --cwd <worktree> commit -m "<message>" -- <paths>\`, \`workit check --cwd <worktree> <name>\` (verb first, so host allow rules match). The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit git --cwd <worktree> branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
125
126
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
126
127
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
127
128
|
const own = subagentSession(session, event.agentId);
|
|
@@ -152,7 +153,8 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
152
153
|
if (
|
|
153
154
|
deps.descriptor.transport !== "hook-process" ||
|
|
154
155
|
!PRE_TOOL.has(input.event.kind) ||
|
|
155
|
-
decision.kind === "deny"
|
|
156
|
+
decision.kind === "deny" ||
|
|
157
|
+
decision.kind === "ask"
|
|
156
158
|
)
|
|
157
159
|
return decision;
|
|
158
160
|
const delta = standingDelta(input);
|
|
@@ -223,10 +225,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
223
225
|
const rewrites = usable(descriptor.shellPolicy.rewrite);
|
|
224
226
|
if (plan.kind === "rewrite" && !rewrites && canDeny) return heavyDenial(event.command, plan);
|
|
225
227
|
const lease = plan.kind === "rewrite" && rewrites ? plan : null;
|
|
226
|
-
// `cd <dir> && workit …` becomes `workit --cwd <dir> …` (issue
|
|
227
|
-
//
|
|
228
|
+
// `cd <dir> && workit …` becomes `workit <verb> --cwd <dir> …` (issue
|
|
229
|
+
// #388), and `workit --cwd <dir> <verb>` puts the verb first (#297), so
|
|
230
|
+
// Workit's enumerated allow rules match. A workit command is never heavy
|
|
231
|
+
// itself, so the lease plan wins when set.
|
|
228
232
|
const cwdCommand =
|
|
229
|
-
!lease && rewrites && input.host === "claude_code"
|
|
233
|
+
!lease && rewrites && input.host === "claude_code"
|
|
234
|
+
? (cwdRewrite(event.command) ?? verbFirst(event.command))
|
|
235
|
+
: null;
|
|
230
236
|
const aside = plan.kind === "passthrough" ? plan.note : null;
|
|
231
237
|
// The command will run: note HEAD so the post-tool hook (or the next
|
|
232
238
|
// command, without one) can tell whether a raw commit moved it.
|
|
@@ -235,6 +241,15 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
235
241
|
event.command,
|
|
236
242
|
postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
|
|
237
243
|
);
|
|
244
|
+
// Claude Code: a Workit bypass flag always reaches the user, whatever
|
|
245
|
+
// the allow rules match (issue #297). Words, not text, so quoting
|
|
246
|
+
// cannot hide it; a deny above still wins. A leased command runs under
|
|
247
|
+
// `workit lock run`, which no Workit rule allows.
|
|
248
|
+
const bypass =
|
|
249
|
+
!lease && input.host === "claude_code"
|
|
250
|
+
? workitBypassFlag(event.command, event.dialect)
|
|
251
|
+
: null;
|
|
252
|
+
if (bypass) return { kind: "ask", reason: bypass };
|
|
238
253
|
const rewrittenTo = lease?.command ?? cwdCommand;
|
|
239
254
|
const rewritten = (text: string | null): HookDecision =>
|
|
240
255
|
rewrittenTo
|
|
@@ -298,6 +298,17 @@ const render = (decision: HookDecision, native: string | null) => {
|
|
|
298
298
|
},
|
|
299
299
|
exitCode: 0,
|
|
300
300
|
};
|
|
301
|
+
if (decision.kind === "ask" && native === "PreToolUse")
|
|
302
|
+
return {
|
|
303
|
+
json: {
|
|
304
|
+
hookSpecificOutput: {
|
|
305
|
+
hookEventName: "PreToolUse",
|
|
306
|
+
permissionDecision: "ask",
|
|
307
|
+
permissionDecisionReason: decision.reason,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
exitCode: 0,
|
|
311
|
+
};
|
|
301
312
|
if (decision.kind === "context" && native !== null && CONTEXT_EVENTS.has(native))
|
|
302
313
|
return {
|
|
303
314
|
json: { hookSpecificOutput: { hookEventName: native, additionalContext: decision.text } },
|
package/src/hooks/index.ts
CHANGED
|
@@ -19,6 +19,12 @@ export {
|
|
|
19
19
|
type SessionHandle,
|
|
20
20
|
} from "./context";
|
|
21
21
|
export { shellPolicy } from "./policy";
|
|
22
|
+
export {
|
|
23
|
+
isAllowedWorkitCall,
|
|
24
|
+
isAllowedWorkitCommand,
|
|
25
|
+
WORKIT_ALLOWED_VERBS,
|
|
26
|
+
WORKIT_BYPASS_FLAGS,
|
|
27
|
+
} from "./delivery-verbs";
|
|
22
28
|
export { heavyDenial, heavyGate, splitDenial } from "./heavy-gate";
|
|
23
29
|
export { shellGate, shellWrites, writeGate } from "./write-gate";
|
|
24
30
|
export { liveOwner } from "../core/live-session";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -97,6 +97,8 @@ export type HookDecision =
|
|
|
97
97
|
| { kind: "context"; text: string }
|
|
98
98
|
/** The reason text always carries its own correction (principle 3). */
|
|
99
99
|
| { kind: "deny"; reason: string; unblock: string | null }
|
|
100
|
+
/** shell.pre: let the user decide, whatever the host's rules allow. Never an allow. */
|
|
101
|
+
| { kind: "ask"; reason: string }
|
|
100
102
|
/**
|
|
101
103
|
* shell.pre: run `command` instead (the heavy-command lease rewrite).
|
|
102
104
|
* `input` is the host's tool input with the command replaced, where the
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -602,8 +602,15 @@ function walk(
|
|
|
602
602
|
// (The heavy-command reader, `seen`, stops at workit: a wrapped command
|
|
603
603
|
// already runs under the lease.)
|
|
604
604
|
const wrapped = seen ? null : workitCheckTail(words);
|
|
605
|
-
if (wrapped)
|
|
606
|
-
|
|
605
|
+
if (wrapped) {
|
|
606
|
+
// `--cwd <dir>` is where the wrapped command runs, not the shell's directory.
|
|
607
|
+
const outer = dir;
|
|
608
|
+
if (wrapped.cwd !== null) dir = joinDir(dir, wrapped.cwd);
|
|
609
|
+
if ("script" in wrapped) nested(wrapped.script, "posix");
|
|
610
|
+
else runs([wrapped.argv]);
|
|
611
|
+
dir = outer;
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
607
614
|
if (CD.has(name)) {
|
|
608
615
|
const target = cdTarget(words);
|
|
609
616
|
dir = target === null ? os.homedir() : joinDir(dir, target);
|
|
@@ -699,22 +706,25 @@ const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
|
|
|
699
706
|
/**
|
|
700
707
|
* What a `workit check [flags] -- <cmd…>` (or `workit lock run -- <cmd…>`,
|
|
701
708
|
* also through `npx @brainervirus/workit-cli`) runs: the script of `--shell`,
|
|
702
|
-
* else the argv. Null for any
|
|
709
|
+
* else the argv, and the `--cwd` it runs in (null: the shell's). Null for any
|
|
710
|
+
* other command.
|
|
703
711
|
*/
|
|
704
712
|
export function workitCheckTail(
|
|
705
713
|
words: readonly string[],
|
|
706
|
-
): { script: string } | { argv: string[] } | null {
|
|
714
|
+
): (({ script: string } | { argv: string[] }) & { cwd: string | null }) | null {
|
|
707
715
|
let at = 0;
|
|
708
716
|
if (PACKAGE_RUNNERS.has(commandName(words[0]))) {
|
|
709
717
|
at = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
|
|
710
718
|
if (at < 0 || !WORKIT_PACKAGE.test(words[at])) return null;
|
|
711
719
|
} else if (!/^workit(?:\.exe|\.cmd)?$/.test(commandName(words[0]))) return null;
|
|
712
|
-
const args = words.slice(at + 1);
|
|
720
|
+
const { rest: args, cwd } = skipWorkitGlobals(words.slice(at + 1));
|
|
713
721
|
if (args[0] !== "check" && !(args[0] === "lock" && args[1] === "run")) return null;
|
|
714
722
|
const split = args.indexOf("--");
|
|
715
723
|
const tail = split < 0 ? [] : args.slice(split + 1);
|
|
716
724
|
if (!tail.length) return null;
|
|
717
|
-
return args.slice(0, split).includes("--shell")
|
|
725
|
+
return args.slice(0, split).includes("--shell")
|
|
726
|
+
? { script: tail.join(" "), cwd }
|
|
727
|
+
: { argv: tail, cwd };
|
|
718
728
|
}
|
|
719
729
|
|
|
720
730
|
/**
|
|
@@ -791,7 +801,12 @@ export function workitInvocations(
|
|
|
791
801
|
/** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
|
|
792
802
|
const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
|
|
793
803
|
|
|
794
|
-
/**
|
|
804
|
+
/**
|
|
805
|
+
* An invocation's arguments without the global options before the verb and
|
|
806
|
+
* without any `--cwd` before `--`, and the last `--cwd`. Like the CLI's
|
|
807
|
+
* parseGlobals, `--cwd` counts anywhere before `--`, so `workit <verb> --cwd
|
|
808
|
+
* <dir> …` (the form host allow rules match) runs in `<dir>` here too.
|
|
809
|
+
*/
|
|
795
810
|
export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cwd: string | null } {
|
|
796
811
|
let cwd: string | null = null;
|
|
797
812
|
let at = 0;
|
|
@@ -804,7 +819,18 @@ export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cw
|
|
|
804
819
|
at++;
|
|
805
820
|
} else if (args[at] !== "--json") break;
|
|
806
821
|
}
|
|
807
|
-
|
|
822
|
+
const rest: string[] = [];
|
|
823
|
+
for (; at < args.length; at++) {
|
|
824
|
+
const arg = args[at];
|
|
825
|
+
if (arg === "--") {
|
|
826
|
+
rest.push(...args.slice(at));
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
if (arg.startsWith("--cwd=")) cwd = arg.slice("--cwd=".length);
|
|
830
|
+
else if (arg === "--cwd" && args[at + 1] !== undefined) cwd = args[++at];
|
|
831
|
+
else rest.push(arg);
|
|
832
|
+
}
|
|
833
|
+
return { rest, cwd };
|
|
808
834
|
}
|
|
809
835
|
|
|
810
836
|
/** What one raw invocation means for workit. */
|
package/src/hooks/shell-words.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type Segment = {
|
|
|
18
18
|
pipe?: true;
|
|
19
19
|
/** Text it reads on standard input: heredoc bodies and here-strings. */
|
|
20
20
|
stdin?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Indexes into `words` of the words the shell expands before the command
|
|
23
|
+
* runs: an unquoted `$`, `*`, `?`, `[` or backtick, or a `$(…)`, `<(…)` or
|
|
24
|
+
* double-quoted `$`/backtick. The text of such a word is not what runs.
|
|
25
|
+
*/
|
|
26
|
+
expands?: number[];
|
|
21
27
|
/** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
|
|
22
28
|
subs?: string[];
|
|
23
29
|
/** The command could not be analyzed (too deeply nested): why. The only segment then. */
|
|
@@ -291,6 +297,20 @@ const substitutions = (body: string): string[] => {
|
|
|
291
297
|
const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
|
|
292
298
|
const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
|
|
293
299
|
|
|
300
|
+
/** Does a double-quoted body hold an expansion (`$x`, `$(…)`, a backtick)? */
|
|
301
|
+
const expandsInQuotes = (body: string, dialect: ShellDialect): boolean => {
|
|
302
|
+
const escape = dialect === "posix" ? "\\" : "`";
|
|
303
|
+
for (let index = 0; index < body.length; index++) {
|
|
304
|
+
const char = body[index];
|
|
305
|
+
if (char === escape) index++;
|
|
306
|
+
else if (char === "$" || (dialect === "posix" && char === "`")) return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** An unquoted glob or variable character in a plain run. */
|
|
312
|
+
const EXPANDING_RUN = /[*?[]/u;
|
|
313
|
+
|
|
294
314
|
/** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
|
|
295
315
|
const arithmeticEnd = (command: string, start: number): number => {
|
|
296
316
|
const end = substitutionEnd(command, start);
|
|
@@ -316,6 +336,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
316
336
|
let current: Segment = { words: [], redirects: [] };
|
|
317
337
|
let word = "";
|
|
318
338
|
let inWord = false;
|
|
339
|
+
let expanding = false;
|
|
319
340
|
let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
|
|
320
341
|
const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
|
|
321
342
|
const escape = posix ? "\\" : "`";
|
|
@@ -327,11 +348,16 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
327
348
|
} else if (pending === "heredoc")
|
|
328
349
|
heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
|
|
329
350
|
else if (pending === "herestring") (current.stdin ??= []).push(text);
|
|
330
|
-
else if (pending !== "input")
|
|
351
|
+
else if (pending !== "input") {
|
|
352
|
+
const at = current.words.length;
|
|
331
353
|
current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
|
|
354
|
+
if (expanding || (!posix && word.startsWith("@")))
|
|
355
|
+
(current.expands ??= []).push(...current.words.slice(at).map((_, i) => at + i));
|
|
356
|
+
}
|
|
332
357
|
pending = null;
|
|
333
358
|
word = "";
|
|
334
359
|
inWord = false;
|
|
360
|
+
expanding = false;
|
|
335
361
|
};
|
|
336
362
|
const flushSegment = () => {
|
|
337
363
|
flushWord();
|
|
@@ -347,6 +373,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
347
373
|
/** A substitution read apart: its command runs, its text stays in the word. */
|
|
348
374
|
const substitution = (inner: string, text: string) => {
|
|
349
375
|
(current.subs ??= []).push(inner);
|
|
376
|
+
expanding = true;
|
|
350
377
|
word += text;
|
|
351
378
|
inWord = true;
|
|
352
379
|
};
|
|
@@ -358,6 +385,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
358
385
|
if (run) {
|
|
359
386
|
word += run[0];
|
|
360
387
|
inWord = true;
|
|
388
|
+
if (EXPANDING_RUN.test(run[0])) expanding = true;
|
|
361
389
|
index += run[0].length - 1;
|
|
362
390
|
continue;
|
|
363
391
|
}
|
|
@@ -383,6 +411,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
383
411
|
const body = command.slice(index + 1, end < 0 ? undefined : end);
|
|
384
412
|
if (posix && char === '"' && /\$\(|`/.test(body))
|
|
385
413
|
(current.subs ??= []).push(...substitutions(body));
|
|
414
|
+
if (char === '"' && expandsInQuotes(body, dialect)) expanding = true;
|
|
386
415
|
word += unquote(body, char, dialect);
|
|
387
416
|
inWord = true;
|
|
388
417
|
index = end < 0 ? command.length : end;
|
|
@@ -428,6 +457,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
428
457
|
if (char === "(" && inWord && word.endsWith("$")) {
|
|
429
458
|
word = word.slice(0, -1);
|
|
430
459
|
inWord = word !== "";
|
|
460
|
+
if (!inWord) expanding = false;
|
|
431
461
|
}
|
|
432
462
|
group(char === "(" ? "open" : "close");
|
|
433
463
|
} else if (!posix && (char === "{" || char === "}")) {
|
|
@@ -474,6 +504,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
|
|
|
474
504
|
} else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
|
|
475
505
|
else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
|
|
476
506
|
else {
|
|
507
|
+
if (char === "$") expanding = true;
|
|
477
508
|
word += char;
|
|
478
509
|
inWord = true;
|
|
479
510
|
}
|
|
@@ -182,7 +182,7 @@ const shown = (target: string): string =>
|
|
|
182
182
|
|
|
183
183
|
const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
184
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>
|
|
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 git --cwd <worktree> commit -m … -- <paths>`, `workit check --cwd <worktree> <name>`). If your brief names no worktree, stop and report that to the lead.";
|
|
186
186
|
return {
|
|
187
187
|
kind: "deny",
|
|
188
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}`,
|
|
@@ -191,7 +191,7 @@ const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
|
191
191
|
};
|
|
192
192
|
|
|
193
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}
|
|
194
|
+
const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit git --cwd ${worktree} commit -m … -- <paths>\`, \`workit check --cwd ${worktree} <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
195
|
return {
|
|
196
196
|
kind: "deny",
|
|
197
197
|
reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
|
|
@@ -201,7 +201,7 @@ const outsideDeny = (input: HookInput, worktree: string, target: string): HookDe
|
|
|
201
201
|
|
|
202
202
|
const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
|
|
203
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}\`.`;
|
|
204
|
+
const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit <verb> --cwd ${wt}\`.`;
|
|
205
205
|
return {
|
|
206
206
|
kind: "deny",
|
|
207
207
|
reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -509,7 +509,7 @@ const segmentTargets = (
|
|
|
509
509
|
}
|
|
510
510
|
const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
|
|
511
511
|
if (inner !== null && dir !== undefined)
|
|
512
|
-
targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
|
|
512
|
+
targets.push(...segmentTargets(workitRunDir(dir, words), inner, "posix", depth + 1, strict));
|
|
513
513
|
if (strict && dir !== undefined) {
|
|
514
514
|
const runIn = dir;
|
|
515
515
|
for (const sub of depth < 4 ? (segment.subs ?? []) : [])
|
|
@@ -550,6 +550,15 @@ const segmentTargets = (
|
|
|
550
550
|
return targets;
|
|
551
551
|
};
|
|
552
552
|
|
|
553
|
+
/** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
|
|
554
|
+
const workitRunDir = (dir: string, words: string[]): string => {
|
|
555
|
+
for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
|
|
556
|
+
const { at, args } = workitCwd(dir, invocation);
|
|
557
|
+
if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
|
|
558
|
+
}
|
|
559
|
+
return dir;
|
|
560
|
+
};
|
|
561
|
+
|
|
553
562
|
/** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
|
|
554
563
|
const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
|
|
555
564
|
const { rest, cwd } = skipWorkitGlobals(args);
|