@tekmidian/pai 0.61.0 → 0.63.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/dist/{async-CLvuY7b-.mjs → async-DNVWtQl2.mjs} +2 -2
- package/dist/{async-CLvuY7b-.mjs.map → async-DNVWtQl2.mjs.map} +1 -1
- package/dist/{chain-C5oeyUg4.mjs → chain-CUfE6WYK.mjs} +2 -2
- package/dist/{chain-C5oeyUg4.mjs.map → chain-CUfE6WYK.mjs.map} +1 -1
- package/dist/cli/index.mjs +11 -11
- package/dist/cli/program.mjs +11 -11
- package/dist/{context-handover-cache-BdtJZpgv.mjs → context-handover-cache-DFNnlWv2.mjs} +2 -2
- package/dist/{context-handover-cache-BdtJZpgv.mjs.map → context-handover-cache-DFNnlWv2.mjs.map} +1 -1
- package/dist/daemon/index.mjs +8 -8
- package/dist/daemon-C8-uzOuV.mjs +19 -0
- package/dist/{daemon-DSsIEqzW.mjs → daemon-Cex3dT9f.mjs} +32 -18
- package/dist/{daemon-DSsIEqzW.mjs.map → daemon-Cex3dT9f.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +4 -4
- package/dist/{factory-EUUhSSoq.mjs → factory-BozZa81q.mjs} +3 -3
- package/dist/{factory-EUUhSSoq.mjs.map → factory-BozZa81q.mjs.map} +1 -1
- package/dist/{factory-DW7BCJJT.mjs → factory-UO9_DFMN.mjs} +1 -1
- package/dist/{fallback-DDbJVaR8.mjs → fallback-ktbR0aR7.mjs} +2 -2
- package/dist/{fallback-DDbJVaR8.mjs.map → fallback-ktbR0aR7.mjs.map} +1 -1
- package/dist/{helpers-D9WkIf9R.mjs → helpers-Dj-B314s.mjs} +3 -2
- package/dist/{helpers-D9WkIf9R.mjs.map → helpers-Dj-B314s.mjs.map} +1 -1
- package/dist/hooks/block-sleep-poll.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +8519 -133
- package/dist/hooks/load-project-context.mjs.map +4 -4
- package/dist/hooks/security-validator.mjs.map +1 -1
- package/dist/hooks/worker-guard.mjs +7859 -0
- package/dist/hooks/worker-guard.mjs.map +7 -0
- package/dist/hooks/worker-status-line.mjs +101 -8
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/index.mjs +1 -1
- package/dist/{indexer-backend-DcDtin1O.mjs → indexer-backend-BN9rpp9O.mjs} +2 -2
- package/dist/{main-resolver-DIQ6m_RA.mjs → main-resolver-BIft_FVF.mjs} +3 -3
- package/dist/{main-resolver-DIQ6m_RA.mjs.map → main-resolver-BIft_FVF.mjs.map} +1 -1
- package/dist/{main-resolver-CxOsxtOr.mjs → main-resolver-C9-poMdi.mjs} +1 -1
- package/dist/{planner-D8jAr_sx.mjs → planner-DAQMTMjP.mjs} +4 -4
- package/dist/{planner-D8jAr_sx.mjs.map → planner-DAQMTMjP.mjs.map} +1 -1
- package/dist/{postgres-nvU5pShF.mjs → postgres-CxD6_Dca.mjs} +3 -3
- package/dist/{postgres-nvU5pShF.mjs.map → postgres-CxD6_Dca.mjs.map} +1 -1
- package/dist/{program-BTdspOOa.mjs → program-De1GVVM4.mjs} +178 -48
- package/dist/program-De1GVVM4.mjs.map +1 -0
- package/dist/{run-CxxZKEZw.mjs → run-CZVCCvXE.mjs} +353 -133
- package/dist/run-CZVCCvXE.mjs.map +1 -0
- package/dist/{session-keepalive-urk--pfa.mjs → session-keepalive-CWGzLBVU.mjs} +2 -2
- package/dist/{session-keepalive-urk--pfa.mjs.map → session-keepalive-CWGzLBVU.mjs.map} +1 -1
- package/dist/{sqlite-D9p5a4qe.mjs → sqlite-CfKfuggn.mjs} +2 -2
- package/dist/{sqlite-D9p5a4qe.mjs.map → sqlite-CfKfuggn.mjs.map} +1 -1
- package/dist/{tools-r-7ZG8B5.mjs → tools-B52IvaU6.mjs} +3 -3
- package/dist/{tools-qXyHFwNj.mjs → tools-DFdPbX-X.mjs} +2 -2
- package/dist/{tools-qXyHFwNj.mjs.map → tools-DFdPbX-X.mjs.map} +1 -1
- package/dist/{vault-indexer-DHYBpRZ5.mjs → vault-indexer-mCFus_AM.mjs} +2 -2
- package/dist/{vault-indexer-DHYBpRZ5.mjs.map → vault-indexer-mCFus_AM.mjs.map} +1 -1
- package/dist/{wakeup-C48hcN6O.mjs → wakeup-DO1O4udw.mjs} +2 -2
- package/dist/{wakeup-C48hcN6O.mjs.map → wakeup-DO1O4udw.mjs.map} +1 -1
- package/dist/{work-queue-worker-DUvPiwGO.mjs → work-queue-worker-BbrC3I_R.mjs} +2 -2
- package/dist/{work-queue-worker-DUvPiwGO.mjs.map → work-queue-worker-BbrC3I_R.mjs.map} +1 -1
- package/dist/{work-queue-worker-1_VBfI_k.mjs → work-queue-worker-z-AATZ2t.mjs} +3 -3
- package/docs/commands/README.md +4 -0
- package/docs/commands/project.md +14 -5
- package/docs/commands/projects.md +14 -5
- package/docs/commands/worker.md +27 -0
- package/package.json +2 -2
- package/scripts/build-hooks.mjs +6 -5
- package/scripts/build-skill-stubs.mjs +6 -5
- package/src/hooks/ts/lib/worker-guard.test.ts +188 -0
- package/src/hooks/ts/lib/worker-guard.ts +315 -0
- package/src/hooks/ts/pre-tool-use/worker-guard.ts +75 -0
- package/src/hooks/ts/session-start/load-project-context.ts +23 -0
- package/dist/daemon-MoFF4oeg.mjs +0 -19
- package/dist/program-BTdspOOa.mjs.map +0 -1
- package/dist/run-CxxZKEZw.mjs.map +0 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-guard.ts — the decision behind the worker-only PreToolUse guard.
|
|
3
|
+
*
|
|
4
|
+
* Pure but for reading a `git commit -F <file>` message: no stdin, no git — the hook entry (pre-tool-use/worker-guard.ts)
|
|
5
|
+
* gathers the context, this module decides, same split as sleep-poll-gate.ts.
|
|
6
|
+
* Spec prose ("never symlink node_modules", "do not push", ...) was broken by
|
|
7
|
+
* workers anyway; this is the enforcement. Active only for PAI_WORKER=1.
|
|
8
|
+
*
|
|
9
|
+
* Parsing is conservative on purpose: a command is split on ; && || | & and
|
|
10
|
+
* newlines outside quotes, `bash -c '<cmd>'` is unwrapped, `cd` is tracked
|
|
11
|
+
* across segments. Not caught (false negatives chosen over blocking real
|
|
12
|
+
* work): $(...) / backtick substitutions, subshell/brace grouping, variables
|
|
13
|
+
* other than ~ and $HOME in paths, scripts that do the forbidden thing
|
|
14
|
+
* internally, `git -C <other>` targeting another repo's cwd, cp/rsync with
|
|
15
|
+
* -t or a source that is a subdirectory of the tree.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { dirname, resolve } from "node:path";
|
|
20
|
+
import { type GateDecision } from "./sleep-poll-gate.js";
|
|
21
|
+
import { decideWorkerGitGuard } from "./worker-git-guard.js";
|
|
22
|
+
|
|
23
|
+
export interface WorkerGuardInput {
|
|
24
|
+
tool_name?: string;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
tool_input?: { command?: string; file_path?: string; path?: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkerGuardContext {
|
|
30
|
+
/** Hook cwd. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
home: string;
|
|
33
|
+
/** Top level of the git tree containing cwd, null outside git. */
|
|
34
|
+
worktreeRoot: string | null;
|
|
35
|
+
/** True when cwd is a linked worktree (git-dir differs from common dir). */
|
|
36
|
+
inWorktree: boolean;
|
|
37
|
+
/** The main checkout the worktree hangs off; null when not in a worktree. */
|
|
38
|
+
mainCheckout: string | null;
|
|
39
|
+
/** package.json `scripts.test` of the tree, null when unknown. */
|
|
40
|
+
testScript: () => string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Segment {
|
|
44
|
+
words: string[];
|
|
45
|
+
/** Redirection targets (`> f`, `>> f`, `2>f`). */
|
|
46
|
+
redirects: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deny = (reason: string): GateDecision => ({ decision: "deny", reason });
|
|
50
|
+
const ALLOW: GateDecision = { decision: "allow" };
|
|
51
|
+
|
|
52
|
+
/** Split into segments; quotes group words, operators outside quotes split. */
|
|
53
|
+
export function parseCommand(command: string): Segment[] {
|
|
54
|
+
const segs: Segment[] = [];
|
|
55
|
+
let seg: Segment = { words: [], redirects: [] };
|
|
56
|
+
let tok = "";
|
|
57
|
+
let has = false; // a token is in progress (covers empty quoted "")
|
|
58
|
+
let quote: string | null = null;
|
|
59
|
+
let target: "redir" | "skip" | null = null;
|
|
60
|
+
|
|
61
|
+
const flushTok = (): void => {
|
|
62
|
+
if (!has) return;
|
|
63
|
+
if (target === "redir") seg.redirects.push(tok);
|
|
64
|
+
else if (target !== "skip") seg.words.push(tok);
|
|
65
|
+
target = null;
|
|
66
|
+
tok = "";
|
|
67
|
+
has = false;
|
|
68
|
+
};
|
|
69
|
+
const endSeg = (): void => {
|
|
70
|
+
flushTok();
|
|
71
|
+
if (seg.words.length || seg.redirects.length) segs.push(seg);
|
|
72
|
+
seg = { words: [], redirects: [] };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < command.length; i++) {
|
|
76
|
+
const ch = command[i];
|
|
77
|
+
if (quote) {
|
|
78
|
+
if (ch === quote) quote = null;
|
|
79
|
+
else if (ch === "\\" && quote === '"' && i + 1 < command.length) tok += command[++i];
|
|
80
|
+
else tok += ch;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === "'" || ch === '"') {
|
|
84
|
+
quote = ch;
|
|
85
|
+
has = true;
|
|
86
|
+
} else if (ch === "\\" && i + 1 < command.length) {
|
|
87
|
+
tok += command[++i];
|
|
88
|
+
has = true;
|
|
89
|
+
} else if (/\s/.test(ch) && ch !== "\n") {
|
|
90
|
+
flushTok();
|
|
91
|
+
} else if (ch === ";" || ch === "\n" || ch === "|" || ch === "&") {
|
|
92
|
+
if (ch === "&" && command[i + 1] === ">") continue; // `&>` redirects
|
|
93
|
+
endSeg();
|
|
94
|
+
if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++;
|
|
95
|
+
} else if (ch === ">" || ch === "<") {
|
|
96
|
+
if (has && /^\d+$/.test(tok)) {
|
|
97
|
+
tok = "";
|
|
98
|
+
has = false;
|
|
99
|
+
} else flushTok();
|
|
100
|
+
if (ch === ">" && command[i + 1] === ">") i++;
|
|
101
|
+
if (command[i + 1] === "&") {
|
|
102
|
+
i++;
|
|
103
|
+
target = "skip"; // fd duplication: `2>&1`
|
|
104
|
+
} else target = ch === ">" ? "redir" : "skip";
|
|
105
|
+
} else {
|
|
106
|
+
tok += ch;
|
|
107
|
+
has = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
endSeg();
|
|
111
|
+
return segs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const WRAPPERS = new Set(["sudo", "command", "env", "time", "nohup", "exec", "builtin"]);
|
|
115
|
+
|
|
116
|
+
/** Drop env assignments and wrapper commands from the front. */
|
|
117
|
+
function stripPrefix(words: string[]): string[] {
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || WRAPPERS.has(words[i]))) i++;
|
|
120
|
+
return words.slice(i);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isFlag = (w: string): boolean => w.startsWith("-") && w.length > 1;
|
|
124
|
+
|
|
125
|
+
function expand(p: string, home: string): string {
|
|
126
|
+
return p.replace(/^~(?=\/|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const hasUnresolvedVar = (p: string): boolean => /[$`]/.test(p);
|
|
130
|
+
|
|
131
|
+
const isInside = (p: string, root: string): boolean => p === root || p.startsWith(root.replace(/\/$/, "") + "/");
|
|
132
|
+
|
|
133
|
+
/** Absolute, or null when the path holds variables we cannot resolve. */
|
|
134
|
+
function abs(p: string, cwd: string, home: string): string | null {
|
|
135
|
+
const e = expand(p, home);
|
|
136
|
+
return hasUnresolvedVar(e) ? null : resolve(cwd, e);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Live PAI/Claude config that tests and workers must not write. */
|
|
140
|
+
export function isLiveConfig(p: string, home: string): boolean {
|
|
141
|
+
if (p === `${home}/.claude.json` || p === `${home}/.claude/settings.json`) return true;
|
|
142
|
+
return dirname(p) === `${home}/.claude/pai` && /\.(ya?ml|json)$/.test(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isTodo = (p: string): boolean => p === "Notes/TODO.md" || p.endsWith("/Notes/TODO.md");
|
|
146
|
+
|
|
147
|
+
function protectedPathReason(p: string, home: string): string | null {
|
|
148
|
+
if (isTodo(p)) return "Notes/TODO.md is the orchestrator's: report what should change in your result instead";
|
|
149
|
+
if (isLiveConfig(p, home)) return "live PAI/Claude config must not be written: tests use temp-dir copies";
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Paths a segment writes to (redirects plus the write-side of common tools). */
|
|
154
|
+
function writeTargets(words: string[], redirects: string[]): string[] {
|
|
155
|
+
const [cmd, ...args] = words;
|
|
156
|
+
const pos = args.filter((a) => !isFlag(a));
|
|
157
|
+
const out = [...redirects];
|
|
158
|
+
if (cmd === "tee" || cmd === "mv") out.push(...pos);
|
|
159
|
+
else if (cmd === "cp" && pos.length) out.push(pos[pos.length - 1]);
|
|
160
|
+
else if (cmd === "sed" && args.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a))) out.push(...pos);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const RECURSIVE_CP = /^-[a-zA-Z]*[rRa]|^--recursive$|^--archive$/;
|
|
165
|
+
const RECURSIVE_RSYNC = /^-[a-zA-Z]*[ra]|^--recursive$|^--archive$/;
|
|
166
|
+
|
|
167
|
+
const AI_ATTRIBUTION = /Co-Authored-By:[^\n]*(claude|anthropic)|Generated with[^\n]*Claude Code/i;
|
|
168
|
+
|
|
169
|
+
/** Message text a `git commit` carries: the raw command (-m, heredoc) plus any -F/--file body. */
|
|
170
|
+
function commitMessageText(rest: string[], cwd: string, raw: string, home: string): string {
|
|
171
|
+
let text = raw;
|
|
172
|
+
for (let i = 0; i < rest.length; i++) {
|
|
173
|
+
const f = rest[i] === "-F" || rest[i] === "--file" ? rest[i + 1] : rest[i].startsWith("--file=") ? rest[i].slice(7) : undefined;
|
|
174
|
+
const p = f ? abs(f, cwd, home) : null;
|
|
175
|
+
if (!p) continue;
|
|
176
|
+
try {
|
|
177
|
+
text += "\n" + readFileSync(p, "utf8");
|
|
178
|
+
} catch {
|
|
179
|
+
// missing file: nothing to check
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return text;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const GIT_TREE_CMDS = new Set(["reset", "clean", "restore", "switch", "rebase", "merge", "checkout", "stash"]);
|
|
186
|
+
|
|
187
|
+
function gitSub(args: string[]): { sub: string; rest: string[] } | null {
|
|
188
|
+
for (let i = 0; i < args.length; i++) {
|
|
189
|
+
const a = args[i];
|
|
190
|
+
if (a === "-C" || a === "-c" || a === "--git-dir" || a === "--work-tree") i++;
|
|
191
|
+
else if (!isFlag(a)) return { sub: a, rest: args.slice(i + 1) };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Decide one segment; `st.cwd` is the tracked working directory. */
|
|
197
|
+
function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: WorkerGuardContext): GateDecision {
|
|
198
|
+
const words = stripPrefix(seg.words);
|
|
199
|
+
const [cmd, ...args] = words;
|
|
200
|
+
const boundary = ctx.worktreeRoot ?? ctx.cwd;
|
|
201
|
+
|
|
202
|
+
for (const t of writeTargets(words.length ? words : [""], seg.redirects)) {
|
|
203
|
+
const p = abs(t, st.cwd, ctx.home);
|
|
204
|
+
const why = p && protectedPathReason(p, ctx.home);
|
|
205
|
+
if (why) return deny(`write to ${t} blocked: ${why}`);
|
|
206
|
+
}
|
|
207
|
+
if (!cmd) return ALLOW;
|
|
208
|
+
|
|
209
|
+
if ((cmd === "bash" || cmd === "sh" || cmd === "zsh") && args[0] === "-c" && args[1]) {
|
|
210
|
+
return decideCommand(args[1], { ...ctx, cwd: st.cwd });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const pos = args.filter((a) => !isFlag(a));
|
|
214
|
+
|
|
215
|
+
if (cmd === "cd") {
|
|
216
|
+
const p = pos[0] ? abs(pos[0], st.cwd, ctx.home) : null;
|
|
217
|
+
if (!p) return ALLOW;
|
|
218
|
+
if (ctx.mainCheckout && ctx.worktreeRoot && isInside(p, ctx.mainCheckout) && !isInside(p, ctx.worktreeRoot)) {
|
|
219
|
+
return deny(`cd ${pos[0]} blocked: it leaves your worktree for the main checkout; use paths relative to your worktree`);
|
|
220
|
+
}
|
|
221
|
+
st.cwd = p;
|
|
222
|
+
return ALLOW;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (cmd === "ln" && args.some((a) => /^-[a-zA-Z]*s|^--symbolic$/.test(a)) && pos.length) {
|
|
226
|
+
const link = pos[1] ? abs(pos[1], st.cwd, ctx.home) : null;
|
|
227
|
+
const from = link ? dirname(link) : st.cwd;
|
|
228
|
+
const target = abs(pos[0], from, ctx.home);
|
|
229
|
+
if (target && !isInside(target, boundary)) {
|
|
230
|
+
return deny("ln -s to a target outside your worktree blocked: the runner provisions node_modules in your worktree");
|
|
231
|
+
}
|
|
232
|
+
return ALLOW;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if ((cmd === "cp" && args.some((a) => RECURSIVE_CP.test(a))) || (cmd === "rsync" && args.some((a) => RECURSIVE_RSYNC.test(a)))) {
|
|
236
|
+
if (pos.length >= 2) {
|
|
237
|
+
const dest = abs(pos[pos.length - 1], st.cwd, ctx.home);
|
|
238
|
+
const roots = [boundary, ctx.mainCheckout].filter((r): r is string => !!r);
|
|
239
|
+
const copiesRoot = pos.slice(0, -1).some((s) => {
|
|
240
|
+
const p = abs(s, st.cwd, ctx.home);
|
|
241
|
+
return !!p && roots.includes(p);
|
|
242
|
+
});
|
|
243
|
+
if (copiesRoot && dest && !isInside(dest, boundary)) {
|
|
244
|
+
return deny(`${cmd} of the repo tree out of your worktree blocked: build and test inside your worktree, never from a copy`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return ALLOW;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if ((cmd === "npm" || cmd === "bun" || cmd === "pnpm" || cmd === "yarn") && pos[0] === "publish") {
|
|
251
|
+
return deny(`${cmd} publish blocked: releases are the orchestrator's`);
|
|
252
|
+
}
|
|
253
|
+
if (cmd === "npm" && pos[0] === "version" && pos.length > 1) {
|
|
254
|
+
return deny("npm version blocked: version bumps are the orchestrator's");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (cmd === "bun" && pos[0] === "test" && /vitest/.test(ctx.testScript() ?? "")) {
|
|
258
|
+
return deny("bun test blocked: this repo's tests are vitest; run `npx vitest run <files>`");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (cmd === "git") {
|
|
262
|
+
const g = gitSub(args);
|
|
263
|
+
if (!g) return ALLOW;
|
|
264
|
+
if (g.sub === "push") return deny("git push blocked: pushing is the orchestrator's; commit on your branch and stop");
|
|
265
|
+
if (g.sub === "commit" && AI_ATTRIBUTION.test(commitMessageText(g.rest, st.cwd, st.raw, ctx.home))) {
|
|
266
|
+
return deny("no AI attribution in commits (operator rule): drop the Co-Authored-By / Generated-with line and commit again");
|
|
267
|
+
}
|
|
268
|
+
if (g.sub === "tag") {
|
|
269
|
+
const listing = !g.rest.some((a) => !isFlag(a)) || g.rest.some((a) => /^(-l|--list|-n\d*|--contains|--points-at)$/.test(a));
|
|
270
|
+
if (!listing) return deny("git tag blocked: releases are the orchestrator's");
|
|
271
|
+
}
|
|
272
|
+
if (!ctx.inWorktree) {
|
|
273
|
+
if (GIT_TREE_CMDS.has(g.sub)) {
|
|
274
|
+
const r = decideWorkerGitGuard(words.join(" "), true, st.cwd, null);
|
|
275
|
+
if (r.blocked) return deny(`${r.cmd} blocked: you run in a shared checkout, not a worktree; it can destroy other workers' edits, use --worktree`);
|
|
276
|
+
}
|
|
277
|
+
if (g.sub === "checkout" && g.rest.some((a) => !isFlag(a))) {
|
|
278
|
+
return deny("git checkout <branch> blocked: you run in a shared checkout, not a worktree; use --worktree");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return ALLOW;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function decideCommand(command: string, ctx: WorkerGuardContext): GateDecision {
|
|
286
|
+
const st = { cwd: ctx.cwd, raw: command };
|
|
287
|
+
for (const seg of parseCommand(command)) {
|
|
288
|
+
const d = decideSegment(seg, st, ctx);
|
|
289
|
+
if (d.decision === "deny") return d;
|
|
290
|
+
}
|
|
291
|
+
return ALLOW;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function decideWorkerGuard(
|
|
295
|
+
input: WorkerGuardInput,
|
|
296
|
+
env: Record<string, string | undefined>,
|
|
297
|
+
ctx: () => WorkerGuardContext
|
|
298
|
+
): GateDecision {
|
|
299
|
+
if (env.PAI_WORKER !== "1") return ALLOW;
|
|
300
|
+
const tool = input.tool_name;
|
|
301
|
+
const ti = input.tool_input ?? {};
|
|
302
|
+
|
|
303
|
+
if (tool === "Bash") {
|
|
304
|
+
return typeof ti.command === "string" ? decideCommand(ti.command, ctx()) : ALLOW;
|
|
305
|
+
}
|
|
306
|
+
if (tool === "Edit" || tool === "Write" || tool === "MultiEdit" || tool === "NotebookEdit") {
|
|
307
|
+
const f = ti.file_path ?? ti.path;
|
|
308
|
+
if (typeof f !== "string") return ALLOW;
|
|
309
|
+
const c = ctx();
|
|
310
|
+
const p = abs(f, c.cwd, c.home);
|
|
311
|
+
const why = p && protectedPathReason(p, c.home);
|
|
312
|
+
return why ? deny(`edit of ${f} blocked: ${why}`) : ALLOW;
|
|
313
|
+
}
|
|
314
|
+
return ALLOW;
|
|
315
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* worker-guard.ts — PreToolUse (Bash|Edit|Write|MultiEdit|NotebookEdit)
|
|
5
|
+
*
|
|
6
|
+
* Worker-only (PAI_WORKER=1) enforcement of the git/worktree rules every
|
|
7
|
+
* worker spec used to repeat in prose and workers still broke: no symlinks
|
|
8
|
+
* out of the worktree, no repo copies, no push/publish/tag/version, no
|
|
9
|
+
* tree-rewriting git in a shared checkout, no cd into the main checkout,
|
|
10
|
+
* no Notes/TODO.md or live config writes, no `bun test` on a vitest repo.
|
|
11
|
+
* A no-op in interactive sessions.
|
|
12
|
+
*
|
|
13
|
+
* This file is only I/O: read stdin, gather the git context, print the
|
|
14
|
+
* decision. The decision lives in lib/worker-guard.ts and is tested there
|
|
15
|
+
* and through this entry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { renderDecision } from "../lib/sleep-poll-gate.js";
|
|
23
|
+
import { decideWorkerGuard, type WorkerGuardContext, type WorkerGuardInput } from "../lib/worker-guard.js";
|
|
24
|
+
|
|
25
|
+
function git(cwd: string, args: string[]): string | null {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildContext(cwd: string): WorkerGuardContext {
|
|
34
|
+
const worktreeRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
35
|
+
const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
|
|
36
|
+
const common = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
37
|
+
const inWorktree = !!gitDir && !!common && gitDir !== common;
|
|
38
|
+
return {
|
|
39
|
+
cwd,
|
|
40
|
+
home: homedir(),
|
|
41
|
+
worktreeRoot,
|
|
42
|
+
inWorktree,
|
|
43
|
+
mainCheckout: inWorktree && common ? dirname(common) : null,
|
|
44
|
+
testScript: () => {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(worktreeRoot ?? cwd, "package.json"), "utf8"));
|
|
47
|
+
return typeof pkg?.scripts?.test === "string" ? pkg.scripts.test : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main(): Promise<void> {
|
|
56
|
+
let text = "";
|
|
57
|
+
try {
|
|
58
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!text.trim()) return;
|
|
63
|
+
|
|
64
|
+
let input: WorkerGuardInput;
|
|
65
|
+
try {
|
|
66
|
+
input = JSON.parse(text) as WorkerGuardInput;
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const decision = decideWorkerGuard(input, process.env, () => buildContext(input.cwd || process.cwd()));
|
|
72
|
+
if (decision.decision === "deny") process.stdout.write(renderDecision(decision) + "\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch(() => process.exit(0));
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* - Sending ntfy.sh notifications
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { readWorkersSection } from '../../../workers/config.js';
|
|
20
|
+
import { workersLogDir } from '../../../workers/paths.js';
|
|
21
|
+
import { pendingResults } from '../../../workers/pending.js';
|
|
19
22
|
import { isWorkerSession } from "../lib/worker-session.js";
|
|
20
23
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
21
24
|
import { join, basename, dirname, resolve } from 'path';
|
|
@@ -492,6 +495,26 @@ ${paiProjectBlock ? `\n${paiProjectBlock}` : ''}
|
|
|
492
495
|
// Output to stdout for Claude to receive
|
|
493
496
|
console.log(reminder);
|
|
494
497
|
|
|
498
|
+
// Unmerged finished worker results in this repo: one line, none when zero
|
|
499
|
+
try {
|
|
500
|
+
const { workers } = readWorkersSection();
|
|
501
|
+
const pending = pendingResults(workersLogDir(workers), cwd);
|
|
502
|
+
if (pending.length) {
|
|
503
|
+
console.log(`${pending.length} finished worker result(s) not merged in this repo: ${pending.map((r) => r.id).join(', ')} — run \`pai worker pending\``);
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
// no workers config or not a git repo: stay silent
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// An MCP/tools pin silently drops servers; say so. Nothing printed when unpinned.
|
|
510
|
+
try {
|
|
511
|
+
const { execFileSync: run } = await import('child_process');
|
|
512
|
+
const pins = run(paiBin, ['project', 'pins'], { encoding: 'utf-8', cwd }).trim();
|
|
513
|
+
if (pins) console.log(pins);
|
|
514
|
+
} catch {
|
|
515
|
+
// pai unavailable: stay silent
|
|
516
|
+
}
|
|
517
|
+
|
|
495
518
|
// 8.5. INJECT THE PAUSE CHECKPOINT
|
|
496
519
|
//
|
|
497
520
|
// `pai pause` writes the handover to TODO.md under `## Continue`. Until now
|
package/dist/daemon-MoFF4oeg.mjs
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import "./utils-C9HsYDpO.mjs";
|
|
2
|
-
import "./migrate-BD7D8EEh.mjs";
|
|
3
|
-
import "./factory-EUUhSSoq.mjs";
|
|
4
|
-
import "./wakeup-C48hcN6O.mjs";
|
|
5
|
-
import "./context-handover-cache-BdtJZpgv.mjs";
|
|
6
|
-
import "./config-CQCmH_gV.mjs";
|
|
7
|
-
import "./run-env-BE5xyCwy.mjs";
|
|
8
|
-
import "./run-CxxZKEZw.mjs";
|
|
9
|
-
import "./server-BY6AedmC.mjs";
|
|
10
|
-
import "./state-n13uPK5B.mjs";
|
|
11
|
-
import "./aibroker-client-BK1h--W_.mjs";
|
|
12
|
-
import "./session-keepalive-urk--pfa.mjs";
|
|
13
|
-
import "./embeddings-DfrYNIng.mjs";
|
|
14
|
-
import { t as serve } from "./daemon-DSsIEqzW.mjs";
|
|
15
|
-
import "./router-DjFZIoDl.mjs";
|
|
16
|
-
import "./tools-qXyHFwNj.mjs";
|
|
17
|
-
import "./work-queue-worker-DUvPiwGO.mjs";
|
|
18
|
-
|
|
19
|
-
export { serve };
|