@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
package/docs/commands/project.md
CHANGED
|
@@ -39,6 +39,7 @@ pai project <subcommand> [options]
|
|
|
39
39
|
| [`pai project names`](#pai-project-names) | List named projects (your curated shortlist) |
|
|
40
40
|
| [`pai project config [identifier]`](#pai-project-config-identifier) | View or modify session launch config for a project. |
|
|
41
41
|
| [`pai project mcp [names...]`](#pai-project-mcp-names) | View or set the MCP servers this project's interactive supervisor session |
|
|
42
|
+
| [`pai project pins`](#pai-project-pins) | Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned) |
|
|
42
43
|
| [`pai project tools [names...]`](#pai-project-tools-names) | View or set the built-in tool schemas this project's interactive |
|
|
43
44
|
|
|
44
45
|
### pai project list
|
|
@@ -405,9 +406,9 @@ Use --options to discover available keys and presets.
|
|
|
405
406
|
View or set the MCP servers this project's interactive supervisor session
|
|
406
407
|
|
|
407
408
|
loads (run from inside the project, like `pai projects here`).
|
|
408
|
-
No
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
No args: print the current value. --set <names> (comma-separated): set it,
|
|
410
|
+
validated against ~/.claude.json's mcpServers. --clear: unset (all
|
|
411
|
+
servers load — today's default). Positional names are an error.
|
|
411
412
|
|
|
412
413
|
**Arguments**
|
|
413
414
|
|
|
@@ -419,16 +420,23 @@ args): set it, validated against ~/.claude.json's mcpServers.
|
|
|
419
420
|
|
|
420
421
|
| Option | Description | Default |
|
|
421
422
|
|--------|-------------|---------|
|
|
423
|
+
| `--set <names>` | Set the pin (comma-separated server or set names) | |
|
|
422
424
|
| `--clear` | Unset — all MCP servers load again | |
|
|
423
425
|
|
|
424
426
|
|
|
427
|
+
### pai project pins
|
|
428
|
+
|
|
429
|
+
Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned)
|
|
430
|
+
|
|
431
|
+
|
|
425
432
|
### pai project tools [names...]
|
|
426
433
|
|
|
427
434
|
View or set the built-in tool schemas this project's interactive
|
|
428
435
|
|
|
429
436
|
supervisor session loads (run from inside the project, like `pai projects
|
|
430
|
-
here`). No
|
|
431
|
-
|
|
437
|
+
here`). No args: print the current value. --set <names> (comma-separated):
|
|
438
|
+
set it. --clear: unset (all tools load — today's default). Positional
|
|
439
|
+
names are an error.
|
|
432
440
|
|
|
433
441
|
**Arguments**
|
|
434
442
|
|
|
@@ -440,6 +448,7 @@ repeated args): set it. --clear: unset (all tools load — today's default).
|
|
|
440
448
|
|
|
441
449
|
| Option | Description | Default |
|
|
442
450
|
|--------|-------------|---------|
|
|
451
|
+
| `--set <names>` | Set the pin (comma-separated tool names) | |
|
|
443
452
|
| `--clear` | Unset — all built-in tools load again | |
|
|
444
453
|
|
|
445
454
|
|
|
@@ -39,6 +39,7 @@ pai projects <subcommand> [options]
|
|
|
39
39
|
| [`pai projects names`](#pai-projects-names) | List named projects (your curated shortlist) |
|
|
40
40
|
| [`pai projects config [identifier]`](#pai-projects-config-identifier) | View or modify session launch config for a project. |
|
|
41
41
|
| [`pai projects mcp [names...]`](#pai-projects-mcp-names) | View or set the MCP servers this project's interactive supervisor session |
|
|
42
|
+
| [`pai projects pins`](#pai-projects-pins) | Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned) |
|
|
42
43
|
| [`pai projects tools [names...]`](#pai-projects-tools-names) | View or set the built-in tool schemas this project's interactive |
|
|
43
44
|
|
|
44
45
|
### pai projects list
|
|
@@ -405,9 +406,9 @@ Use --options to discover available keys and presets.
|
|
|
405
406
|
View or set the MCP servers this project's interactive supervisor session
|
|
406
407
|
|
|
407
408
|
loads (run from inside the project, like `pai projects here`).
|
|
408
|
-
No
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
No args: print the current value. --set <names> (comma-separated): set it,
|
|
410
|
+
validated against ~/.claude.json's mcpServers. --clear: unset (all
|
|
411
|
+
servers load — today's default). Positional names are an error.
|
|
411
412
|
|
|
412
413
|
**Arguments**
|
|
413
414
|
|
|
@@ -419,16 +420,23 @@ args): set it, validated against ~/.claude.json's mcpServers.
|
|
|
419
420
|
|
|
420
421
|
| Option | Description | Default |
|
|
421
422
|
|--------|-------------|---------|
|
|
423
|
+
| `--set <names>` | Set the pin (comma-separated server or set names) | |
|
|
422
424
|
| `--clear` | Unset — all MCP servers load again | |
|
|
423
425
|
|
|
424
426
|
|
|
427
|
+
### pai projects pins
|
|
428
|
+
|
|
429
|
+
Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned)
|
|
430
|
+
|
|
431
|
+
|
|
425
432
|
### pai projects tools [names...]
|
|
426
433
|
|
|
427
434
|
View or set the built-in tool schemas this project's interactive
|
|
428
435
|
|
|
429
436
|
supervisor session loads (run from inside the project, like `pai projects
|
|
430
|
-
here`). No
|
|
431
|
-
|
|
437
|
+
here`). No args: print the current value. --set <names> (comma-separated):
|
|
438
|
+
set it. --clear: unset (all tools load — today's default). Positional
|
|
439
|
+
names are an error.
|
|
432
440
|
|
|
433
441
|
**Arguments**
|
|
434
442
|
|
|
@@ -440,6 +448,7 @@ repeated args): set it. --clear: unset (all tools load — today's default).
|
|
|
440
448
|
|
|
441
449
|
| Option | Description | Default |
|
|
442
450
|
|--------|-------------|---------|
|
|
451
|
+
| `--set <names>` | Set the pin (comma-separated tool names) | |
|
|
443
452
|
| `--clear` | Unset — all built-in tools load again | |
|
|
444
453
|
|
|
445
454
|
|
package/docs/commands/worker.md
CHANGED
|
@@ -20,6 +20,7 @@ pai worker <subcommand> [options]
|
|
|
20
20
|
|---------|-------------|
|
|
21
21
|
| [`pai worker run [args...]`](#pai-worker-run-args) | Run one claude-code worker through the configured provider. |
|
|
22
22
|
| [`pai worker ps`](#pai-worker-ps) | List workers of this session (RUNNING + FINISHED); --all for every worker |
|
|
23
|
+
| [`pai worker pending`](#pai-worker-pending) | Finished workers whose branch or worktree holds work not yet merged into this repo |
|
|
23
24
|
| [`pai worker follow [id]`](#pai-worker-follow-id) | Live transcript of one worker, or of this session's running workers |
|
|
24
25
|
| [`pai worker replay <id>`](#pai-worker-replay-id) | Print the transcript of one finished or running worker |
|
|
25
26
|
| [`pai worker watch`](#pai-worker-watch) | ps refreshed every 2 seconds (plain `watch`, colors kept) |
|
|
@@ -31,6 +32,7 @@ pai worker <subcommand> [options]
|
|
|
31
32
|
| [`pai worker merge <id>`](#pai-worker-merge-id) | Merge a worker's worktree branch (worker/<id>) into the original checkout, then remove the worktree and delete the branch |
|
|
32
33
|
| [`pai worker wait <ids...>`](#pai-worker-wait-ids) | Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout |
|
|
33
34
|
| [`pai worker discard <id>`](#pai-worker-discard-id) | Drop a worker's worktree and branch, keeping nothing |
|
|
35
|
+
| [`pai worker gc`](#pai-worker-gc) | Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept) |
|
|
34
36
|
| [`pai worker kill <id>`](#pai-worker-kill-id) | Send SIGTERM to a running worker process |
|
|
35
37
|
| [`pai worker controls <id> <who>`](#pai-worker-controls-id-who) | Hand the desktop controls (clickr) to a worker or take them back. |
|
|
36
38
|
| [`pai worker resume <id> <text>`](#pai-worker-resume-id-text) | Continue a finished worker on the same provider: claude --resume <session> |
|
|
@@ -107,6 +109,19 @@ List workers of this session (RUNNING + FINISHED); --all for every worker
|
|
|
107
109
|
| `--all` | Show workers of all sessions, not just this terminal's | |
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
### pai worker pending
|
|
113
|
+
|
|
114
|
+
Finished workers whose branch or worktree holds work not yet merged into this repo
|
|
115
|
+
|
|
116
|
+
**Options**
|
|
117
|
+
|
|
118
|
+
| Option | Description | Default |
|
|
119
|
+
|--------|-------------|---------|
|
|
120
|
+
| `--all` | Every repo in the log dir, not just the current one | |
|
|
121
|
+
| `--json` | Machine-readable output | |
|
|
122
|
+
| `--gate` | Release gate: exit 1 with the list unless PAI_ALLOW_PENDING=1 | |
|
|
123
|
+
|
|
124
|
+
|
|
110
125
|
### pai worker follow [id]
|
|
111
126
|
|
|
112
127
|
Live transcript of one worker, or of this session's running workers
|
|
@@ -259,6 +274,18 @@ Drop a worker's worktree and branch, keeping nothing
|
|
|
259
274
|
| `<id>` | required |
|
|
260
275
|
|
|
261
276
|
|
|
277
|
+
### pai worker gc
|
|
278
|
+
|
|
279
|
+
Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept)
|
|
280
|
+
|
|
281
|
+
**Options**
|
|
282
|
+
|
|
283
|
+
| Option | Description | Default |
|
|
284
|
+
|--------|-------------|---------|
|
|
285
|
+
| `--older-than <hours>` | minimum age of a finished worker | `24` |
|
|
286
|
+
| `--dry-run` | print what would be archived, change nothing | |
|
|
287
|
+
|
|
288
|
+
|
|
262
289
|
### pai worker kill <id>
|
|
263
290
|
|
|
264
291
|
Send SIGTERM to a running worker process
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekmidian/pai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"dev": "tsdown --watch",
|
|
62
62
|
"test": "vitest",
|
|
63
63
|
"lint": "tsc --noEmit",
|
|
64
|
-
"prepublishOnly": "bun run build"
|
|
64
|
+
"prepublishOnly": "bun run build && node dist/cli/index.mjs worker pending --gate"
|
|
65
65
|
},
|
|
66
66
|
"author": "Matthias Nott",
|
|
67
67
|
"license": "MIT",
|
package/scripts/build-hooks.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from "fs";
|
|
32
32
|
import { join, resolve, basename } from "path";
|
|
33
33
|
import { homedir, platform } from "os";
|
|
34
|
-
import {
|
|
34
|
+
import { syncSkipReason } from "./lib/sync-guard.mjs";
|
|
35
35
|
|
|
36
36
|
const HOOKS_SRC = "src/hooks/ts";
|
|
37
37
|
const HOOKS_OUT = "dist/hooks";
|
|
@@ -164,10 +164,11 @@ console.log(
|
|
|
164
164
|
// --sync: Symlink (or copy on Windows) all deployable files to ~/.claude/
|
|
165
165
|
// ---------------------------------------------------------------------------
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
167
|
+
const skipReason = doSync ? syncSkipReason() : null;
|
|
168
|
+
if (skipReason) {
|
|
169
|
+
// Only the canonical install repoints the live ~/.claude symlinks — see
|
|
170
|
+
// scripts/lib/sync-guard.mjs.
|
|
171
|
+
console.log(skipReason.startsWith("skipping") ? skipReason : `✔ Hook symlinks skipped: ${skipReason}`);
|
|
171
172
|
} else if (doSync) {
|
|
172
173
|
const useSymlinks = platform() !== "win32";
|
|
173
174
|
const claudeDir = join(homedir(), ".claude");
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "fs";
|
|
33
33
|
import { join, resolve } from "path";
|
|
34
34
|
import { homedir, platform } from "os";
|
|
35
|
-
import {
|
|
35
|
+
import { syncSkipReason } from "./lib/sync-guard.mjs";
|
|
36
36
|
|
|
37
37
|
const PROMPTS_DIR = "src/daemon-mcp/prompts";
|
|
38
38
|
const CUSTOM_DIR = join(PROMPTS_DIR, "custom");
|
|
@@ -206,10 +206,11 @@ for (const [fileName, dir] of [
|
|
|
206
206
|
const customLabel = customNames.length > 0 ? ` (${builtinNames.length} built-in + ${customNames.length} custom)` : "";
|
|
207
207
|
console.log(`✔ ${generated} skill stubs generated in ${STUBS_OUT}/${customLabel}`);
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
209
|
+
const skipReason = doSync ? syncSkipReason() : null;
|
|
210
|
+
if (skipReason) {
|
|
211
|
+
// Only the canonical install repoints the live ~/.claude/skills symlinks —
|
|
212
|
+
// see scripts/lib/sync-guard.mjs.
|
|
213
|
+
console.log(skipReason.startsWith("skipping") ? skipReason : `✔ Skill symlinks skipped: ${skipReason}`);
|
|
213
214
|
} else if (doSync) {
|
|
214
215
|
syncSymlinks(generatedDirNames);
|
|
215
216
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { decideWorkerGuard, parseCommand, type WorkerGuardContext } from "./worker-guard.js";
|
|
7
|
+
|
|
8
|
+
const WT = "/work/wt/abc";
|
|
9
|
+
const MAIN = "/work/main";
|
|
10
|
+
const HOME = "/home/u";
|
|
11
|
+
|
|
12
|
+
const wtCtx = (over: Partial<WorkerGuardContext> = {}): WorkerGuardContext => ({
|
|
13
|
+
cwd: WT,
|
|
14
|
+
home: HOME,
|
|
15
|
+
worktreeRoot: WT,
|
|
16
|
+
inWorktree: true,
|
|
17
|
+
mainCheckout: MAIN,
|
|
18
|
+
testScript: () => "vitest",
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
const sharedCtx = wtCtx({ cwd: MAIN, worktreeRoot: MAIN, inWorktree: false, mainCheckout: null });
|
|
22
|
+
|
|
23
|
+
const WORKER = { PAI_WORKER: "1" };
|
|
24
|
+
const bash = (command: string) => ({ tool_name: "Bash", tool_input: { command } });
|
|
25
|
+
const edit = (tool_name: string, file_path: string) => ({ tool_name, tool_input: { file_path } });
|
|
26
|
+
|
|
27
|
+
const DENY: [string, ReturnType<typeof bash>, WorkerGuardContext, string][] = [
|
|
28
|
+
["ln -s into main node_modules", bash(`ln -s ${MAIN}/node_modules node_modules`), wtCtx(), "runner provisions node_modules"],
|
|
29
|
+
["ln -sf relative escape", bash("ln -sf ../../../main/node_modules node_modules"), wtCtx(), "runner provisions node_modules"],
|
|
30
|
+
["ln -s after cd &&", bash("cd /tmp && ln -s /elsewhere/x y"), wtCtx(), "runner provisions node_modules"],
|
|
31
|
+
["cp -R worktree to /tmp", bash(`cp -R ${WT} /tmp/build`), wtCtx(), "never from a copy"],
|
|
32
|
+
["cp -r . to /tmp", bash("cp -r . /tmp/build"), wtCtx(), "never from a copy"],
|
|
33
|
+
["rsync -a . elsewhere", bash("rsync -a ./ /tmp/build/"), wtCtx(), "never from a copy"],
|
|
34
|
+
["rsync main checkout", bash(`rsync -av ${MAIN}/ /tmp/b`), wtCtx(), "never from a copy"],
|
|
35
|
+
["git push", bash("git push origin HEAD"), wtCtx(), "git push blocked"],
|
|
36
|
+
["push after &&", bash("git commit -m x && git push"), wtCtx(), "git push blocked"],
|
|
37
|
+
["env-prefixed push", bash("GIT_SSH=x git -C . push"), wtCtx(), "git push blocked"],
|
|
38
|
+
["npm publish", bash("npm publish --access public"), wtCtx(), "publish blocked"],
|
|
39
|
+
["bun publish", bash("bun publish"), wtCtx(), "publish blocked"],
|
|
40
|
+
["npm version bump", bash("npm version patch"), wtCtx(), "npm version blocked"],
|
|
41
|
+
["git tag create", bash("git tag v1.0.0"), wtCtx(), "git tag blocked"],
|
|
42
|
+
["git stash in shared checkout", bash("git stash"), sharedCtx, "shared checkout"],
|
|
43
|
+
["git reset --hard in shared checkout", bash("git reset --hard HEAD"), sharedCtx, "shared checkout"],
|
|
44
|
+
["git checkout -- . in shared checkout", bash("git checkout -- ."), sharedCtx, "shared checkout"],
|
|
45
|
+
["git restore . in shared checkout", bash("git restore ."), sharedCtx, "shared checkout"],
|
|
46
|
+
["git clean -fd in shared checkout", bash("git clean -fd"), sharedCtx, "shared checkout"],
|
|
47
|
+
["git switch in shared checkout", bash("git switch other"), sharedCtx, "shared checkout"],
|
|
48
|
+
["git checkout branch in shared checkout", bash("git checkout other"), sharedCtx, "shared checkout"],
|
|
49
|
+
["TODO.md via redirect", bash("echo x >> Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
|
|
50
|
+
["TODO.md via tee", bash("echo x | tee Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
|
|
51
|
+
["TODO.md via sed -i", bash("sed -i '' s/a/b/ Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
|
|
52
|
+
["bun test on vitest repo", bash("cd sub && bun test"), wtCtx(), "vitest"],
|
|
53
|
+
["bash -c wrapper", bash(`bash -c 'git push'`), wtCtx(), "git push blocked"],
|
|
54
|
+
["cd into main checkout", bash(`cd ${MAIN}/src`), wtCtx(), "relative to your worktree"],
|
|
55
|
+
["cd main then work", bash(`cd ${MAIN} && ls`), wtCtx(), "relative to your worktree"],
|
|
56
|
+
["redirect to claude.json", bash(`echo {} > ${HOME}/.claude.json`), wtCtx(), "temp-dir copies"],
|
|
57
|
+
["tilde settings.json sed -i", bash("sed -i s/a/b/ ~/.claude/settings.json"), wtCtx(), "temp-dir copies"],
|
|
58
|
+
["cp onto pai yaml", bash("cp /tmp/x.yaml ~/.claude/pai/config.yaml"), wtCtx(), "temp-dir copies"],
|
|
59
|
+
["mv pai json", bash("mv /tmp/x ~/.claude/pai/config.json"), wtCtx(), "temp-dir copies"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const ALLOW: [string, ReturnType<typeof bash>, WorkerGuardContext][] = [
|
|
63
|
+
["ln -s inside worktree", bash("ln -s ../node_modules/x lib/x"), wtCtx()],
|
|
64
|
+
["ln -s absolute inside worktree", bash(`ln -s ${WT}/a b`), wtCtx()],
|
|
65
|
+
["cp -R within worktree", bash("cp -R src /work/wt/abc/tmp/src2"), wtCtx()],
|
|
66
|
+
["cp -r subdir to /tmp", bash("cp -r src /tmp/src"), wtCtx()],
|
|
67
|
+
["git status/diff/log", bash("git status && git diff --stat | head && git log -3"), wtCtx()],
|
|
68
|
+
["git add + commit", bash(`git add -A && git commit -m "fix: mention git push and git stash"`), wtCtx()],
|
|
69
|
+
["git reset in worktree", bash("git reset --hard HEAD~1"), wtCtx()],
|
|
70
|
+
["git stash list in shared checkout", bash("git stash list"), sharedCtx],
|
|
71
|
+
["git tag listing", bash("git tag -l 'v*'"), wtCtx()],
|
|
72
|
+
["npm version (read)", bash("npm version"), wtCtx()],
|
|
73
|
+
["npm run build", bash("npm run build"), wtCtx()],
|
|
74
|
+
["bun test on non-vitest repo", bash("bun test"), wtCtx({ testScript: () => "bun test" })],
|
|
75
|
+
["cd inside worktree", bash("cd src && ls"), wtCtx()],
|
|
76
|
+
["cat TODO.md", bash("cat Notes/TODO.md"), wtCtx()],
|
|
77
|
+
["read live config", bash("cat ~/.claude/settings.json ~/.claude.json | head"), wtCtx()],
|
|
78
|
+
["write other Notes file", bash("echo x > Notes/other.md"), wtCtx()],
|
|
79
|
+
["write deep pai file", bash("echo x > ~/.claude/pai/logs/x.json"), wtCtx()],
|
|
80
|
+
["stderr dup redirect", bash("npm test 2>&1 | tail"), wtCtx()],
|
|
81
|
+
["git checkout -b in worktree", bash("git checkout -b topic"), wtCtx()],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
describe("decideWorkerGuard", () => {
|
|
85
|
+
it.each(DENY)("denies: %s", (_n, input, ctx, needle) => {
|
|
86
|
+
const d = decideWorkerGuard(input, WORKER, () => ctx);
|
|
87
|
+
expect(d.decision).toBe("deny");
|
|
88
|
+
expect((d as { reason: string }).reason).toContain(needle);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each(ALLOW)("allows: %s", (_n, input, ctx) => {
|
|
92
|
+
expect(decideWorkerGuard(input, WORKER, () => ctx).decision).toBe("allow");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("is a no-op when PAI_WORKER is unset", () => {
|
|
96
|
+
for (const [, input, ctx] of DENY) {
|
|
97
|
+
expect(decideWorkerGuard(input, {}, () => ctx).decision).toBe("allow");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each([
|
|
102
|
+
["Write", `${WT}/Notes/TODO.md`, "deny"],
|
|
103
|
+
["Edit", "Notes/TODO.md", "deny"],
|
|
104
|
+
["MultiEdit", `${MAIN}/Notes/TODO.md`, "deny"],
|
|
105
|
+
["Write", `${HOME}/.claude/settings.json`, "deny"],
|
|
106
|
+
["Edit", `${HOME}/.claude.json`, "deny"],
|
|
107
|
+
["Write", `${HOME}/.claude/pai/config.yaml`, "deny"],
|
|
108
|
+
["Write", `${WT}/Notes/other.md`, "allow"],
|
|
109
|
+
["Edit", `${WT}/src/a.ts`, "allow"],
|
|
110
|
+
["Write", `${HOME}/.claude/pai/logs/x.yaml`, "allow"],
|
|
111
|
+
])("%s %s -> %s", (tool, path, want) => {
|
|
112
|
+
expect(decideWorkerGuard(edit(tool, path), WORKER, () => wtCtx()).decision).toBe(want);
|
|
113
|
+
expect(decideWorkerGuard(edit(tool, path), {}, () => wtCtx()).decision).toBe("allow");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ignores other tools", () => {
|
|
117
|
+
expect(decideWorkerGuard({ tool_name: "Read", tool_input: { file_path: "Notes/TODO.md" } }, WORKER, () => wtCtx()).decision).toBe("allow");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("commit attribution", () => {
|
|
122
|
+
const TRAILER = "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>";
|
|
123
|
+
const run = (c: string) => decideWorkerGuard(bash(c), WORKER, () => wtCtx());
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "wg-"));
|
|
125
|
+
const bad = join(dir, "bad.txt");
|
|
126
|
+
const good = join(dir, "good.txt");
|
|
127
|
+
writeFileSync(bad, `feat: x\n\n${TRAILER}\n`);
|
|
128
|
+
writeFileSync(good, "feat: x\n");
|
|
129
|
+
|
|
130
|
+
it.each([
|
|
131
|
+
["-m trailer", `git commit -m "feat: x" -m "${TRAILER}"`],
|
|
132
|
+
["heredoc trailer", `git commit -m "$(cat <<'EOF'\nfeat: x\n\n${TRAILER}\nEOF\n)"`],
|
|
133
|
+
["generated-with", `git commit -m "feat: x 🤖 Generated with [Claude Code](https://claude.com/claude-code)"`],
|
|
134
|
+
["-F file trailer", `git commit -F ${bad}`],
|
|
135
|
+
["--file= trailer", `git commit --file=${bad}`],
|
|
136
|
+
])("denies %s", (_n, c) => {
|
|
137
|
+
const d = run(c);
|
|
138
|
+
expect(d.decision).toBe("deny");
|
|
139
|
+
expect((d as { reason: string }).reason).toContain("no AI attribution in commits");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
["plain commit", 'git commit -m "feat: add thing"'],
|
|
144
|
+
["prose mentioning claude", 'git commit -m "fix: handle claude output parsing"'],
|
|
145
|
+
["-F clean file", `git commit -F ${good}`],
|
|
146
|
+
["-F missing file", `git commit -F ${dir}/nope.txt`],
|
|
147
|
+
])("allows %s", (_n, c) => {
|
|
148
|
+
expect(run(c).decision).toBe("allow");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("parseCommand", () => {
|
|
153
|
+
it("splits operators outside quotes and captures redirects", () => {
|
|
154
|
+
expect(parseCommand(`a 'b && c'; d > f 2>&1 || e | g`)).toEqual([
|
|
155
|
+
{ words: ["a", "b && c"], redirects: [] },
|
|
156
|
+
{ words: ["d"], redirects: ["f"] },
|
|
157
|
+
{ words: ["e"], redirects: [] },
|
|
158
|
+
{ words: ["g"], redirects: [] },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("worker-guard entry", () => {
|
|
164
|
+
const ENTRYPOINT = "src/hooks/ts/pre-tool-use/worker-guard.ts";
|
|
165
|
+
const run = (env: NodeJS.ProcessEnv, command: string): string => {
|
|
166
|
+
const { PAI_WORKER: _drop, ...base } = process.env;
|
|
167
|
+
return execFileSync("bun", [ENTRYPOINT], {
|
|
168
|
+
input: JSON.stringify({ tool_name: "Bash", cwd: process.cwd(), tool_input: { command } }),
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
timeout: 15_000,
|
|
171
|
+
env: { ...base, ...env },
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
it("denies git push for a worker", () => {
|
|
176
|
+
const out = JSON.parse(run({ PAI_WORKER: "1" }, "git push")).hookSpecificOutput;
|
|
177
|
+
expect(out.permissionDecision).toBe("deny");
|
|
178
|
+
expect(out.permissionDecisionReason).toContain("git push blocked");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("prints nothing in an interactive session", () => {
|
|
182
|
+
expect(run({}, "git push")).toBe("");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("prints nothing for an allowed worker command", () => {
|
|
186
|
+
expect(run({ PAI_WORKER: "1" }, "git status")).toBe("");
|
|
187
|
+
});
|
|
188
|
+
});
|