@brainervirus/workit-core 8.12.2 → 8.12.3
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/src/core/route-intent.ts +185 -45
- package/src/hooks/hosts/cursor.ts +3 -1
- package/src/hooks/policy.ts +11 -2
- package/src/hooks/raw-git.ts +1146 -142
- package/src/hooks/shell-words.ts +383 -28
package/package.json
CHANGED
package/src/core/route-intent.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
expandGitAliases,
|
|
6
|
+
inWorkitWorkspace,
|
|
7
|
+
pushAction,
|
|
8
|
+
rawInvocations,
|
|
9
|
+
type RawInvocation,
|
|
10
|
+
} from "../hooks/raw-git";
|
|
5
11
|
import type { ShellDialect } from "../hooks/shell-words";
|
|
6
12
|
import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
|
|
7
13
|
|
|
@@ -11,6 +17,7 @@ const UNKNOWABLE = /[$`*?[\]{}~]/u;
|
|
|
11
17
|
const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
|
|
12
18
|
const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
|
|
13
19
|
const WORKTREE_CREATE = new Set(["-b", "-B"]);
|
|
20
|
+
const SUBTREE_CREATE = new Set(["-b", "--branch"]);
|
|
14
21
|
/** `git branch` options that list, delete or configure: no branch is created. */
|
|
15
22
|
const BRANCH_NO_CREATE = new Set(
|
|
16
23
|
["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
|
|
@@ -21,16 +28,37 @@ const BRANCH_NO_CREATE = new Set(
|
|
|
21
28
|
/** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
|
|
22
29
|
const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
|
|
23
30
|
|
|
24
|
-
/**
|
|
31
|
+
/** `--name` matches a long option in `flags`, whole or by git's unambiguous-prefix rule (`--cre`). */
|
|
32
|
+
const longOption = (name: string, flags: ReadonlySet<string>): boolean =>
|
|
33
|
+
flags.has(name) ||
|
|
34
|
+
(name.length >= 3 && [...flags].some((flag) => flag.startsWith("--") && flag.startsWith(name)));
|
|
35
|
+
|
|
36
|
+
/** The short options of `flags` (`-c` → `c`). */
|
|
37
|
+
const shortsOf = (flags: ReadonlySet<string>): string =>
|
|
38
|
+
[...flags]
|
|
39
|
+
.filter((flag) => /^-[^-]$/u.test(flag))
|
|
40
|
+
.join("")
|
|
41
|
+
.replaceAll("-", "");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The value of `flag` in `args`: `-c x`, `-cx`, a short cluster (`-fc x`),
|
|
45
|
+
* `--create=x`, `--create x` or an abbreviation (`--cre x`). Null when absent.
|
|
46
|
+
*/
|
|
25
47
|
const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
|
|
48
|
+
const shorts = shortsOf(flags);
|
|
26
49
|
for (let index = 1; index < args.length; index++) {
|
|
27
50
|
const arg = args[index];
|
|
28
51
|
if (arg === "--") return null;
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
if (arg.startsWith("--")) {
|
|
53
|
+
const eq = arg.indexOf("=");
|
|
54
|
+
if (!longOption(eq > 0 ? arg.slice(0, eq) : arg, flags)) continue;
|
|
55
|
+
return eq > 0 ? arg.slice(eq + 1) : (args[index + 1] ?? null);
|
|
56
|
+
}
|
|
57
|
+
if (!arg.startsWith("-") || arg.length < 2) continue;
|
|
58
|
+
// A cluster of short switches; the creating one takes the rest or the next word.
|
|
59
|
+
for (let at = 1; at < arg.length; at++)
|
|
60
|
+
if (shorts.includes(arg[at]))
|
|
61
|
+
return at + 1 < arg.length ? arg.slice(at + 1) : (args[index + 1] ?? null);
|
|
34
62
|
}
|
|
35
63
|
return null;
|
|
36
64
|
};
|
|
@@ -68,34 +96,134 @@ const branchCommandTarget = (args: readonly string[]): string | null => {
|
|
|
68
96
|
positionals.push(...args.slice(index + 1));
|
|
69
97
|
break;
|
|
70
98
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
if (arg.startsWith("--")) {
|
|
100
|
+
const flag = arg.split("=", 1)[0];
|
|
101
|
+
if (longOption(flag, BRANCH_NO_CREATE)) return null;
|
|
102
|
+
if (longOption(flag, BRANCH_RENAME)) rename = true;
|
|
103
|
+
} else if (arg.startsWith("-") && arg.length > 1) {
|
|
104
|
+
// A short cluster: `-fm`, `-vv`, `-dr`.
|
|
105
|
+
const shorts = arg.slice(1).split("");
|
|
106
|
+
if (shorts.some((short) => BRANCH_NO_CREATE.has(`-${short}`))) return null;
|
|
107
|
+
if (shorts.some((short) => BRANCH_RENAME.has(`-${short}`))) rename = true;
|
|
108
|
+
} else positionals.push(arg);
|
|
75
109
|
}
|
|
76
110
|
if (positionals.length < 1 || positionals.length > 2) return null;
|
|
77
111
|
return rename ? (positionals.at(-1) ?? null) : positionals[0];
|
|
78
112
|
};
|
|
79
113
|
|
|
114
|
+
/** Positional words after `skip` leading words, past options; `values` take the next word. */
|
|
115
|
+
const positionalsOf = (
|
|
116
|
+
args: readonly string[],
|
|
117
|
+
skip: number,
|
|
118
|
+
values: ReadonlySet<string> = new Set(),
|
|
119
|
+
): string[] => {
|
|
120
|
+
const found: string[] = [];
|
|
121
|
+
for (let index = skip; index < args.length; index++) {
|
|
122
|
+
const arg = args[index];
|
|
123
|
+
if (arg === "--") return [...found, ...args.slice(index + 1)];
|
|
124
|
+
if (values.has(arg)) index++;
|
|
125
|
+
else if (!arg.startsWith("-") || arg === "-") found.push(arg);
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const localHead = (ref: string): string | null =>
|
|
131
|
+
ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : null;
|
|
132
|
+
|
|
133
|
+
/** `git update-ref refs/heads/<b> <sha>` and `--stdin` `create|update refs/heads/<b>` lines. */
|
|
134
|
+
const updateRefTargets = (args: readonly string[], stdin: string | undefined): string[] => {
|
|
135
|
+
if (args.some((arg) => arg === "-d" || arg === "--delete")) return [];
|
|
136
|
+
if (args.includes("--stdin"))
|
|
137
|
+
return (stdin ?? "")
|
|
138
|
+
.split(/[\n\0]/u)
|
|
139
|
+
.map((line) => /^\s*(?:create|update)\s+(\S+)/u.exec(line)?.[1] ?? "")
|
|
140
|
+
.map(localHead)
|
|
141
|
+
.filter((name): name is string => name !== null);
|
|
142
|
+
const [ref] = positionalsOf(args, 1, new Set(["-m"]));
|
|
143
|
+
const name = ref ? localHead(ref) : null;
|
|
144
|
+
return name ? [name] : [];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** `git symbolic-ref HEAD refs/heads/<b>`: the next commit creates `<b>`. */
|
|
148
|
+
const symbolicRefTarget = (args: readonly string[]): string | null => {
|
|
149
|
+
if (args.some((arg) => arg === "-d" || arg === "--delete")) return null;
|
|
150
|
+
const [, ref] = positionalsOf(args, 1, new Set(["-m"]));
|
|
151
|
+
return ref ? localHead(ref) : null;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** git fetch/pull options that take a value. */
|
|
155
|
+
const FETCH_VALUES = new Set(
|
|
156
|
+
["--depth", "--deepen", "--shallow-since", "--shallow-exclude", "--refmap", "-j", "--jobs"]
|
|
157
|
+
.concat(["--upload-pack", "-o", "--server-option", "--negotiation-tip", "--filter"])
|
|
158
|
+
.concat(["--recurse-submodules-default", "--submodule-prefix", "-s", "--strategy", "-X"])
|
|
159
|
+
.concat(["--strategy-option"]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `git fetch|pull <remote> <src>:<dst>` writes the local branch `<dst>`
|
|
164
|
+
* (`refs/heads/<dst>` or a bare name); `--stdin` reads the refspecs. A
|
|
165
|
+
* glob destination mirrors names the remote already has.
|
|
166
|
+
*/
|
|
167
|
+
const fetchTargets = (args: readonly string[], stdin: string | undefined): string[] => {
|
|
168
|
+
if (args.includes("--all") || args.includes("--multiple")) return [];
|
|
169
|
+
const specs = args.includes("--stdin")
|
|
170
|
+
? (stdin ?? "").split(/\s+/u).filter(Boolean)
|
|
171
|
+
: positionalsOf(args, 1, FETCH_VALUES).slice(1);
|
|
172
|
+
const names: string[] = [];
|
|
173
|
+
for (const raw of specs) {
|
|
174
|
+
const colon = raw.indexOf(":");
|
|
175
|
+
const destination = colon >= 0 ? raw.slice(colon + 1) : "";
|
|
176
|
+
if (!destination || destination.includes("*")) continue;
|
|
177
|
+
const name = destination.startsWith("refs/") ? localHead(destination) : destination;
|
|
178
|
+
if (name) names.push(name);
|
|
179
|
+
}
|
|
180
|
+
return names;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** `git push . <src>:<dst>` writes the local branch `<dst>`. */
|
|
184
|
+
const localPushTargets = (args: string[]): string[] => {
|
|
185
|
+
const action = pushAction(args);
|
|
186
|
+
if (action?.kind !== "push" || action.remote !== "." || action.deletes) return [];
|
|
187
|
+
return action.targets.filter((name): name is string => name !== null);
|
|
188
|
+
};
|
|
189
|
+
|
|
80
190
|
/**
|
|
81
|
-
* The
|
|
82
|
-
* `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]
|
|
83
|
-
* <b>`,
|
|
84
|
-
*
|
|
191
|
+
* The branches one git invocation creates: `switch -c|-C|--create <b>`,
|
|
192
|
+
* `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]`, `worktree add -b|-B
|
|
193
|
+
* <b>`, `stash branch <b>`, `update-ref refs/heads/<b>`, `symbolic-ref HEAD
|
|
194
|
+
* refs/heads/<b>`, `fetch|pull <remote> <src>:<b>`, `push . <src>:<b>` and
|
|
195
|
+
* `subtree split -b <b>`, wherever the option stands. `args` start at the
|
|
196
|
+
* subcommand (after git's global options). Names a shell settles only when
|
|
197
|
+
* it runs (`$X`) are left out.
|
|
85
198
|
*/
|
|
86
|
-
export const
|
|
199
|
+
export const gitBranchCreations = (args: string[], stdin?: string): string[] => {
|
|
87
200
|
const [sub, second] = args;
|
|
88
|
-
const
|
|
201
|
+
const targets: Array<string | null> =
|
|
89
202
|
sub === "switch"
|
|
90
|
-
? flagValue(args, SWITCH_CREATE)
|
|
203
|
+
? [flagValue(args, SWITCH_CREATE)]
|
|
91
204
|
: sub === "checkout"
|
|
92
|
-
? flagValue(args, CHECKOUT_CREATE)
|
|
205
|
+
? [flagValue(args, CHECKOUT_CREATE)]
|
|
93
206
|
: sub === "branch"
|
|
94
|
-
? branchCommandTarget(args)
|
|
207
|
+
? [branchCommandTarget(args)]
|
|
95
208
|
: sub === "worktree" && second === "add"
|
|
96
|
-
? worktreeTarget(args.slice(1))
|
|
97
|
-
:
|
|
98
|
-
|
|
209
|
+
? [worktreeTarget(args.slice(1))]
|
|
210
|
+
: sub === "stash" && second === "branch"
|
|
211
|
+
? positionalsOf(args, 2).slice(0, 1)
|
|
212
|
+
: sub === "update-ref"
|
|
213
|
+
? updateRefTargets(args, stdin)
|
|
214
|
+
: sub === "symbolic-ref"
|
|
215
|
+
? [symbolicRefTarget(args)]
|
|
216
|
+
: sub === "fetch" || sub === "pull"
|
|
217
|
+
? fetchTargets(args, stdin)
|
|
218
|
+
: sub === "push"
|
|
219
|
+
? localPushTargets(args)
|
|
220
|
+
: sub === "subtree" && second === "split"
|
|
221
|
+
? [flagValue(args.slice(1), SUBTREE_CREATE)]
|
|
222
|
+
: [];
|
|
223
|
+
return targets.filter(
|
|
224
|
+
(target): target is string =>
|
|
225
|
+
target !== null && target !== "" && !target.startsWith("-") && !UNKNOWABLE.test(target),
|
|
226
|
+
);
|
|
99
227
|
};
|
|
100
228
|
|
|
101
229
|
export type ShellBranchTarget = {
|
|
@@ -104,24 +232,26 @@ export type ShellBranchTarget = {
|
|
|
104
232
|
dir: string | null | undefined;
|
|
105
233
|
};
|
|
106
234
|
|
|
235
|
+
const targetsOf = (invocations: readonly RawInvocation[]): ShellBranchTarget[] =>
|
|
236
|
+
invocations.flatMap((invocation) =>
|
|
237
|
+
invocation.tool === "git"
|
|
238
|
+
? gitBranchCreations(invocation.args, invocation.stdin).map((branch) => ({
|
|
239
|
+
branch,
|
|
240
|
+
dir: invocation.dir,
|
|
241
|
+
}))
|
|
242
|
+
: [],
|
|
243
|
+
);
|
|
244
|
+
|
|
107
245
|
/**
|
|
108
246
|
* Every branch a shell command creates, through the raw-git tokenizer: `&&`,
|
|
109
247
|
* `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
|
|
110
|
-
* quoted names and
|
|
248
|
+
* quoted names, compound commands and nested scripts (git aliases need the
|
|
249
|
+
* repository: see shellBranchPolicyViolation).
|
|
111
250
|
*/
|
|
112
251
|
export const shellBranchTargets = (
|
|
113
252
|
command: string,
|
|
114
253
|
dialect: ShellDialect = "posix",
|
|
115
|
-
): ShellBranchTarget[] =>
|
|
116
|
-
if (!/\bgit\b/u.test(command)) return [];
|
|
117
|
-
const found: ShellBranchTarget[] = [];
|
|
118
|
-
for (const invocation of rawInvocations(command, dialect)) {
|
|
119
|
-
if (invocation.tool !== "git") continue;
|
|
120
|
-
const branch = gitBranchCreation(invocation.args);
|
|
121
|
-
if (branch) found.push({ branch, dir: invocation.dir });
|
|
122
|
-
}
|
|
123
|
-
return found;
|
|
124
|
-
};
|
|
254
|
+
): ShellBranchTarget[] => targetsOf(rawInvocations(command, dialect));
|
|
125
255
|
|
|
126
256
|
/** The first branch a shell command creates, or null. */
|
|
127
257
|
export const shellBranchTarget = (command: string): string | null =>
|
|
@@ -135,6 +265,14 @@ const localBranchExists = (dir: string, branch: string): boolean =>
|
|
|
135
265
|
timeout: 5_000,
|
|
136
266
|
}).status === 0;
|
|
137
267
|
|
|
268
|
+
/** The directory a command judged from `root` runs git in, or null when not this policy's business. */
|
|
269
|
+
const judgedDir = (root: string, dir: string | null | undefined): string | null => {
|
|
270
|
+
if (dir === undefined) return null;
|
|
271
|
+
if (dir === null) return root;
|
|
272
|
+
const resolved = path.resolve(root, dir);
|
|
273
|
+
return existsSync(resolved) && inWorkitWorkspace(resolved) ? resolved : null;
|
|
274
|
+
};
|
|
275
|
+
|
|
138
276
|
/**
|
|
139
277
|
* The branch policy verdict for every new branch a shell command creates,
|
|
140
278
|
* each checked in the directory git runs in (a linked worktree resolves to
|
|
@@ -142,9 +280,9 @@ const localBranchExists = (dir: string, branch: string): boolean =>
|
|
|
142
280
|
* judged there. One that changes directory is judged only when that
|
|
143
281
|
* directory is known, already exists and is in a Workit workspace: a scratch
|
|
144
282
|
* repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
|
|
145
|
-
* not the cwd workspace's business. A
|
|
146
|
-
* skipped (`git branch -f legacy HEAD`).
|
|
147
|
-
* the first violation otherwise.
|
|
283
|
+
* not the cwd workspace's business. A git alias is resolved there first. A
|
|
284
|
+
* name that already exists locally is skipped (`git branch -f legacy HEAD`).
|
|
285
|
+
* Null when no new branch is judged; the first violation otherwise.
|
|
148
286
|
*/
|
|
149
287
|
export const shellBranchPolicyViolation = (
|
|
150
288
|
root: string,
|
|
@@ -152,14 +290,16 @@ export const shellBranchPolicyViolation = (
|
|
|
152
290
|
dialect: ShellDialect = "posix",
|
|
153
291
|
): BranchNamePolicyCheck | null => {
|
|
154
292
|
let judged = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
293
|
+
const cache = new Map();
|
|
294
|
+
for (const raw of rawInvocations(command, dialect)) {
|
|
295
|
+
if (raw.tool !== "git" || judgedDir(root, raw.dir) === null) continue;
|
|
296
|
+
for (const target of targetsOf(expandGitAliases(root, raw, cache))) {
|
|
297
|
+
const dir = judgedDir(root, target.dir);
|
|
298
|
+
if (dir === null || localBranchExists(dir, target.branch)) continue;
|
|
299
|
+
judged = true;
|
|
300
|
+
const check = validateBranchNameFor(dir, target.branch);
|
|
301
|
+
if (!check.ok) return check;
|
|
302
|
+
}
|
|
163
303
|
}
|
|
164
304
|
return judged ? { ok: true } : null;
|
|
165
305
|
};
|
|
@@ -63,7 +63,9 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
63
63
|
// `loop_limit: 1`, so the continuation's own stop never runs the hook.
|
|
64
64
|
stop: { support: "native", native: "stop" },
|
|
65
65
|
},
|
|
66
|
-
|
|
66
|
+
// Fails open like every host: a payload Workit cannot parse (a renamed
|
|
67
|
+
// field in a Cursor release) must not block every shell command.
|
|
68
|
+
shellPolicy: { deny: "native", channel: "exit2+json", failClosed: false },
|
|
67
69
|
context: {
|
|
68
70
|
sessionStart: "native",
|
|
69
71
|
perTurn: "none",
|
package/src/hooks/policy.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
import { shellBranchPolicyViolation } from "../core/route-intent";
|
|
2
2
|
import type { HookDecision } from "./protocol";
|
|
3
|
-
import
|
|
3
|
+
import { analysisDenial } from "./raw-git";
|
|
4
|
+
import { ShellAnalysisError, type ShellDialect } from "./shell-words";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Branch policy for one shell command: a raw branch creation (`git switch -c`,
|
|
7
8
|
* `checkout -b`, `branch`, `worktree add -b`, in any chain, directory or
|
|
8
9
|
* wrapper) onto a noncompliant name is denied. Every other command keeps the
|
|
9
10
|
* host's own permission decision. The reason carries the policy's correction.
|
|
11
|
+
* A command the hook cannot analyze is denied with how to split it; any other
|
|
12
|
+
* failure propagates to the host's fail policy.
|
|
10
13
|
*/
|
|
11
14
|
export const shellPolicy = (
|
|
12
15
|
cwd: string,
|
|
13
16
|
command: string,
|
|
14
17
|
dialect: ShellDialect = "posix",
|
|
15
18
|
): HookDecision => {
|
|
16
|
-
|
|
19
|
+
let policy: ReturnType<typeof shellBranchPolicyViolation>;
|
|
20
|
+
try {
|
|
21
|
+
policy = shellBranchPolicyViolation(cwd, command, dialect);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof ShellAnalysisError) return analysisDenial(error);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
17
26
|
return policy && !policy.ok
|
|
18
27
|
? { kind: "deny", reason: `branch_policy_denied: ${policy.error}`, unblock: null }
|
|
19
28
|
: { kind: "none" };
|