@panguard-ai/panguard 1.6.0 → 1.7.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +56 -6
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/config.d.ts +6 -1
  6. package/dist/cli/commands/config.d.ts.map +1 -1
  7. package/dist/cli/commands/config.js +39 -23
  8. package/dist/cli/commands/config.js.map +1 -1
  9. package/dist/cli/commands/doctor.d.ts.map +1 -1
  10. package/dist/cli/commands/doctor.js +63 -20
  11. package/dist/cli/commands/doctor.js.map +1 -1
  12. package/dist/cli/commands/guard.d.ts.map +1 -1
  13. package/dist/cli/commands/guard.js +220 -70
  14. package/dist/cli/commands/guard.js.map +1 -1
  15. package/dist/cli/commands/hook.d.ts +115 -0
  16. package/dist/cli/commands/hook.d.ts.map +1 -0
  17. package/dist/cli/commands/hook.js +767 -0
  18. package/dist/cli/commands/hook.js.map +1 -0
  19. package/dist/cli/commands/persist.d.ts +32 -0
  20. package/dist/cli/commands/persist.d.ts.map +1 -0
  21. package/dist/cli/commands/persist.js +104 -0
  22. package/dist/cli/commands/persist.js.map +1 -0
  23. package/dist/cli/commands/scan.d.ts.map +1 -1
  24. package/dist/cli/commands/scan.js +157 -54
  25. package/dist/cli/commands/scan.js.map +1 -1
  26. package/dist/cli/commands/setup.d.ts.map +1 -1
  27. package/dist/cli/commands/setup.js +110 -37
  28. package/dist/cli/commands/setup.js.map +1 -1
  29. package/dist/cli/commands/status.d.ts.map +1 -1
  30. package/dist/cli/commands/status.js +66 -26
  31. package/dist/cli/commands/status.js.map +1 -1
  32. package/dist/cli/commands/up.d.ts.map +1 -1
  33. package/dist/cli/commands/up.js +380 -96
  34. package/dist/cli/commands/up.js.map +1 -1
  35. package/dist/cli/consent.d.ts +26 -6
  36. package/dist/cli/consent.d.ts.map +1 -1
  37. package/dist/cli/consent.js +47 -18
  38. package/dist/cli/consent.js.map +1 -1
  39. package/dist/cli/credentials.d.ts +11 -1
  40. package/dist/cli/credentials.d.ts.map +1 -1
  41. package/dist/cli/credentials.js +6 -1
  42. package/dist/cli/credentials.js.map +1 -1
  43. package/dist/cli/dashboard-url.d.ts +31 -0
  44. package/dist/cli/dashboard-url.d.ts.map +1 -0
  45. package/dist/cli/dashboard-url.js +66 -0
  46. package/dist/cli/dashboard-url.js.map +1 -0
  47. package/dist/cli/first-run.d.ts +35 -0
  48. package/dist/cli/first-run.d.ts.map +1 -0
  49. package/dist/cli/first-run.js +59 -0
  50. package/dist/cli/first-run.js.map +1 -0
  51. package/dist/cli/guard-config.d.ts.map +1 -1
  52. package/dist/cli/guard-config.js +15 -3
  53. package/dist/cli/guard-config.js.map +1 -1
  54. package/dist/cli/index.js +32 -11
  55. package/dist/cli/index.js.map +1 -1
  56. package/dist/cli/interactive/actions/setup.d.ts.map +1 -1
  57. package/dist/cli/interactive/actions/setup.js +2 -10
  58. package/dist/cli/interactive/actions/setup.js.map +1 -1
  59. package/dist/cli/interactive/menu-defs.js +6 -6
  60. package/dist/cli/interactive/render.js +1 -1
  61. package/dist/cli/interactive/render.js.map +1 -1
  62. package/dist/cli/workspace-sync.d.ts +0 -1
  63. package/dist/cli/workspace-sync.d.ts.map +1 -1
  64. package/dist/cli/workspace-sync.js +3 -1
  65. package/dist/cli/workspace-sync.js.map +1 -1
  66. package/dist/init/wizard-runner.d.ts.map +1 -1
  67. package/dist/init/wizard-runner.js +0 -8
  68. package/dist/init/wizard-runner.js.map +1 -1
  69. package/package.json +15 -14
