portable-agent-layer 0.63.0 → 0.63.2
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/assets/templates/hooks.copilot.json +12 -6
- package/assets/templates/hooks.vscode.json +37 -0
- package/package.json +1 -1
- package/src/cli/index.ts +84 -26
- package/src/hooks/SecurityValidator.ts +83 -32
- package/src/hooks/SkillGuard.ts +8 -12
- package/src/hooks/StopOrchestrator.ts +7 -4
- package/src/hooks/lib/agent.ts +112 -8
- package/src/hooks/lib/security.ts +90 -0
- package/src/hooks/lib/transcript.ts +37 -18
- package/src/targets/claude/install.ts +3 -21
- package/src/targets/codex/install.ts +2 -17
- package/src/targets/copilot/install.ts +13 -32
- package/src/targets/copilot/uninstall.ts +21 -22
- package/src/targets/cursor/install.ts +2 -30
- package/src/targets/cursor/uninstall.ts +4 -16
- package/src/targets/lib.ts +57 -17
- package/src/targets/opencode/install.ts +4 -31
|
@@ -4,33 +4,39 @@
|
|
|
4
4
|
"sessionStart": [
|
|
5
5
|
{
|
|
6
6
|
"type": "command",
|
|
7
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
|
|
7
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts",
|
|
8
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
|
|
8
9
|
}
|
|
9
10
|
],
|
|
10
11
|
"userPromptSubmitted": [
|
|
11
12
|
{
|
|
12
13
|
"type": "command",
|
|
13
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
|
|
14
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts",
|
|
15
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
|
|
14
16
|
}
|
|
15
17
|
],
|
|
16
18
|
"preToolUse": [
|
|
17
19
|
{
|
|
18
20
|
"type": "command",
|
|
19
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
|
|
21
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts",
|
|
22
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
|
|
20
23
|
},
|
|
21
24
|
{
|
|
22
25
|
"type": "command",
|
|
23
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
|
|
26
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts",
|
|
27
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
|
|
24
28
|
},
|
|
25
29
|
{
|
|
26
30
|
"type": "command",
|
|
27
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
|
|
31
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts",
|
|
32
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
|
|
28
33
|
}
|
|
29
34
|
],
|
|
30
35
|
"agentStop": [
|
|
31
36
|
{
|
|
32
37
|
"type": "command",
|
|
33
|
-
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
|
|
38
|
+
"bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts",
|
|
39
|
+
"powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
|
|
34
40
|
}
|
|
35
41
|
]
|
|
36
42
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=vscode"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"UserPromptSubmit": [
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=vscode"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"PreToolUse": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=vscode"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=vscode"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=vscode"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"Stop": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=vscode"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { homedir } from "node:os";
|
|
|
37
37
|
import { resolve } from "node:path";
|
|
38
38
|
import { inference, previewInferenceRoute } from "../hooks/lib/inference";
|
|
39
39
|
import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
|
|
40
|
-
import { palHome, palPkg, platform } from "../hooks/lib/paths";
|
|
40
|
+
import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
|
|
41
41
|
import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
|
|
42
42
|
import { log } from "../targets/lib";
|
|
43
43
|
import { checkPendingMigrations } from "./migrate";
|
|
@@ -80,6 +80,20 @@ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolChec
|
|
|
80
80
|
return { name: cmd, available: false };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Copilot ships as a VS Code extension as well as a CLI, and the extension puts
|
|
85
|
+
* no `copilot` binary on PATH — but both read hooks, skills and agents out of
|
|
86
|
+
* ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
|
|
87
|
+
*/
|
|
88
|
+
function checkCopilot(): ToolCheck {
|
|
89
|
+
const cli = checkTool("copilot", ["version"]);
|
|
90
|
+
if (cli.available) return cli;
|
|
91
|
+
if (existsSync(platform.copilotDir())) {
|
|
92
|
+
return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
|
|
93
|
+
}
|
|
94
|
+
return cli;
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
function detectAgent(): string | null {
|
|
84
98
|
if (checkTool("claude").available) return "claude";
|
|
85
99
|
if (checkTool("opencode").available) return "opencode";
|
|
@@ -443,13 +457,18 @@ function checkCopilotInstructionsPresent(): boolean {
|
|
|
443
457
|
|
|
444
458
|
// ── Install integrity (Tier 2 doctor checks) ──
|
|
445
459
|
|
|
446
|
-
/**
|
|
460
|
+
/** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
|
|
461
|
+
function isHookCommandField(key: string): boolean {
|
|
462
|
+
return key === "command" || key === "bash" || key === "powershell";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Recursively collect every command-carrying field value in a hook-config JSON. */
|
|
447
466
|
function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
|
|
448
467
|
if (Array.isArray(obj)) {
|
|
449
468
|
for (const item of obj) extractAllHookCommands(item, out);
|
|
450
469
|
} else if (obj && typeof obj === "object") {
|
|
451
470
|
for (const [k, v] of Object.entries(obj)) {
|
|
452
|
-
if ((k
|
|
471
|
+
if (isHookCommandField(k) && typeof v === "string") {
|
|
453
472
|
out.push(v);
|
|
454
473
|
} else {
|
|
455
474
|
extractAllHookCommands(v, out);
|
|
@@ -466,14 +485,27 @@ interface HookPrefixCheck {
|
|
|
466
485
|
firstMissing?: string;
|
|
467
486
|
}
|
|
468
487
|
|
|
469
|
-
/**
|
|
488
|
+
/**
|
|
489
|
+
* True when a hook command names its agent, by env prefix or by argv flag.
|
|
490
|
+
*
|
|
491
|
+
* An env prefix only parses in one shell family, so hook configs whose host
|
|
492
|
+
* shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
|
|
493
|
+
*/
|
|
494
|
+
function declaresAgent(cmd: string, agentName: string): boolean {
|
|
495
|
+
return (
|
|
496
|
+
cmd.startsWith(`PAL_AGENT=${agentName} `) ||
|
|
497
|
+
cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
|
|
498
|
+
cmd.includes(`--agent=${agentName}`)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Verify every command in an installed hook file names `<agent>` as its agent. */
|
|
470
503
|
function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
|
|
471
504
|
if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
|
|
472
505
|
try {
|
|
473
506
|
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
474
507
|
const commands = extractAllHookCommands(data.hooks ?? data);
|
|
475
|
-
const
|
|
476
|
-
const missing = commands.filter((c) => !c.startsWith(prefix));
|
|
508
|
+
const missing = commands.filter((c) => !declaresAgent(c, agentName));
|
|
477
509
|
return {
|
|
478
510
|
ok: commands.length > 0 && missing.length === 0,
|
|
479
511
|
total: commands.length,
|
|
@@ -701,7 +733,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
701
733
|
const claude = checkTool("claude");
|
|
702
734
|
const opencode = checkTool("opencode");
|
|
703
735
|
const cursor = checkTool("cursor");
|
|
704
|
-
const copilot =
|
|
736
|
+
const copilot = checkCopilot();
|
|
705
737
|
const codex = checkTool("codex");
|
|
706
738
|
const rtk = checkTool("rtk");
|
|
707
739
|
const hasAgent =
|
|
@@ -902,12 +934,12 @@ function doctor(silent = false): DoctorResult {
|
|
|
902
934
|
): void => {
|
|
903
935
|
const r = checkAgentHookPrefix(filePath, agentName);
|
|
904
936
|
if (r.ok) {
|
|
905
|
-
ok(`${agentName}:
|
|
937
|
+
ok(`${agentName}: declared on all ${r.total} hook commands`);
|
|
906
938
|
} else if (r.total === 0) {
|
|
907
939
|
fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
|
|
908
940
|
} else {
|
|
909
941
|
fail(
|
|
910
|
-
`${agentName}: ${r.missing}/${r.total} hook commands
|
|
942
|
+
`${agentName}: ${r.missing}/${r.total} hook commands do not declare ${agentName} (run '${installCmd}')`
|
|
911
943
|
);
|
|
912
944
|
if (r.firstMissing) {
|
|
913
945
|
log.warn(` First offender: ${r.firstMissing}…`);
|
|
@@ -1058,6 +1090,10 @@ async function init(args: string[]) {
|
|
|
1058
1090
|
log.info(`Creating PAL home at ${home}`);
|
|
1059
1091
|
mkdirSync(resolve(home, "telos"), { recursive: true });
|
|
1060
1092
|
mkdirSync(resolve(home, "memory"), { recursive: true });
|
|
1093
|
+
// Scaffolded here, not left to generateSkillIndex: that returns early when
|
|
1094
|
+
// ~/.pal/skills is absent, so an init that installs no skills would leave
|
|
1095
|
+
// every writer of memory/state with nowhere to write.
|
|
1096
|
+
mkdirSync(resolve(home, "memory", "state"), { recursive: true });
|
|
1061
1097
|
|
|
1062
1098
|
scaffoldTelos();
|
|
1063
1099
|
|
|
@@ -1066,16 +1102,23 @@ async function init(args: string[]) {
|
|
|
1066
1102
|
await install(targets);
|
|
1067
1103
|
}
|
|
1068
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* Run a setup subprocess, showing its output only when it fails.
|
|
1107
|
+
*
|
|
1108
|
+
* These are idempotent and usually report "no changes", so their banners are
|
|
1109
|
+
* pure noise on a re-install — but the moment one fails, the reason it gives
|
|
1110
|
+
* is the only thing that explains the warning.
|
|
1111
|
+
*/
|
|
1112
|
+
function runQuietly(cmd: string, args: string[], cwd: string): number | null {
|
|
1113
|
+
const r = spawnSync(cmd, args, { cwd, encoding: "utf-8", shell: true });
|
|
1114
|
+
if (r.status !== 0) process.stderr.write((r.stdout ?? "") + (r.stderr ?? ""));
|
|
1115
|
+
return r.status;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1069
1118
|
async function install(targets: Targets) {
|
|
1070
1119
|
// Ensure dependencies are installed
|
|
1071
1120
|
const pkg = palPkg();
|
|
1072
|
-
|
|
1073
|
-
const deps = spawnSync("bun", ["install", "--frozen-lockfile"], {
|
|
1074
|
-
cwd: pkg,
|
|
1075
|
-
stdio: "inherit",
|
|
1076
|
-
shell: true,
|
|
1077
|
-
});
|
|
1078
|
-
if (deps.status !== 0) {
|
|
1121
|
+
if (runQuietly("bun", ["install", "--frozen-lockfile"], pkg) !== 0) {
|
|
1079
1122
|
log.warn("bun install failed — continuing anyway, but hooks may not work");
|
|
1080
1123
|
}
|
|
1081
1124
|
|
|
@@ -1084,15 +1127,10 @@ async function install(targets: Targets) {
|
|
|
1084
1127
|
// (used by tests to avoid a ~150MB download on every run).
|
|
1085
1128
|
// Uses `bun x` (not `bunx`) for Windows compatibility — bunx resolves unreliably under cmd.exe.
|
|
1086
1129
|
if (process.env.PAL_SKIP_BROWSER_INSTALL !== "1") {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
cwd: pkg,
|
|
1090
|
-
stdio: "inherit",
|
|
1091
|
-
shell: true,
|
|
1092
|
-
});
|
|
1093
|
-
if (pw.status !== 0) {
|
|
1130
|
+
const pw = runQuietly("bun", ["x", "playwright", "install", "chromium"], pkg);
|
|
1131
|
+
if (pw !== 0) {
|
|
1094
1132
|
log.warn(
|
|
1095
|
-
`playwright install chromium failed (exit ${pw
|
|
1133
|
+
`playwright install chromium failed (exit ${pw}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
|
|
1096
1134
|
);
|
|
1097
1135
|
}
|
|
1098
1136
|
}
|
|
@@ -1112,7 +1150,8 @@ async function install(targets: Targets) {
|
|
|
1112
1150
|
}
|
|
1113
1151
|
|
|
1114
1152
|
// Scaffold TELOS + PAL settings, then prompt for missing identity
|
|
1115
|
-
const { scaffoldTelos, scaffoldPalSettings } =
|
|
1153
|
+
const { scaffoldTelos, scaffoldPalSettings, copyPalDocs, generateSkillIndex } =
|
|
1154
|
+
await import("../targets/lib");
|
|
1116
1155
|
const { promptIdentity } = await import("./setup-identity");
|
|
1117
1156
|
const { promptTelos } = await import("./setup-telos");
|
|
1118
1157
|
const { promptAttribution } = await import("./setup-attribution");
|
|
@@ -1122,6 +1161,13 @@ async function install(targets: Targets) {
|
|
|
1122
1161
|
await promptTelos();
|
|
1123
1162
|
await promptAttribution();
|
|
1124
1163
|
|
|
1164
|
+
// Shared, target-independent state. Every target installer used to repeat these
|
|
1165
|
+
// identical calls; AGENTS.md in particular must exist before any target symlinks
|
|
1166
|
+
// to it, so it runs once here rather than once per target.
|
|
1167
|
+
const { regenerateIfNeeded } = await import("../hooks/lib/claude-md");
|
|
1168
|
+
const palDocsCount = copyPalDocs();
|
|
1169
|
+
regenerateIfNeeded();
|
|
1170
|
+
|
|
1125
1171
|
if (targets.claude) {
|
|
1126
1172
|
console.log("━━━ Claude Code ━━━");
|
|
1127
1173
|
await import("../targets/claude/install");
|
|
@@ -1152,6 +1198,16 @@ async function install(targets: Targets) {
|
|
|
1152
1198
|
console.log("");
|
|
1153
1199
|
}
|
|
1154
1200
|
|
|
1201
|
+
// The rest of the shared work reads what the installers just wrote: the index
|
|
1202
|
+
// walks ~/.pal/skills, and the digests land in ~/.cursor/rules and
|
|
1203
|
+
// ~/.copilot/instructions, which are skipped when the agent's home is absent.
|
|
1204
|
+
const { writeContextDigests } = await import("../hooks/handlers/context-digests");
|
|
1205
|
+
const indexedSkills = generateSkillIndex();
|
|
1206
|
+
writeContextDigests();
|
|
1207
|
+
log.success(
|
|
1208
|
+
`Shared: ${indexedSkills} skills indexed · ${palDocsCount} docs → ~/.pal/docs/ · AGENTS.md + context digests written`
|
|
1209
|
+
);
|
|
1210
|
+
|
|
1155
1211
|
log.success("Done. Existing config was preserved — only new entries were added.");
|
|
1156
1212
|
}
|
|
1157
1213
|
|
|
@@ -1377,7 +1433,9 @@ async function update() {
|
|
|
1377
1433
|
function cliDebug(args: string[]) {
|
|
1378
1434
|
const stateDir = resolve(palHome(), "memory", "state");
|
|
1379
1435
|
const flagFile = resolve(stateDir, "debug-enabled");
|
|
1380
|
-
|
|
1436
|
+
// Must match log.ts's logFile() — reporting a different path sends anyone
|
|
1437
|
+
// debugging a hook to an empty file.
|
|
1438
|
+
const logFile = resolve(paths.debug(), "debug.log");
|
|
1381
1439
|
const sub = args[0];
|
|
1382
1440
|
if (sub === "on") {
|
|
1383
1441
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -1,34 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook: PreToolUse — Guards against dangerous commands.
|
|
3
|
-
*
|
|
3
|
+
* Emits the current agent's deny response to block, or exits silently to allow.
|
|
4
4
|
*
|
|
5
5
|
* Fail-open design: if anything goes wrong, the command is allowed through.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { blockResponse } from "./lib/agent";
|
|
8
|
+
import { blockResponse, normalizeToolUse } from "./lib/agent";
|
|
9
|
+
import { logDebug } from "./lib/log";
|
|
9
10
|
import { checkBashCommand, checkFilePath } from "./lib/security";
|
|
10
11
|
import { readStdinJSON } from "./lib/stdin";
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
-
interface ToolUseInput {
|
|
14
|
-
tool_name: string;
|
|
15
|
-
hook_event_name?: string; // Codex includes this in all hook inputs
|
|
16
|
-
tool_input: {
|
|
17
|
-
command?: string;
|
|
18
|
-
file_path?: string;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// beforeShellExecution shape (Cursor only) — flat, no tool_name wrapper
|
|
13
|
+
// beforeShellExecution shape (Cursor only) — flat, no tool-name wrapper
|
|
23
14
|
interface ShellExecInput {
|
|
24
15
|
command: string;
|
|
25
16
|
sandbox?: boolean;
|
|
26
17
|
}
|
|
27
18
|
|
|
28
|
-
type SecurityInput =
|
|
19
|
+
type SecurityInput = Record<string, unknown> | ShellExecInput;
|
|
29
20
|
|
|
30
21
|
function isShellExec(input: SecurityInput): input is ShellExecInput {
|
|
31
|
-
return !("tool_name" in input) && "command" in input;
|
|
22
|
+
return !("tool_name" in input) && !("toolName" in input) && "command" in input;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A name this list misses is a command this hook waves through, so both sets mirror
|
|
26
|
+
// the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
|
|
27
|
+
const SHELL_TOOLS = [
|
|
28
|
+
"bash",
|
|
29
|
+
"shell",
|
|
30
|
+
"powershell",
|
|
31
|
+
"local_shell",
|
|
32
|
+
"runinterminal",
|
|
33
|
+
"run_in_terminal",
|
|
34
|
+
"terminal",
|
|
35
|
+
"execute_command",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const FILE_WRITE_TOOLS = [
|
|
39
|
+
"write",
|
|
40
|
+
"edit",
|
|
41
|
+
"multiedit",
|
|
42
|
+
"write_file",
|
|
43
|
+
"apply_patch",
|
|
44
|
+
"applypatch",
|
|
45
|
+
"create",
|
|
46
|
+
"create_file",
|
|
47
|
+
"createfile",
|
|
48
|
+
"str_replace",
|
|
49
|
+
"str_replace_editor",
|
|
50
|
+
"insert",
|
|
51
|
+
"insert_edit_into_file",
|
|
52
|
+
"replace_string_in_file",
|
|
53
|
+
"multi_replace_string_in_file",
|
|
54
|
+
"replacestring",
|
|
55
|
+
"edit_notebook_file",
|
|
56
|
+
"notebookedit",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
|
|
60
|
+
function firstStringArg(
|
|
61
|
+
args: Record<string, unknown>,
|
|
62
|
+
keys: string[]
|
|
63
|
+
): string | undefined {
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
const value = args[key];
|
|
66
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tool names that run a shell command, across every agent's naming. */
|
|
72
|
+
function runsShellCommand(toolName: string): boolean {
|
|
73
|
+
return SHELL_TOOLS.includes(toolName.toLowerCase());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Tool names that write to a file, across every agent's naming. */
|
|
77
|
+
function writesFile(toolName: string): boolean {
|
|
78
|
+
return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
|
|
32
79
|
}
|
|
33
80
|
|
|
34
81
|
try {
|
|
@@ -44,31 +91,35 @@ try {
|
|
|
44
91
|
process.exit(0);
|
|
45
92
|
}
|
|
46
93
|
|
|
47
|
-
const
|
|
94
|
+
const toolUse = normalizeToolUse(input);
|
|
95
|
+
if (!toolUse) process.exit(0);
|
|
48
96
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
input.tool_name === "Write" ||
|
|
56
|
-
input.tool_name === "Edit" ||
|
|
57
|
-
input.tool_name === "write_file" ||
|
|
58
|
-
input.tool_name === "apply_patch";
|
|
97
|
+
// Each agent names its shell/write tools differently; log the real name so an
|
|
98
|
+
// unrecognized one shows up here instead of silently skipping the check.
|
|
99
|
+
logDebug(
|
|
100
|
+
"SecurityValidator",
|
|
101
|
+
`toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
|
|
102
|
+
);
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
|
|
105
|
+
if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
|
|
106
|
+
const reason = checkBashCommand(command);
|
|
107
|
+
const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
|
|
108
|
+
// "No output" from a downstream tool is indistinguishable between "denied,
|
|
109
|
+
// never ran" and "ran, produced nothing" — logging the verdict here, next
|
|
110
|
+
// to the literal command, is what actually tells the two apart.
|
|
111
|
+
logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
|
|
62
112
|
if (reason) {
|
|
63
|
-
process.stdout.write(blockResponse(`Blocked: ${reason}`, hookEventName));
|
|
113
|
+
process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
|
|
64
114
|
process.exit(0);
|
|
65
115
|
}
|
|
66
116
|
}
|
|
67
117
|
|
|
68
|
-
|
|
69
|
-
|
|
118
|
+
const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
|
|
119
|
+
if (writesFile(toolUse.toolName) && typeof filePath === "string") {
|
|
120
|
+
const reason = checkFilePath(filePath);
|
|
70
121
|
if (reason) {
|
|
71
|
-
process.stdout.write(blockResponse(reason, hookEventName));
|
|
122
|
+
process.stdout.write(blockResponse(reason, toolUse.hookEventName));
|
|
72
123
|
process.exit(0);
|
|
73
124
|
}
|
|
74
125
|
}
|
package/src/hooks/SkillGuard.ts
CHANGED
|
@@ -9,29 +9,25 @@
|
|
|
9
9
|
* Fail-open: on any error, the skill is allowed through.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { blockResponse } from "./lib/agent";
|
|
12
|
+
import { blockResponse, normalizeToolUse } from "./lib/agent";
|
|
13
13
|
import { readStdinJSON } from "./lib/stdin";
|
|
14
14
|
|
|
15
15
|
const BLOCKED_SKILLS = ["keybindings-help"];
|
|
16
16
|
|
|
17
|
-
interface SkillInput {
|
|
18
|
-
tool_name: string;
|
|
19
|
-
tool_input: {
|
|
20
|
-
skill?: string;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
17
|
try {
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
18
|
+
const toolUse = normalizeToolUse(await readStdinJSON());
|
|
19
|
+
if (!toolUse) process.exit(0);
|
|
27
20
|
|
|
28
|
-
const skill = (
|
|
21
|
+
const skill = String(toolUse.toolInput.skill ?? "")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.trim();
|
|
29
24
|
|
|
30
25
|
if (BLOCKED_SKILLS.includes(skill)) {
|
|
31
26
|
process.stdout.write(
|
|
32
27
|
blockResponse(
|
|
33
28
|
'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
|
|
34
|
-
"The user did NOT ask about keybindings. Continue with their ACTUAL request."
|
|
29
|
+
"The user did NOT ask about keybindings. Continue with their ACTUAL request.",
|
|
30
|
+
toolUse.hookEventName
|
|
35
31
|
)
|
|
36
32
|
);
|
|
37
33
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
10
|
-
import { isCodex, isCursor } from "./lib/agent";
|
|
10
|
+
import { blockResponse, isCodex, isCursor } from "./lib/agent";
|
|
11
11
|
import { logError } from "./lib/log";
|
|
12
12
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
13
13
|
import { readStdinJSON } from "./lib/stdin";
|
|
@@ -29,8 +29,10 @@ interface StopHookInput {
|
|
|
29
29
|
|
|
30
30
|
// Check README sync before anything else — may block the session
|
|
31
31
|
try {
|
|
32
|
+
// A block carrying no reason stops the turn without telling the model why, so it
|
|
33
|
+
// is worth less than not blocking at all — require the reason to raise one.
|
|
32
34
|
const decision = checkReadmeSync();
|
|
33
|
-
if (decision.decision === "block") {
|
|
35
|
+
if (decision.decision === "block" && decision.reason) {
|
|
34
36
|
if (isCursor()) {
|
|
35
37
|
// Cursor stop hook: followup_message auto-sends to the agent
|
|
36
38
|
process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
|
|
@@ -38,8 +40,9 @@ try {
|
|
|
38
40
|
// Codex stop hook: additionalContext re-queues as next prompt
|
|
39
41
|
process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
|
|
40
42
|
} else {
|
|
41
|
-
// Claude Code
|
|
42
|
-
|
|
43
|
+
// Claude Code, the Copilot CLI and VS Code's own Copilot each read a
|
|
44
|
+
// different stop-block shape; VS Code ignores the top-level keys entirely.
|
|
45
|
+
process.stdout.write(blockResponse(decision.reason, "Stop"));
|
|
43
46
|
}
|
|
44
47
|
process.exit(0);
|
|
45
48
|
}
|