@brainervirus/workit-core 8.15.1 → 8.17.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 +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/task-contract.ts +21 -0
- package/src/core/task-engine.ts +14 -2
- package/src/core/task-evaluation.ts +71 -0
- package/src/core/workspaces.ts +22 -0
- package/src/fanout-brief.ts +2 -0
- package/src/fanout.ts +26 -2
- 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/write-gate.ts +42 -2
package/package.json
CHANGED
|
@@ -5,13 +5,13 @@ description: Run independent slices in parallel - one worker per isolated worktr
|
|
|
5
5
|
|
|
6
6
|
# Fan out parallel workers
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Inline-first (workspace `delegation`): fan out only past a threshold, named as
|
|
9
|
+
each slice's `reason` (explore, parallel, backstop, high-risk-verify): an
|
|
10
|
+
explorer past ~3 tool calls or 10k tokens; parallel writers only for 2+ disjoint
|
|
11
|
+
units of ~100+ lines each. Coupled work stays with one owner. Workit never spawns.
|
|
12
12
|
|
|
13
13
|
1. **Plan the slices** (workit-shape) in a plan file (`references/brief.md`):
|
|
14
|
-
per slice an id, branch, TIER, SCOPE globs, `owns` for shared files,
|
|
14
|
+
per slice an id, branch, REASON, TIER, SCOPE globs, `owns` for shared files,
|
|
15
15
|
`dependsOn` only for a real dependency, and the brief fields. `workit fanout
|
|
16
16
|
plan <plan.json>` refuses an empty or placeholder brief field (exit 2) and
|
|
17
17
|
two slices that may write one file (exit 3) with a fix; apply it and re-run:
|
|
@@ -21,8 +21,8 @@ ceremony; do it yourself. Workit never spawns agents; you do.
|
|
|
21
21
|
2. **Record standing orders** (`workit ledger standing add "<order>"`): every
|
|
22
22
|
user directive all workers share. Directives decay across resumes.
|
|
23
23
|
3. **Brief each worker verbatim** with `workit fanout brief <slice>`: plan
|
|
24
|
-
fields, standing orders, SCRATCH, session id, fan-in
|
|
25
|
-
it; change the plan or the standing orders and render again.
|
|
24
|
+
fields, standing orders, SCRATCH, session id, fan-in and heavy-lease rules.
|
|
25
|
+
Never hand-edit it; change the plan or the standing orders and render again.
|
|
26
26
|
4. **Spawn in a rolling window**, in the background, each in its own worktree:
|
|
27
27
|
keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
|
|
28
28
|
each one finishes. Model: the brief's `model:` line, else TIER
|
|
@@ -59,7 +59,8 @@ defaults to the trunk, or to the branch of a single `dependsOn` slice (a
|
|
|
59
59
|
stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
|
|
60
60
|
file another slice's glob also matches. Globs that match no file yet are compared
|
|
61
61
|
through a sample path. Use `/` as the separator; a backslash only escapes
|
|
62
|
-
literal brackets: `"app/\\[id\\]/page.tsx"`. A `
|
|
62
|
+
literal brackets: `"app/\\[id\\]/page.tsx"`. A `reason` (`explore`, `parallel`, `backstop` or `high-risk-verify`) is rendered as the brief's `REASON:` line and is
|
|
63
|
+
required unless the workspace sets `delegation` to `lead-orchestrates`. A `timebox` such as `45 minutes`
|
|
63
64
|
is the slice's STUCK threshold in `workit fanout status`. `"fanIn":
|
|
64
65
|
"integration"` (with an integration branch as `trunk`) is the one-PR mode in
|
|
65
66
|
`orchestration.md`; the default `"prs"` lands one PR per slice.
|
|
@@ -79,6 +80,7 @@ is the slice's STUCK threshold in `workit fanout status`. `"fanIn":
|
|
|
79
80
|
"acceptance": ["Given no auth header, When GET /v1/usage, Then the status is 401"],
|
|
80
81
|
"verify": ["workit check test"],
|
|
81
82
|
"forbidden": ["no edits outside SCOPE", "no rebase or force-push"],
|
|
83
|
+
"reason": "parallel",
|
|
82
84
|
"context": "docs/usage/spec.md Behavior",
|
|
83
85
|
"timebox": "45 minutes"
|
|
84
86
|
},
|
|
@@ -19,7 +19,9 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
19
19
|
Mechanical change: the existing checks are enough. No tautological tests:
|
|
20
20
|
a test must fail if the behavior breaks, never restate the implementation.
|
|
21
21
|
4. Run the real checks: `workit check test` (and `lint`, `typecheck` when the
|
|
22
|
-
repo has them).
|
|
22
|
+
repo has them). Heavy work runs under one machine-wide lease: an ad-hoc
|
|
23
|
+
suite is `workit check --heavy -- <cmd>`, never a hand-made flock; a busy
|
|
24
|
+
lease waits and names its holder (`workit lock status`).
|
|
23
25
|
5. Prove the feature on its real surface with the project's `verify-<app>`
|
|
24
26
|
skill; none yet? Write one (workit-verify-app). Tests show branch behavior,
|
|
25
27
|
not that the feature works.
|
|
@@ -41,6 +43,11 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
41
43
|
|
|
42
44
|
Independent slices that could run in parallel: fan them out (workit-fanout).
|
|
43
45
|
|
|
46
|
+
Stay inline by default (workspace `delegation: inline-first`). Hand work off
|
|
47
|
+
only past the thresholds: an explorer after about 3 tool calls or 10k tokens
|
|
48
|
+
of searching, parallel writers for 2 or more disjoint units of about 100+
|
|
49
|
+
lines each.
|
|
50
|
+
|
|
44
51
|
## Example
|
|
45
52
|
|
|
46
53
|
Bad: "Done - added the --since flag, tests pass." (no check ran this turn; the
|
|
@@ -24,7 +24,8 @@ independent; `type-check-only` never proves a behavior change.
|
|
|
24
24
|
- **Standards:** repo rules first (`CODING_STANDARDS.md` when present),
|
|
25
25
|
then a smell baseline (unclear name, long function, duplicated logic,
|
|
26
26
|
leaky abstraction). Judgment only; lint owns nits.
|
|
27
|
-
4. **Tests:**
|
|
27
|
+
4. **Tests:** run suites under the heavy-check lease (`workit check test`,
|
|
28
|
+
`workit check --heavy -- <cmd>`); `workit test-audit --diff`. Would each new test fail if the
|
|
28
29
|
behavior broke? Triage the findings (workit-test-audit).
|
|
29
30
|
5. **Blast radius:** for each touched contract, caller, config or migration,
|
|
30
31
|
state the one fact it is safe because of and run the proof. Anything
|
|
@@ -21,6 +21,8 @@ way a user would.
|
|
|
21
21
|
`.cursor/skills/`; other hosts: the directory their docs name). If the repo
|
|
22
22
|
already has a project skills directory, use it:
|
|
23
23
|
- **Launch:** one command, its ready signal (log line, port, exit 0), a timeout.
|
|
24
|
+
Builds, installs and e2e suites run under the heavy-check lease
|
|
25
|
+
(`workit check --heavy -- <cmd>`); a long-running server does not.
|
|
24
26
|
- **Doctor:** each precondition and its fix (deps, env vars, ports, services).
|
|
25
27
|
- **Drive:** how to exercise it: CLI invocations, `curl` against routes, a
|
|
26
28
|
browser tool for UI, with fixture data.
|
package/src/check-config.ts
CHANGED
|
@@ -29,6 +29,26 @@ import path from "node:path";
|
|
|
29
29
|
export const SCRIPT_CHECKS = ["test", "lint", "typecheck", "check"] as const;
|
|
30
30
|
export const CHECKS_FILE = "workit.checks.json";
|
|
31
31
|
|
|
32
|
+
/** Where the repo's checks config lives under the repository top `root` (the one path resolver). */
|
|
33
|
+
export const checksConfigFile = (root: string): string => path.join(root, CHECKS_FILE);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `resources.heavyCommands` of the repo's checks config under `root`, read
|
|
37
|
+
* leniently (no git, no check validation): the heavy-command gate's patterns.
|
|
38
|
+
*/
|
|
39
|
+
export function repoHeavyCommands(root: string): string[] {
|
|
40
|
+
try {
|
|
41
|
+
const value: unknown = JSON.parse(fs.readFileSync(checksConfigFile(root), "utf8"));
|
|
42
|
+
const resources = isRecord(value) ? value.resources : undefined;
|
|
43
|
+
const list = isRecord(resources) ? resources.heavyCommands : undefined;
|
|
44
|
+
return Array.isArray(list)
|
|
45
|
+
? list.filter((item): item is string => typeof item === "string")
|
|
46
|
+
: [];
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export type NamedCheck = {
|
|
33
53
|
name: string;
|
|
34
54
|
/** The argv `workit check <name>` runs. */
|
|
@@ -171,7 +191,7 @@ const GATES = ["testing", "verification"] as const;
|
|
|
171
191
|
/** The named checks for the repository containing `cwd` (see the header). */
|
|
172
192
|
export function loadCheckConfig(cwd: string): CheckConfig {
|
|
173
193
|
const root = checkRoot(cwd);
|
|
174
|
-
const file =
|
|
194
|
+
const file = checksConfigFile(root);
|
|
175
195
|
const none = (error: string | null = null): CheckConfig => ({
|
|
176
196
|
source: error ? CHECKS_FILE : "none",
|
|
177
197
|
root,
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Host permission rules the heavy-command gate must not override (#325,
|
|
2
|
+
// ruling mvid5onk). A heavy command is wrapped through the lease only when no
|
|
3
|
+
// readable deny/ask rule names it and wrapping does not raise its permission
|
|
4
|
+
// (an allow that covers `workit` but not the command itself). The matcher is
|
|
5
|
+
// conservative: anything it cannot read for sure counts as naming the
|
|
6
|
+
// command, so the gate steps aside and the host decides.
|
|
7
|
+
//
|
|
8
|
+
// Sources, per host:
|
|
9
|
+
// - Claude Code: managed-settings.json, the user settings
|
|
10
|
+
// ($CLAUDE_CONFIG_DIR or ~/.claude), <project>/.claude/settings.json and
|
|
11
|
+
// settings.local.json: permissions.deny/ask/allow `Bash(...)` and `Bash`;
|
|
12
|
+
// - OpenCode: ~/.config/opencode/opencode.json(c), opencode.json(c) and
|
|
13
|
+
// .opencode/opencode.json(c) up to the git top, OPENCODE_CONFIG,
|
|
14
|
+
// OPENCODE_CONFIG_CONTENT: permission.bash / permission.shell and every
|
|
15
|
+
// agent.*.permission;
|
|
16
|
+
// - Codex: ~/.codex/rules/*.rules and <project>/.codex/rules/*.rules
|
|
17
|
+
// (`prefix_rule(pattern=[…], decision=…)`);
|
|
18
|
+
// - Cursor: ~/.cursor/cli-config.json and <project>/.cursor/cli.json,
|
|
19
|
+
// permissions.deny/allow `Shell(x)`.
|
|
20
|
+
// Hook-safe: node built-ins only.
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
/** One rule: its pattern (`npx jest:*`, `*`, `` for a bare tool rule). */
|
|
26
|
+
export type Rules = { deny: string[]; allow: string[] };
|
|
27
|
+
/** Rules read from the host, or `unreadable` when any source could not be read for sure. */
|
|
28
|
+
export type RuleRead = Rules | "unreadable";
|
|
29
|
+
|
|
30
|
+
const EMPTY = (): Rules => ({ deny: [], allow: [] });
|
|
31
|
+
|
|
32
|
+
const readText = (file: string): string | null => {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readFileSync(file, "utf8");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
42
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
/** JSON with `//` and `/* *\/` comments and trailing commas (opencode.jsonc). */
|
|
45
|
+
export function parseJsonc(text: string): unknown {
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
48
|
+
const char = text[index];
|
|
49
|
+
if (char === '"') {
|
|
50
|
+
const end = /"(?:[^"\\]|\\.)*"/y;
|
|
51
|
+
end.lastIndex = index;
|
|
52
|
+
const match = end.exec(text);
|
|
53
|
+
if (!match) throw new Error("unterminated string");
|
|
54
|
+
out += match[0];
|
|
55
|
+
index += match[0].length - 1;
|
|
56
|
+
} else if (char === "/" && text[index + 1] === "/") {
|
|
57
|
+
while (index < text.length && text[index] !== "\n") index += 1;
|
|
58
|
+
out += "\n";
|
|
59
|
+
} else if (char === "/" && text[index + 1] === "*") {
|
|
60
|
+
const close = text.indexOf("*/", index + 2);
|
|
61
|
+
if (close < 0) throw new Error("unterminated comment");
|
|
62
|
+
index = close + 1;
|
|
63
|
+
} else out += char;
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/gu, "$1"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The git top of `cwd` (the nearest directory with `.git`), or null. */
|
|
69
|
+
export function gitTop(cwd: string): string | null {
|
|
70
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
71
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
72
|
+
if (path.dirname(dir) === dir) return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `cwd` and its parents up to the git top (or just `cwd` outside git). */
|
|
77
|
+
const projectDirs = (cwd: string): string[] => {
|
|
78
|
+
const top = gitTop(cwd);
|
|
79
|
+
const dirs: string[] = [];
|
|
80
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
81
|
+
dirs.push(dir);
|
|
82
|
+
if (!top || dir === top || path.dirname(dir) === dir) return dirs;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const homeOf = (env: NodeJS.ProcessEnv) => env.HOME || os.homedir();
|
|
87
|
+
|
|
88
|
+
// --- Claude Code -----------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const CLAUDE_MANAGED: Partial<Record<NodeJS.Platform, string>> = {
|
|
91
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
92
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
93
|
+
win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The Claude Code settings files the gate reads, most authoritative first. */
|
|
97
|
+
export function claudeSettingsFiles(
|
|
98
|
+
cwd: string,
|
|
99
|
+
env: NodeJS.ProcessEnv,
|
|
100
|
+
platform: NodeJS.Platform = process.platform,
|
|
101
|
+
): string[] {
|
|
102
|
+
const project = env.CLAUDE_PROJECT_DIR || gitTop(cwd) || cwd;
|
|
103
|
+
const user = env.CLAUDE_CONFIG_DIR || path.join(homeOf(env), ".claude");
|
|
104
|
+
return [
|
|
105
|
+
...(CLAUDE_MANAGED[platform] ? [CLAUDE_MANAGED[platform]] : []),
|
|
106
|
+
path.join(user, "settings.json"),
|
|
107
|
+
path.join(project, ".claude", "settings.json"),
|
|
108
|
+
path.join(project, ".claude", "settings.local.json"),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `Bash(x)` → `x`, `Bash` → ``, any other tool → null. */
|
|
113
|
+
const bashPattern = (entry: string): string | null => {
|
|
114
|
+
const match = /^Bash(?:\((.*)\))?$/su.exec(entry.trim());
|
|
115
|
+
return match ? (match[1] ?? "") : null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function claudeRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
119
|
+
const rules = EMPTY();
|
|
120
|
+
try {
|
|
121
|
+
for (const file of claudeSettingsFiles(cwd, env)) {
|
|
122
|
+
const text = readText(file);
|
|
123
|
+
if (text === null) continue;
|
|
124
|
+
const value: unknown = JSON.parse(text);
|
|
125
|
+
if (!isRecord(value)) return "unreadable";
|
|
126
|
+
const permissions = value.permissions;
|
|
127
|
+
if (permissions === undefined) continue;
|
|
128
|
+
if (!isRecord(permissions)) return "unreadable";
|
|
129
|
+
for (const [key, bucket] of [
|
|
130
|
+
["deny", rules.deny],
|
|
131
|
+
["ask", rules.deny],
|
|
132
|
+
["allow", rules.allow],
|
|
133
|
+
] as const) {
|
|
134
|
+
const list = permissions[key];
|
|
135
|
+
if (list === undefined) continue;
|
|
136
|
+
if (!Array.isArray(list)) return "unreadable";
|
|
137
|
+
for (const entry of list) {
|
|
138
|
+
if (typeof entry !== "string") return "unreadable";
|
|
139
|
+
const pattern = bashPattern(entry);
|
|
140
|
+
if (pattern !== null) bucket.push(pattern);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return "unreadable";
|
|
146
|
+
}
|
|
147
|
+
return rules;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- OpenCode ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const OPENCODE_NAMES = ["opencode.json", "opencode.jsonc"];
|
|
153
|
+
|
|
154
|
+
/** A `permission` block's bash/shell rules into `rules`; false when it cannot be read. */
|
|
155
|
+
const opencodePermission = (permission: unknown, rules: Rules): boolean => {
|
|
156
|
+
if (permission === undefined) return true;
|
|
157
|
+
if (typeof permission === "string") {
|
|
158
|
+
// A blanket permission covers every tool, the shell included.
|
|
159
|
+
(permission === "allow" ? rules.allow : rules.deny).push("");
|
|
160
|
+
return ["allow", "ask", "deny"].includes(permission);
|
|
161
|
+
}
|
|
162
|
+
if (!isRecord(permission)) return false;
|
|
163
|
+
for (const key of ["bash", "shell"]) {
|
|
164
|
+
const value = permission[key];
|
|
165
|
+
if (value === undefined) continue;
|
|
166
|
+
if (typeof value === "string") {
|
|
167
|
+
if (!["allow", "ask", "deny"].includes(value)) return false;
|
|
168
|
+
(value === "allow" ? rules.allow : rules.deny).push("");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!isRecord(value)) return false;
|
|
172
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
173
|
+
if (action !== "allow" && action !== "ask" && action !== "deny") return false;
|
|
174
|
+
(action === "allow" ? rules.allow : rules.deny).push(pattern);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const opencodeConfig = (value: unknown, rules: Rules): boolean => {
|
|
181
|
+
if (!isRecord(value)) return false;
|
|
182
|
+
if (!opencodePermission(value.permission, rules)) return false;
|
|
183
|
+
if (value.agent === undefined) return true;
|
|
184
|
+
if (!isRecord(value.agent)) return false;
|
|
185
|
+
return Object.values(value.agent).every(
|
|
186
|
+
(agent) => isRecord(agent) && opencodePermission(agent.permission, rules),
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export function opencodeRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
191
|
+
const rules = EMPTY();
|
|
192
|
+
try {
|
|
193
|
+
const config = env.XDG_CONFIG_HOME || path.join(homeOf(env), ".config");
|
|
194
|
+
const files = [
|
|
195
|
+
...OPENCODE_NAMES.map((name) => path.join(config, "opencode", name)),
|
|
196
|
+
...projectDirs(cwd).flatMap((dir) =>
|
|
197
|
+
OPENCODE_NAMES.flatMap((name) => [path.join(dir, name), path.join(dir, ".opencode", name)]),
|
|
198
|
+
),
|
|
199
|
+
...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
|
|
200
|
+
];
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const text = readText(file);
|
|
203
|
+
if (text !== null && !opencodeConfig(parseJsonc(text), rules)) return "unreadable";
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
env.OPENCODE_CONFIG_CONTENT &&
|
|
207
|
+
!opencodeConfig(parseJsonc(env.OPENCODE_CONFIG_CONTENT), rules)
|
|
208
|
+
)
|
|
209
|
+
return "unreadable";
|
|
210
|
+
} catch {
|
|
211
|
+
return "unreadable";
|
|
212
|
+
}
|
|
213
|
+
return rules;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Codex ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `prefix_rule(pattern=["npx", "jest"], decision="forbidden")` → its rule
|
|
220
|
+
* (the words joined), or null when in doubt: no pattern list, a nested
|
|
221
|
+
* alternatives list, a non-string word, or no known decision.
|
|
222
|
+
*/
|
|
223
|
+
const codexRule = (body: string): { pattern: string; decision: string } | null => {
|
|
224
|
+
const open = /\bpattern\s*=\s*\[/u.exec(body);
|
|
225
|
+
if (!open) return null;
|
|
226
|
+
const words: string[] = [];
|
|
227
|
+
let index = open.index + open[0].length;
|
|
228
|
+
for (;;) {
|
|
229
|
+
while (/[\s,]/u.test(body[index] ?? "")) index += 1;
|
|
230
|
+
if (body[index] === "]") break;
|
|
231
|
+
if (body[index] !== '"') return null;
|
|
232
|
+
const literal = /"((?:[^"\\]|\\.)*)"/y;
|
|
233
|
+
literal.lastIndex = index;
|
|
234
|
+
const match = literal.exec(body);
|
|
235
|
+
if (!match) return null;
|
|
236
|
+
words.push(JSON.parse(`"${match[1]}"`) as string);
|
|
237
|
+
index = literal.lastIndex;
|
|
238
|
+
}
|
|
239
|
+
const decision = /\bdecision\s*=\s*"(\w+)"/u.exec(body)?.[1];
|
|
240
|
+
if (!words.length || !decision || !["allow", "prompt", "forbidden"].includes(decision))
|
|
241
|
+
return null;
|
|
242
|
+
return { pattern: words.join(" "), decision };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function codexRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
246
|
+
const rules = EMPTY();
|
|
247
|
+
try {
|
|
248
|
+
const home = env.CODEX_HOME || path.join(homeOf(env), ".codex");
|
|
249
|
+
const top = gitTop(cwd) ?? cwd;
|
|
250
|
+
for (const dir of [path.join(home, "rules"), path.join(top, ".codex", "rules")]) {
|
|
251
|
+
let files: string[];
|
|
252
|
+
try {
|
|
253
|
+
files = fs.readdirSync(dir).filter((name) => name.endsWith(".rules"));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
256
|
+
return "unreadable";
|
|
257
|
+
}
|
|
258
|
+
for (const name of files) {
|
|
259
|
+
const text = readText(path.join(dir, name)) ?? "";
|
|
260
|
+
for (const part of text.split(/\bprefix_rule\s*\(/u).slice(1)) {
|
|
261
|
+
const rule = codexRule(part);
|
|
262
|
+
if (!rule) return "unreadable";
|
|
263
|
+
(rule.decision === "allow" ? rules.allow : rules.deny).push(rule.pattern);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
return "unreadable";
|
|
269
|
+
}
|
|
270
|
+
return rules;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Cursor -----------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function cursorRules(cwd: string, env: NodeJS.ProcessEnv = process.env): RuleRead {
|
|
276
|
+
const rules = EMPTY();
|
|
277
|
+
try {
|
|
278
|
+
const top = gitTop(cwd) ?? cwd;
|
|
279
|
+
for (const file of [
|
|
280
|
+
path.join(homeOf(env), ".cursor", "cli-config.json"),
|
|
281
|
+
path.join(top, ".cursor", "cli.json"),
|
|
282
|
+
]) {
|
|
283
|
+
const text = readText(file);
|
|
284
|
+
if (text === null) continue;
|
|
285
|
+
const value: unknown = JSON.parse(text);
|
|
286
|
+
if (!isRecord(value)) return "unreadable";
|
|
287
|
+
if (value.permissions === undefined) continue;
|
|
288
|
+
if (!isRecord(value.permissions)) return "unreadable";
|
|
289
|
+
for (const [key, bucket] of [
|
|
290
|
+
["deny", rules.deny],
|
|
291
|
+
["allow", rules.allow],
|
|
292
|
+
] as const) {
|
|
293
|
+
const list = value.permissions[key];
|
|
294
|
+
if (list === undefined) continue;
|
|
295
|
+
if (!Array.isArray(list)) return "unreadable";
|
|
296
|
+
for (const entry of list) {
|
|
297
|
+
if (typeof entry !== "string") return "unreadable";
|
|
298
|
+
const match = /^Shell(?:\((.*)\))?$/su.exec(entry.trim());
|
|
299
|
+
if (match) bucket.push(match[1] ?? "");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
return "unreadable";
|
|
305
|
+
}
|
|
306
|
+
return rules;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- the matcher --------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
const baseName = (word: string): string =>
|
|
312
|
+
(word.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat)$/u, "");
|
|
313
|
+
|
|
314
|
+
/** A pattern's first token: cut at `:` or a space. */
|
|
315
|
+
const firstToken = (pattern: string): string => pattern.trim().split(/[:\s]/u)[0] ?? "";
|
|
316
|
+
|
|
317
|
+
/** Does a deny/ask `pattern` name a command with these words? */
|
|
318
|
+
export function names(pattern: string, words: readonly string[]): boolean {
|
|
319
|
+
const token = firstToken(pattern);
|
|
320
|
+
if (!token || token === "*" || token.includes("*")) return true;
|
|
321
|
+
const wanted = baseName(token);
|
|
322
|
+
return words.some((word) => baseName(word) === wanted);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A pattern's literal prefix, without a trailing `:*`, ` *` or `*`. */
|
|
326
|
+
const literalPrefix = (pattern: string): string => pattern.trim().replace(/(?::\*| \*|\*)$/u, "");
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Could an allow `pattern` cover the wrapper (`workit check --heavy -- …`)?
|
|
330
|
+
* A bare rule, a first word with a glob, or a literal prefix of the wrapper
|
|
331
|
+
* (`workit`, `workit check`). `workit git push:*` cannot match it.
|
|
332
|
+
*/
|
|
333
|
+
const coversWrapper = (pattern: string): boolean => {
|
|
334
|
+
const token = firstToken(pattern);
|
|
335
|
+
if (!token || token.includes("*")) return true;
|
|
336
|
+
const words = literalPrefix(pattern).split(/\s+/u);
|
|
337
|
+
if (baseName(words[0]) !== "workit") return false;
|
|
338
|
+
return ["check", "--heavy"].slice(0, words.length - 1).every((word, at) => {
|
|
339
|
+
const given = words[at + 1];
|
|
340
|
+
return given === word || word.startsWith(given);
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Does an allow `pattern` cover `command` by a literal prefix? */
|
|
345
|
+
const coversCommand = (pattern: string, command: string): boolean => {
|
|
346
|
+
const prefix = literalPrefix(pattern);
|
|
347
|
+
if (!prefix) return true;
|
|
348
|
+
return !prefix.includes("*") && command.trim().startsWith(prefix);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export type RuleVerdict = { ok: true } | { ok: false; why: "named" | "allowlisted" };
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* May the gate wrap a heavy command? Not when a deny/ask rule names a word
|
|
355
|
+
* of it (`words`), and not when an allow covers `workit` but not `command`
|
|
356
|
+
* (wrapping would raise its permission; skipped with `bypass`).
|
|
357
|
+
*/
|
|
358
|
+
export function wrapAllowed(
|
|
359
|
+
read: RuleRead,
|
|
360
|
+
words: readonly string[],
|
|
361
|
+
command: string,
|
|
362
|
+
options: { bypass?: boolean } = {},
|
|
363
|
+
): RuleVerdict {
|
|
364
|
+
if (read === "unreadable") return { ok: false, why: "named" };
|
|
365
|
+
if (read.deny.some((pattern) => names(pattern, words))) return { ok: false, why: "named" };
|
|
366
|
+
if (
|
|
367
|
+
!options.bypass &&
|
|
368
|
+
read.allow.some(coversWrapper) &&
|
|
369
|
+
!read.allow.some((pattern) => coversCommand(pattern, command))
|
|
370
|
+
)
|
|
371
|
+
return { ok: false, why: "allowlisted" };
|
|
372
|
+
return { ok: true };
|
|
373
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The systemd user scope a heavy check runs in (issue #282), and the probe
|
|
2
|
+
// `workit doctor` uses to say whether its limits really apply (#325). Node
|
|
3
|
+
// built-ins only, so the doctor and the install-time doctor load it without
|
|
4
|
+
// the check runner or the hook graph.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const readText = (file: string): string | null => {
|
|
10
|
+
try {
|
|
11
|
+
return fs.readFileSync(file, "utf8");
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ScopeSupport = { ok: true } | { ok: false; reason: string };
|
|
18
|
+
|
|
19
|
+
const onPath = (command: string, env: NodeJS.ProcessEnv): boolean =>
|
|
20
|
+
(env.PATH ?? "")
|
|
21
|
+
.split(path.delimiter)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.some((dir) => {
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(path.join(dir, command), fs.constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Can heavy checks run in their own `systemd-run --user --scope`? Needs Linux,
|
|
34
|
+
* `systemd-run` on PATH, a running user manager, and the memory and cpu
|
|
35
|
+
* controllers delegated to it (cgroup v2).
|
|
36
|
+
*/
|
|
37
|
+
export function scopeSupport(
|
|
38
|
+
env: NodeJS.ProcessEnv,
|
|
39
|
+
platform: NodeJS.Platform = process.platform,
|
|
40
|
+
read: (file: string) => string | null = readText,
|
|
41
|
+
): ScopeSupport {
|
|
42
|
+
if (platform !== "linux") return { ok: false, reason: `no systemd scopes on ${platform}` };
|
|
43
|
+
if (!onPath("systemd-run", env)) return { ok: false, reason: "systemd-run is not on PATH" };
|
|
44
|
+
const uid = process.getuid?.() ?? 0;
|
|
45
|
+
const runtime = env.XDG_RUNTIME_DIR || `/run/user/${uid}`;
|
|
46
|
+
if (!fs.existsSync(path.join(runtime, "systemd", "private")))
|
|
47
|
+
return { ok: false, reason: "no user systemd manager is running" };
|
|
48
|
+
const controllers = read(
|
|
49
|
+
`/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
|
|
50
|
+
);
|
|
51
|
+
if (controllers === null)
|
|
52
|
+
return { ok: false, reason: "cgroup v2 is not mounted for the user manager" };
|
|
53
|
+
const missing = ["memory", "cpu"].filter((name) => !controllers.split(/\s+/u).includes(name));
|
|
54
|
+
if (missing.length)
|
|
55
|
+
return { ok: false, reason: `the user manager is not delegated ${missing.join(" and ")}` };
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `systemd-run` prefix that runs a command in its own limited scope. */
|
|
60
|
+
export function scopePrefix(unit: string, settings: { memoryMax: string; cpuQuota: string }) {
|
|
61
|
+
return [
|
|
62
|
+
"systemd-run",
|
|
63
|
+
"--user",
|
|
64
|
+
"--scope",
|
|
65
|
+
"--quiet",
|
|
66
|
+
"--collect",
|
|
67
|
+
`--unit=${unit}`,
|
|
68
|
+
"-p",
|
|
69
|
+
`MemoryMax=${settings.memoryMax}`,
|
|
70
|
+
"-p",
|
|
71
|
+
`CPUQuota=${settings.cpuQuota}`,
|
|
72
|
+
"--",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ScopeProbe =
|
|
77
|
+
| { effective: true; memoryMax: string; cpuMax: string; delegate: string | null }
|
|
78
|
+
| { effective: false; supported: boolean; reason: string; delegate: string | null };
|
|
79
|
+
|
|
80
|
+
const PROBE_MEMORY = "268435456";
|
|
81
|
+
const PROBE_CPU = "50%";
|
|
82
|
+
const PROBE_SCRIPT =
|
|
83
|
+
'cg=$(sed -n "s/^0:://p" /proc/self/cgroup); cat "/sys/fs/cgroup$cg/memory.max" "/sys/fs/cgroup$cg/cpu.max"';
|
|
84
|
+
|
|
85
|
+
/** `Delegate=` and `DelegateControllers=` of the user manager's unit, as systemd reports them. */
|
|
86
|
+
function userManagerDelegate(env: NodeJS.ProcessEnv): string | null {
|
|
87
|
+
const uid = process.getuid?.() ?? 0;
|
|
88
|
+
const shown = spawnSync(
|
|
89
|
+
"systemctl",
|
|
90
|
+
["show", `user@${uid}.service`, "-p", "Delegate", "-p", "DelegateControllers"],
|
|
91
|
+
{ env, encoding: "utf8", timeout: 5000 },
|
|
92
|
+
);
|
|
93
|
+
const text = shown.status === 0 ? shown.stdout.trim().split(/\n/u).join(", ") : "";
|
|
94
|
+
return text || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Do heavy-check scopes really get their limits here? Starts a throwaway
|
|
99
|
+
* scope the way a heavy check does (MemoryMax 256 MiB, CPUQuota 50%) and
|
|
100
|
+
* reads the limits back from its own cgroup, so a manager that accepts the
|
|
101
|
+
* properties but cannot apply them (no delegation) is told apart.
|
|
102
|
+
*/
|
|
103
|
+
export function probeScope(env: NodeJS.ProcessEnv = process.env): ScopeProbe {
|
|
104
|
+
const support = scopeSupport(env);
|
|
105
|
+
const delegate = process.platform === "linux" ? userManagerDelegate(env) : null;
|
|
106
|
+
if (!support.ok) return { effective: false, supported: false, reason: support.reason, delegate };
|
|
107
|
+
const unit = `workit-probe-${process.pid}-${Date.now().toString(36)}`;
|
|
108
|
+
// The same `systemd-run …` prefix a heavy check runs under.
|
|
109
|
+
const [runner, ...prefix] = scopePrefix(unit, { memoryMax: PROBE_MEMORY, cpuQuota: PROBE_CPU });
|
|
110
|
+
const probe = spawnSync(runner, [...prefix, "sh", "-c", PROBE_SCRIPT], {
|
|
111
|
+
env,
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
timeout: 10_000,
|
|
114
|
+
});
|
|
115
|
+
if (probe.status !== 0)
|
|
116
|
+
return {
|
|
117
|
+
effective: false,
|
|
118
|
+
supported: true,
|
|
119
|
+
reason: `systemd-run failed: ${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim().split("\n")[0]}`,
|
|
120
|
+
delegate,
|
|
121
|
+
};
|
|
122
|
+
const [memoryMax = "", cpuMax = ""] = probe.stdout.trim().split(/\n/u);
|
|
123
|
+
if (memoryMax.trim() === PROBE_MEMORY && cpuMax.trim().startsWith("50000 "))
|
|
124
|
+
return { effective: true, memoryMax: memoryMax.trim(), cpuMax: cpuMax.trim(), delegate };
|
|
125
|
+
return {
|
|
126
|
+
effective: false,
|
|
127
|
+
supported: true,
|
|
128
|
+
reason: `the scope started but its cgroup reads memory.max=${memoryMax.trim() || "?"} cpu.max=${cpuMax.trim() || "?"} (asked ${PROBE_MEMORY} and 50000 100000)`,
|
|
129
|
+
delegate,
|
|
130
|
+
};
|
|
131
|
+
}
|