@@ -0,0 +1,767 @@
1
+ /**
2
+ * panguard hook — PreToolUse protection for an agent's BUILT-IN tools, across
3
+ * every platform that exposes a pre-tool-execution hook.
4
+ *
5
+ * The MCP proxy only sees MCP tool servers. An agent's built-in tools (shell,
6
+ * file edit/write, web fetch) bypass it — the most dangerous surface. This
7
+ * command is a per-platform tool-call hook: it reads the tool call on stdin,
8
+ * evaluates the COMMAND CONTENT with the ATR engine, and emits allow / ask /
9
+ * deny in the EXACT contract each host requires (verified per platform — a
10
+ * wrong byte = the host silently ignores the deny = fake protection).
11
+ *
12
+ * Output-format groups (verified 2026-06-16):
13
+ * claude → {hookSpecificOutput:{permissionDecision}} claude-code, continue, codex
14
+ * cursor → {permission, agent_message} cursor
15
+ * gemini → {decision, reason} gemini-cli
16
+ * cline → {cancel, errorMessage} (nested stdin) cline
17
+ * windsurf→ exit 2 + stderr (no JSON) windsurf
18
+ *
19
+ * @module @panguard-ai/panguard/cli/commands/hook
20
+ */
21
+ import { Command } from 'commander';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, rmSync, } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+ import { c } from '@panguard-ai/core';
26
+ import { ProxyEvaluator } from '@panguard-ai/panguard-mcp-proxy/evaluator';
27
+ const PLATFORMS = {
28
+ 'claude-code': { format: 'claude', ask: true },
29
+ continue: { format: 'claude', ask: true },
30
+ codex: { format: 'claude', ask: false },
31
+ cursor: { format: 'cursor', ask: true },
32
+ gemini: { format: 'gemini', ask: true },
33
+ cline: { format: 'cline', ask: false },
34
+ windsurf: { format: 'windsurf', ask: false },
35
+ };
36
+ const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
37
+ /**
38
+ * Extract the actionable COMMAND CONTENT from a tool's arguments, across tool
39
+ * vocabularies. We deliberately evaluate the command/content, NOT the tool name
40
+ * (ATR has tool_name-existence rules that flag a tool literally named
41
+ * "Bash"/"shell" — false-positiving every built-in call).
42
+ */
43
+ function contentFromArgs(toolName, input) {
44
+ const i = input ?? {};
45
+ const t = toolName.toLowerCase();
46
+ // shell
47
+ if (t.includes('bash') || t.includes('shell') || t.includes('command') || t === 'run')
48
+ return str(i['command'] ?? i['cmd'] ?? i['command_line']);
49
+ // file write/edit
50
+ if (t.includes('write') || t.includes('edit') || t.includes('replace') || t.includes('patch'))
51
+ return `${str(i['file_path'] ?? i['path'] ?? i['filePath'])} ${str(i['new_string'] ?? i['content'] ?? i['new_str'])}`.trim();
52
+ if (t.includes('read'))
53
+ return str(i['file_path'] ?? i['path'] ?? i['filePath']);
54
+ if (t.includes('notebook'))
55
+ return str(i['new_source']);
56
+ // web
57
+ if (t.includes('fetch') || t.includes('web'))
58
+ return `${str(i['url'])} ${str(i['prompt'] ?? i['query'])}`.trim();
59
+ try {
60
+ return JSON.stringify(i);
61
+ }
62
+ catch {
63
+ return '';
64
+ }
65
+ }
66
+ /** Built-in tools we evaluate, by the Claude-Code vocabulary (used for the matcher). */
67
+ const EVALUATED_TOOLS = ['Bash', 'Edit', 'MultiEdit', 'Write', 'Read', 'NotebookEdit', 'WebFetch'];
68
+ /** Map any platform's stdin payload to { toolName, content }, or null to abstain. */
69
+ export function normalizeInput(platform, payload) {
70
+ if (platform === 'cline') {
71
+ const p = (payload['preToolUse'] ?? {});
72
+ const toolName = str(p['toolName']);
73
+ if (!toolName)
74
+ return null;
75
+ return { toolName, content: contentFromArgs(toolName, (p['parameters'] ?? {})) };
76
+ }
77
+ if (platform === 'windsurf') {
78
+ const ti = (payload['tool_info'] ?? {});
79
+ const action = str(payload['agent_action_name']);
80
+ const content = str(ti['command_line'] ?? ti['file_path']) || contentFromArgs(action, ti);
81
+ return content ? { toolName: action || 'command', content } : null;
82
+ }
83
+ if (platform === 'cursor') {
84
+ // beforeShellExecution → top-level command; beforeMCPExecution → tool_name + tool_input(string)
85
+ if (payload['command'] != null)
86
+ return { toolName: 'shell', content: str(payload['command']) };
87
+ const toolName = str(payload['tool_name']);
88
+ let input = {};
89
+ const raw = payload['tool_input'];
90
+ if (typeof raw === 'string') {
91
+ try {
92
+ input = JSON.parse(raw);
93
+ }
94
+ catch {
95
+ input = { _raw: raw };
96
+ }
97
+ }
98
+ else if (raw && typeof raw === 'object')
99
+ input = raw;
100
+ if (!toolName && !Object.keys(input).length)
101
+ return null;
102
+ return { toolName: toolName || 'tool', content: contentFromArgs(toolName, input) };
103
+ }
104
+ // claude-code / continue / codex / gemini: top-level tool_name + tool_input
105
+ const toolName = str(payload['tool_name']);
106
+ if (!toolName)
107
+ return null;
108
+ return {
109
+ toolName,
110
+ content: contentFromArgs(toolName, (payload['tool_input'] ?? {})),
111
+ };
112
+ }
113
+ /**
114
+ * Build the exact host response for a verdict. ALLOW = abstain (exit 0, no
115
+ * stdout) so we never bypass the host's own permission flow. ASK downgrades to
116
+ * deny on hosts without an ask verdict (codex/cline/windsurf) — safe, never to
117
+ * allow. Pure + exported for testing.
118
+ */
119
+ export function emitFor(platform, verdict, reason) {
120
+ const spec = PLATFORMS[platform];
121
+ if (verdict === 'allow')
122
+ return { exit: 0 };
123
+ // Downgrade ask→deny where the host has no ask verdict.
124
+ const v = verdict === 'ask' && !spec.ask ? 'deny' : verdict;
125
+ switch (spec.format) {
126
+ case 'claude':
127
+ return {
128
+ exit: 0,
129
+ stdout: JSON.stringify({
130
+ hookSpecificOutput: {
131
+ hookEventName: 'PreToolUse',
132
+ permissionDecision: v,
133
+ permissionDecisionReason: reason,
134
+ },
135
+ }),
136
+ };
137
+ case 'cursor':
138
+ return {
139
+ exit: 0,
140
+ stdout: JSON.stringify({ permission: v, agent_message: reason, user_message: reason }),
141
+ };
142
+ case 'gemini':
143
+ return { exit: 0, stdout: JSON.stringify({ decision: v, reason }) };
144
+ case 'cline':
145
+ // No ask; deny → cancel:true. (ask already downgraded to deny above.)
146
+ return { exit: 0, stdout: JSON.stringify({ cancel: true, errorMessage: reason }) };
147
+ case 'windsurf':
148
+ // No JSON contract: the ONLY non-zero exit we ever emit is exactly 2.
149
+ return { exit: 2, stderr: reason };
150
+ }
151
+ }
152
+ // ── 0-rules fail-open detection (degraded-protection signal) ─────────────────
153
+ /**
154
+ * Where the hook records that it loaded 0 rules on its last run. Lives in the
155
+ * shared guard state dir (~/.panguard-guard) so `pga doctor` and the dashboard
156
+ * can surface "protection degraded" — fail-OPEN is the correct safety choice
157
+ * (a hook with no rules must not brick the agent), but SILENT permanent
158
+ * no-protection is not acceptable. The marker is JSON: { degraded, ruleCount,
159
+ * at } and is cleared the moment a run loads rules again.
160
+ */
161
+ const hookStatusPath = () => join(homedir(), '.panguard-guard', 'hook-protection-status.json');
162
+ /**
163
+ * Has this process already emitted the stderr fail-open warning? The hook is a
164
+ * short-lived per-tool-call process, but a host may reuse it; emit the loud
165
+ * stderr line at most ONCE per process so we are noisy enough to be noticed
166
+ * without spamming every tool call within a single invocation.
167
+ */
168
+ let warnedZeroRules = false;
169
+ /**
170
+ * Record the degraded (0-rules) protection state for doctor/dashboard, and emit
171
+ * a one-time stderr warning. Best-effort: a failure to write the marker (e.g.
172
+ * read-only HOME) must never break the hook — we still allow the tool call.
173
+ */
174
+ function signalZeroRulesFailOpen() {
175
+ if (!warnedZeroRules) {
176
+ warnedZeroRules = true;
177
+ process.stderr.write('[panguard-hook] WARNING: 0 detection rules loaded — built-in-tool protection is ' +
178
+ 'DEGRADED (allowing all tool calls). Run "pga doctor" / "pga guard sync-rules" to ' +
179
+ 'restore protection.\n');
180
+ }
181
+ try {
182
+ const path = hookStatusPath();
183
+ mkdirSync(dirname(path), { recursive: true });
184
+ writeFileSync(path, JSON.stringify({ degraded: true, ruleCount: 0, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
185
+ }
186
+ catch {
187
+ /* best-effort marker; never block the agent on a write failure */
188
+ }
189
+ }
190
+ /**
191
+ * Clear the degraded marker once rules load again (healthy run). Best-effort —
192
+ * we overwrite with a healthy record rather than deleting, so the file stays a
193
+ * stable readable signal of "last hook run was OK". Only writes when a stale
194
+ * degraded marker exists, to avoid touching disk on every healthy tool call.
195
+ */
196
+ function clearZeroRulesFailOpen(ruleCount) {
197
+ try {
198
+ const path = hookStatusPath();
199
+ if (!existsSync(path))
200
+ return;
201
+ const prev = JSON.parse(readFileSync(path, 'utf-8'));
202
+ if (prev.degraded !== true)
203
+ return; // already healthy; nothing to clear
204
+ writeFileSync(path, JSON.stringify({ degraded: false, ruleCount, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
205
+ }
206
+ catch {
207
+ /* best-effort */
208
+ }
209
+ }
210
+ /**
211
+ * Read the last-recorded hook protection status, for `pga doctor` / dashboard.
212
+ * Returns null when no hook has run yet (no marker) or the marker is unreadable.
213
+ * `degraded: true` means the most recent hook run loaded 0 rules and therefore
214
+ * allowed every built-in-tool call (no enforcement).
215
+ */
216
+ export function readHookProtectionStatus() {
217
+ try {
218
+ const path = hookStatusPath();
219
+ if (!existsSync(path))
220
+ return null;
221
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
222
+ return {
223
+ degraded: parsed.degraded === true,
224
+ ruleCount: typeof parsed.ruleCount === 'number' ? parsed.ruleCount : 0,
225
+ at: typeof parsed.at === 'string' ? parsed.at : '',
226
+ };
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ function readStdin() {
233
+ return new Promise((resolve) => {
234
+ let data = '';
235
+ if (process.stdin.isTTY) {
236
+ resolve('');
237
+ return;
238
+ }
239
+ process.stdin.setEncoding('utf-8');
240
+ process.stdin.on('data', (chunk) => {
241
+ data += chunk;
242
+ if (data.length > 1_000_000)
243
+ resolve(data);
244
+ });
245
+ process.stdin.on('end', () => resolve(data));
246
+ process.stdin.on('error', () => resolve(data));
247
+ });
248
+ }
249
+ /**
250
+ * The hook. Reads the tool call, evaluates the COMMAND CONTENT, emits the host's
251
+ * exact verdict. FAIL-SAFE: any error → allow (exit 0) + stderr log — a buggy
252
+ * hook must never brick the agent (the proxy + daemon remain). The windsurf
253
+ * adapter's only non-zero exit is 2 (exit 1 = silent fail-open there).
254
+ */
255
+ export async function runHook(platform) {
256
+ const apply = (e) => {
257
+ if (e.stdout)
258
+ process.stdout.write(e.stdout);
259
+ if (e.stderr)
260
+ process.stderr.write(e.stderr + '\n');
261
+ process.exit(e.exit);
262
+ };
263
+ try {
264
+ const raw = await readStdin();
265
+ const payload = JSON.parse(raw || '{}');
266
+ const norm = normalizeInput(platform, payload);
267
+ if (!norm || !norm.content.trim())
268
+ process.exit(0);
269
+ const evaluator = new ProxyEvaluator();
270
+ const ruleCount = await evaluator.loadRules();
271
+ if (ruleCount <= 0) {
272
+ // Fail-OPEN (correct: never brick the agent) but NOT silent — record the
273
+ // degraded state + warn once on stderr so doctor/dashboard/user can see
274
+ // that protection is currently a no-op, then allow the tool call.
275
+ signalZeroRulesFailOpen();
276
+ process.exit(0);
277
+ }
278
+ // Rules loaded → ensure any prior degraded marker is cleared.
279
+ clearZeroRulesFailOpen(ruleCount);
280
+ // Neutral tool name so tool_name-existence rules never fire on a built-in
281
+ // tool name; only the command content is judged.
282
+ const result = await evaluator.evaluateToolCall('command', { input: norm.content });
283
+ if (result.outcome === 'allow')
284
+ process.exit(0);
285
+ const rules = result.matchedRules?.length
286
+ ? ` [${result.matchedRules.slice(0, 3).join(', ')}]`
287
+ : '';
288
+ const reason = `PanGuard: ${result.reason || 'matched a detection rule'}${rules}`;
289
+ apply(emitFor(platform, result.outcome, reason));
290
+ }
291
+ catch (err) {
292
+ process.stderr.write(`[panguard-hook] error (allowing): ${err instanceof Error ? err.message : String(err)}\n`);
293
+ process.exit(0);
294
+ }
295
+ }
296
+ // ── Registration (per-platform config; idempotent, non-clobbering) ───────────
297
+ const HOOK_CMD = (p) => `pga hook run --platform ${p}`;
298
+ const TOOL_MATCHER = EVALUATED_TOOLS.join('|');
299
+ function readJsonForRoundTrip(path) {
300
+ if (!existsSync(path))
301
+ return { kind: 'absent' };
302
+ let raw;
303
+ try {
304
+ raw = readFileSync(path, 'utf-8');
305
+ }
306
+ catch (err) {
307
+ // File exists but is unreadable (permissions/IO) — treat as corrupt: do not
308
+ // overwrite something we could not even read.
309
+ return { kind: 'corrupt', error: err instanceof Error ? err.message : String(err) };
310
+ }
311
+ // A genuinely empty file is treated as absent-equivalent (safe to start fresh).
312
+ if (raw.trim() === '')
313
+ return { kind: 'absent' };
314
+ try {
315
+ const parsed = JSON.parse(raw);
316
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
317
+ return { kind: 'ok', data: parsed };
318
+ }
319
+ return { kind: 'corrupt', error: 'top-level JSON value is not an object' };
320
+ }
321
+ catch (err) {
322
+ return { kind: 'corrupt', error: err instanceof Error ? err.message : String(err) };
323
+ }
324
+ }
325
+ /**
326
+ * Resolve an existing config for a round-trip write, or signal abort. Returns
327
+ * the object to merge into ({} when the file was genuinely absent), or null when
328
+ * the file exists but could not be parsed — in which case the caller records a
329
+ * clear error and NEVER writes over the file. The corruption reason is captured
330
+ * in `lastInstallError` for the install command to surface.
331
+ */
332
+ function configOrAbort(path, label) {
333
+ const r = readJsonForRoundTrip(path);
334
+ if (r.kind === 'absent')
335
+ return {};
336
+ if (r.kind === 'ok')
337
+ return r.data;
338
+ lastInstallError = `${label} (${path}) exists but is not valid JSON (${r.error}). PanGuard did NOT modify it — fix or back up the file, then re-run "pga hook install".`;
339
+ return null;
340
+ }
341
+ /**
342
+ * Last human-readable reason a platform install returned 'error'. Written by
343
+ * configOrAbort / installFor and read by the install command to tell the user
344
+ * exactly which config could not be parsed (so they can fix it before we ever
345
+ * touch it). Reset at the top of each installFor call.
346
+ */
347
+ let lastInstallError = null;
348
+ /** The reason the most recent installFor() returned 'error', if any. */
349
+ export function lastHookInstallError() {
350
+ return lastInstallError;
351
+ }
352
+ function writeJson(path, data) {
353
+ mkdirSync(dirname(path), { recursive: true });
354
+ const tmp = `${path}.tmp.${process.pid}`;
355
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
356
+ renameSync(tmp, path);
357
+ }
358
+ /**
359
+ * Path to Claude's settings file, resolved lazily so it always reflects the
360
+ * current homedir() (consistent with every other platform writer, which all
361
+ * compute their path inside installFor). A module-level const would freeze the
362
+ * path at import time.
363
+ */
364
+ const claudeSettingsPath = () => join(homedir(), '.claude', 'settings.json');
365
+ /** Is our PreToolUse hook present in a Claude-style settings object? (pure) */
366
+ export function isHookInstalled(settings) {
367
+ const arr = settings.hooks?.PreToolUse ?? [];
368
+ return arr.some((m) => m.hooks?.some((h) => h.command?.startsWith('pga hook run')));
369
+ }
370
+ /** Merge our hook into Claude-style settings without clobbering existing hooks (pure). */
371
+ export function withHookInstalled(settings, cmd = HOOK_CMD('claude-code')) {
372
+ if (isHookInstalled(settings))
373
+ return settings;
374
+ const hooks = { ...(settings.hooks ?? {}) };
375
+ const pre = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
376
+ pre.push({ matcher: TOOL_MATCHER, hooks: [{ type: 'command', command: cmd }] });
377
+ return { ...settings, hooks: { ...hooks, PreToolUse: pre } };
378
+ }
379
+ /** Remove our hook entry, leaving any other PreToolUse hooks intact (pure). */
380
+ export function withHookRemoved(settings) {
381
+ const pre = settings.hooks?.PreToolUse;
382
+ if (!Array.isArray(pre))
383
+ return settings;
384
+ const filtered = pre
385
+ .map((m) => ({
386
+ ...m,
387
+ hooks: (m.hooks ?? []).filter((h) => !h.command?.startsWith('pga hook run')),
388
+ }))
389
+ .filter((m) => m.hooks.length > 0);
390
+ return { ...settings, hooks: { ...settings.hooks, PreToolUse: filtered } };
391
+ }
392
+ /**
393
+ * Register the hook for one platform, writing its exact config. Returns a status.
394
+ *
395
+ * DATA-LOSS SAFETY: every writer that round-trips an EXISTING user config reads
396
+ * it through configOrAbort, which returns null when the file exists but cannot
397
+ * be parsed. On null we abort that platform with 'error' (and a clear message in
398
+ * lastInstallError) and NEVER write — overwriting an unparseable settings.json
399
+ * would wipe the user's permissions/env/model/MCP servers. We only merge-write
400
+ * when the file was successfully parsed (kind 'ok') or genuinely absent
401
+ * (configOrAbort returns {}). Every writer spreads the parsed object first, so
402
+ * all existing top-level keys are preserved.
403
+ */
404
+ export function installFor(platform) {
405
+ lastInstallError = null;
406
+ try {
407
+ switch (platform) {
408
+ case 'claude-code':
409
+ case 'continue': {
410
+ // Continue reads ~/.claude/settings.json too, so the Claude entry covers both.
411
+ const settingsPath = claudeSettingsPath();
412
+ const base = configOrAbort(settingsPath, 'Claude settings');
413
+ if (base === null)
414
+ return 'error';
415
+ const s = base;
416
+ if (isHookInstalled(s))
417
+ return 'already';
418
+ // withHookInstalled spreads `s`, preserving permissions/env/model/MCP keys.
419
+ writeJson(settingsPath, withHookInstalled(s, HOOK_CMD('claude-code')));
420
+ return 'installed';
421
+ }
422
+ case 'codex': {
423
+ const path = join(homedir(), '.codex', 'hooks.json');
424
+ const base = configOrAbort(path, 'Codex hooks');
425
+ if (base === null)
426
+ return 'error';
427
+ const s = base;
428
+ if (isHookInstalled(s))
429
+ return 'already';
430
+ const hooks = { ...(s.hooks ?? {}) };
431
+ const pre = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
432
+ pre.push({
433
+ matcher: 'Bash|apply_patch',
434
+ hooks: [{ type: 'command', command: HOOK_CMD('codex') }],
435
+ });
436
+ writeJson(path, { ...s, hooks: { ...hooks, PreToolUse: pre } });
437
+ return 'installed';
438
+ }
439
+ case 'gemini': {
440
+ const path = join(homedir(), '.gemini', 'settings.json');
441
+ const base = configOrAbort(path, 'Gemini settings');
442
+ if (base === null)
443
+ return 'error';
444
+ const s = base;
445
+ const before = Array.isArray(s.hooks?.BeforeTool)
446
+ ? [...s.hooks.BeforeTool]
447
+ : [];
448
+ if (JSON.stringify(before).includes('pga hook run'))
449
+ return 'already';
450
+ before.push({
451
+ matcher: 'run_shell_command|write_file|replace',
452
+ hooks: [
453
+ { name: 'panguard-atr', type: 'command', command: HOOK_CMD('gemini'), timeout: 10000 },
454
+ ],
455
+ });
456
+ writeJson(path, { ...s, hooks: { ...(s.hooks ?? {}), BeforeTool: before } });
457
+ return 'installed';
458
+ }
459
+ case 'cursor': {
460
+ const path = join(homedir(), '.cursor', 'hooks.json');
461
+ const base = configOrAbort(path, 'Cursor hooks');
462
+ if (base === null)
463
+ return 'error';
464
+ const s = base;
465
+ const hooks = { ...(s.hooks ?? {}) };
466
+ if (JSON.stringify(hooks).includes('pga hook run'))
467
+ return 'already';
468
+ for (const ev of ['beforeShellExecution', 'beforeMCPExecution']) {
469
+ const arr = Array.isArray(hooks[ev]) ? [...hooks[ev]] : [];
470
+ // failClosed must stay FALSE: if the `pga` binary is later removed
471
+ // (uninstall / npm-remove) but this config entry survives, a
472
+ // failClosed:true hook makes Cursor DENY every tool call when it
473
+ // cannot exec the missing command — bricking the agent. Failing OPEN
474
+ // means an orphaned hook degrades to no-protection (loud, recoverable)
475
+ // instead of a hard deny. Our own runHook is already fail-safe and
476
+ // never relies on the host's failClosed flag for enforcement.
477
+ arr.push({ command: HOOK_CMD('cursor'), failClosed: false });
478
+ hooks[ev] = arr;
479
+ }
480
+ writeJson(path, { version: 1, ...s, hooks });
481
+ return 'installed';
482
+ }
483
+ case 'windsurf': {
484
+ const path = join(homedir(), '.codeium', 'windsurf', 'hooks.json');
485
+ const base = configOrAbort(path, 'Windsurf hooks');
486
+ if (base === null)
487
+ return 'error';
488
+ const s = base;
489
+ const hooks = { ...(s.hooks ?? {}) };
490
+ if (JSON.stringify(hooks).includes('pga hook run'))
491
+ return 'already';
492
+ for (const ev of ['pre_run_command', 'pre_write_code', 'pre_mcp_tool_use']) {
493
+ const arr = Array.isArray(hooks[ev]) ? [...hooks[ev]] : [];
494
+ arr.push({ command: HOOK_CMD('windsurf'), show_output: true });
495
+ hooks[ev] = arr;
496
+ }
497
+ writeJson(path, { ...s, hooks });
498
+ return 'installed';
499
+ }
500
+ case 'cline': {
501
+ // Filename-based: an executable named after the event. macOS/Linux only.
502
+ // Not a JSON round-trip — the file is our own script, so there is no user
503
+ // config to clobber; we only skip if it already references our hook.
504
+ const dir = join(homedir(), 'Documents', 'Cline', 'Rules', 'Hooks');
505
+ const file = join(dir, 'PreToolUse');
506
+ if (existsSync(file) && readFileSync(file, 'utf-8').includes('pga hook run'))
507
+ return 'already';
508
+ mkdirSync(dir, { recursive: true });
509
+ writeFileSync(file, '#!/usr/bin/env bash\nexec pga hook run --platform cline\n', {
510
+ mode: 0o755,
511
+ });
512
+ chmodSync(file, 0o755);
513
+ return 'installed';
514
+ }
515
+ }
516
+ }
517
+ catch (err) {
518
+ lastInstallError = err instanceof Error ? err.message : String(err);
519
+ return 'error';
520
+ }
521
+ }
522
+ /** Claude-only install, kept for `pga up` back-compat. */
523
+ export function installHook() {
524
+ const r = installFor('claude-code');
525
+ return r === 'error' ? 'already' : r;
526
+ }
527
+ /**
528
+ * Strip every PanGuard hook entry from one platform's config — the symmetric
529
+ * inverse of installFor. `install` (no flag) writes hooks across ~7 platforms;
530
+ * `uninstall` must be able to remove them all, or an orphaned failClosed hook
531
+ * left behind after `npm remove` can DENY every tool call and brick the agent.
532
+ *
533
+ * DATA-LOSS SAFETY mirrors installFor: a config that exists but cannot be parsed
534
+ * is treated as 'error' and NEVER overwritten (we'd wipe the user's other
535
+ * settings). 'absent' means there is nothing to remove. We only ever filter out
536
+ * entries whose command references `pga hook run`, preserving every other hook.
537
+ */
538
+ export function uninstallFor(platform) {
539
+ lastInstallError = null;
540
+ try {
541
+ // Helper: detect our entries inside an arbitrary host hook array by string.
542
+ const references = (v) => JSON.stringify(v ?? null).includes('pga hook run');
543
+ const filterArr = (arr) => arr.filter((e) => !references(e));
544
+ switch (platform) {
545
+ case 'claude-code':
546
+ case 'continue': {
547
+ // Both read ~/.claude/settings.json; the Claude entry covers both.
548
+ const path = claudeSettingsPath();
549
+ const r = readJsonForRoundTrip(path);
550
+ if (r.kind === 'corrupt') {
551
+ lastInstallError = `Claude settings (${path}) is not valid JSON (${r.error}). PanGuard did NOT modify it.`;
552
+ return 'error';
553
+ }
554
+ if (r.kind === 'absent')
555
+ return 'absent';
556
+ const s = r.data;
557
+ if (!isHookInstalled(s))
558
+ return 'absent';
559
+ writeJson(path, withHookRemoved(s));
560
+ return 'removed';
561
+ }
562
+ case 'codex': {
563
+ const path = join(homedir(), '.codex', 'hooks.json');
564
+ const r = readJsonForRoundTrip(path);
565
+ if (r.kind === 'corrupt') {
566
+ lastInstallError = `Codex hooks (${path}) is not valid JSON (${r.error}). PanGuard did NOT modify it.`;
567
+ return 'error';
568
+ }
569
+ if (r.kind === 'absent')
570
+ return 'absent';
571
+ const s = r.data;
572
+ if (!isHookInstalled(s))
573
+ return 'absent';
574
+ writeJson(path, withHookRemoved(s));
575
+ return 'removed';
576
+ }
577
+ case 'gemini': {
578
+ const path = join(homedir(), '.gemini', 'settings.json');
579
+ const r = readJsonForRoundTrip(path);
580
+ if (r.kind === 'corrupt') {
581
+ lastInstallError = `Gemini settings (${path}) is not valid JSON (${r.error}). PanGuard did NOT modify it.`;
582
+ return 'error';
583
+ }
584
+ if (r.kind === 'absent')
585
+ return 'absent';
586
+ const s = r.data;
587
+ const before = Array.isArray(s.hooks?.BeforeTool) ? s.hooks.BeforeTool : [];
588
+ if (!before.some(references))
589
+ return 'absent';
590
+ const filtered = filterArr(before);
591
+ writeJson(path, { ...s, hooks: { ...(s.hooks ?? {}), BeforeTool: filtered } });
592
+ return 'removed';
593
+ }
594
+ case 'cursor': {
595
+ const path = join(homedir(), '.cursor', 'hooks.json');
596
+ const r = readJsonForRoundTrip(path);
597
+ if (r.kind === 'corrupt') {
598
+ lastInstallError = `Cursor hooks (${path}) is not valid JSON (${r.error}). PanGuard did NOT modify it.`;
599
+ return 'error';
600
+ }
601
+ if (r.kind === 'absent')
602
+ return 'absent';
603
+ const s = r.data;
604
+ const hooks = { ...(s.hooks ?? {}) };
605
+ if (!JSON.stringify(hooks).includes('pga hook run'))
606
+ return 'absent';
607
+ for (const ev of ['beforeShellExecution', 'beforeMCPExecution']) {
608
+ if (Array.isArray(hooks[ev]))
609
+ hooks[ev] = filterArr(hooks[ev]);
610
+ }
611
+ writeJson(path, { ...s, hooks });
612
+ return 'removed';
613
+ }
614
+ case 'windsurf': {
615
+ const path = join(homedir(), '.codeium', 'windsurf', 'hooks.json');
616
+ const r = readJsonForRoundTrip(path);
617
+ if (r.kind === 'corrupt') {
618
+ lastInstallError = `Windsurf hooks (${path}) is not valid JSON (${r.error}). PanGuard did NOT modify it.`;
619
+ return 'error';
620
+ }
621
+ if (r.kind === 'absent')
622
+ return 'absent';
623
+ const s = r.data;
624
+ const hooks = { ...(s.hooks ?? {}) };
625
+ if (!JSON.stringify(hooks).includes('pga hook run'))
626
+ return 'absent';
627
+ for (const ev of ['pre_run_command', 'pre_write_code', 'pre_mcp_tool_use']) {
628
+ if (Array.isArray(hooks[ev]))
629
+ hooks[ev] = filterArr(hooks[ev]);
630
+ }
631
+ writeJson(path, { ...s, hooks });
632
+ return 'removed';
633
+ }
634
+ case 'cline': {
635
+ // Filename-based: our own executable script. Safe to remove only when it
636
+ // is actually ours (references our hook), never a user-authored script.
637
+ const file = join(homedir(), 'Documents', 'Cline', 'Rules', 'Hooks', 'PreToolUse');
638
+ if (!existsSync(file))
639
+ return 'absent';
640
+ if (!readFileSync(file, 'utf-8').includes('pga hook run'))
641
+ return 'absent';
642
+ rmSync(file);
643
+ return 'removed';
644
+ }
645
+ }
646
+ }
647
+ catch (err) {
648
+ lastInstallError = err instanceof Error ? err.message : String(err);
649
+ return 'error';
650
+ }
651
+ }
652
+ /** Platforms that have a built-in-tool hook PanGuard can wire (closes the blind spot). */
653
+ export const HOOKABLE_PLATFORMS = [
654
+ 'claude-code',
655
+ 'continue',
656
+ 'codex',
657
+ 'cursor',
658
+ 'gemini',
659
+ 'cline',
660
+ 'windsurf',
661
+ ];
662
+ /** Map a detected platform id (platform-detector) to a hookable platform, if any. */
663
+ export function toHookPlatform(detectedId) {
664
+ const map = {
665
+ 'claude-code': 'claude-code',
666
+ continue: 'continue',
667
+ codex: 'codex',
668
+ cursor: 'cursor',
669
+ 'gemini-cli': 'gemini',
670
+ cline: 'cline',
671
+ windsurf: 'windsurf',
672
+ };
673
+ return map[detectedId] ?? null;
674
+ }
675
+ export function hookCommand() {
676
+ const cmd = new Command('hook').description('PreToolUse protection for built-in agent tools (Bash/Edit/Write/WebFetch) across platforms');
677
+ cmd
678
+ .command('run')
679
+ .description('Run the tool-call hook (invoked by the host agent; reads stdin)')
680
+ .option('--platform <id>', 'Host platform (claude-code default)', 'claude-code')
681
+ .action(async (opts) => {
682
+ const p = (opts.platform ?? 'claude-code');
683
+ await runHook(PLATFORMS[p] ? p : 'claude-code');
684
+ });
685
+ cmd
686
+ .command('install')
687
+ .description('Register the hook for a platform (or all hookable detected platforms)')
688
+ .option('--platform <id>', 'A specific platform; omit for all hookable')
689
+ .action((opts) => {
690
+ const targets = opts.platform
691
+ ? [opts.platform].filter((p) => PLATFORMS[p])
692
+ : HOOKABLE_PLATFORMS;
693
+ if (!targets.length) {
694
+ console.log(` ${c.caution(`Unknown platform: ${opts.platform}`)}`);
695
+ return;
696
+ }
697
+ for (const p of targets) {
698
+ const r = installFor(p);
699
+ const tag = r === 'installed'
700
+ ? c.safe('installed')
701
+ : r === 'already'
702
+ ? c.dim('already')
703
+ : c.caution('error');
704
+ console.log(` ${p.padEnd(12)} ${tag}`);
705
+ // On error, surface WHY (e.g. corrupt config we refused to overwrite)
706
+ // so the user can fix it — never silently skip a data-loss abort.
707
+ if (r === 'error' && lastHookInstallError()) {
708
+ console.log(` ${''.padEnd(12)} ${c.dim(lastHookInstallError())}`);
709
+ }
710
+ }
711
+ console.log(` ${c.dim('Restart the host agent for the hook to take effect.')}`);
712
+ });
713
+ cmd
714
+ .command('uninstall')
715
+ .description('Remove the PanGuard hook (all hookable platforms by default)')
716
+ .option('--platform <id>', 'A specific platform; omit for all hookable')
717
+ .option('--all', 'Remove from every hookable platform (default)')
718
+ .action((opts) => {
719
+ // Default to ALL hookable platforms, mirroring `install`. `install` (no
720
+ // flag) wires ~7 platforms, so `uninstall` (no flag) must strip all of
721
+ // them — otherwise an orphaned hook (e.g. Cursor) is left behind to brick
722
+ // the agent after the `pga` binary is gone.
723
+ const targets = opts.platform
724
+ ? [opts.platform].filter((p) => PLATFORMS[p])
725
+ : HOOKABLE_PLATFORMS;
726
+ if (!targets.length) {
727
+ console.log(` ${c.caution(`Unknown platform: ${opts.platform}`)}`);
728
+ return;
729
+ }
730
+ let removed = 0;
731
+ for (const p of targets) {
732
+ const r = uninstallFor(p);
733
+ const tag = r === 'removed'
734
+ ? c.safe('removed')
735
+ : r === 'absent'
736
+ ? c.dim('not installed')
737
+ : c.caution('error');
738
+ if (r === 'removed')
739
+ removed++;
740
+ console.log(` ${p.padEnd(12)} ${tag}`);
741
+ // On error, surface WHY (e.g. corrupt config we refused to overwrite).
742
+ if (r === 'error' && lastHookInstallError()) {
743
+ console.log(` ${''.padEnd(12)} ${c.dim(lastHookInstallError())}`);
744
+ }
745
+ }
746
+ console.log(removed > 0
747
+ ? ` ${c.dim('Restart the host agent for the change to take effect.')}`
748
+ : ` ${c.dim('Nothing to remove.')}`);
749
+ });
750
+ cmd
751
+ .command('status')
752
+ .description('Show whether the built-in-tool hook is registered (Claude)')
753
+ .action(() => {
754
+ const settingsPath = claudeSettingsPath();
755
+ const read = readJsonForRoundTrip(settingsPath);
756
+ if (read.kind === 'corrupt') {
757
+ console.log(` ${c.caution('Cannot read settings:')} ${c.dim(`${settingsPath} is not valid JSON (${read.error}).`)}`);
758
+ return;
759
+ }
760
+ const installed = isHookInstalled((read.kind === 'ok' ? read.data : {}));
761
+ console.log(installed
762
+ ? ` ${c.safe('Built-in-tool hook ACTIVE')} (Claude). Hookable platforms: ${HOOKABLE_PLATFORMS.join(', ')}.`
763
+ : ` ${c.caution('Built-in-tool hook NOT installed')} — run: pga hook install`);
764
+ });
765
+ return cmd;
766
+ }
767
+ //# sourceMappingURL=hook.js.map