@guardion/guardion 0.2.0 → 0.4.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -0
  3. package/dist/bin/cli.d.ts.map +1 -0
  4. package/dist/bin/cli.js +590 -0
  5. package/dist/bin/cli.js.map +1 -0
  6. package/dist/connectors/claude-code/hooks/enforce.cjs +58 -0
  7. package/dist/connectors/claude-code/hooks/guardion-hook.cjs +355 -0
  8. package/dist/connectors/claude-code/hooks/tool-scanner.cjs +272 -0
  9. package/dist/connectors/claude-code/src/collect.d.ts +5 -0
  10. package/dist/connectors/claude-code/src/collect.d.ts.map +1 -0
  11. package/dist/connectors/claude-code/src/collect.js +17 -0
  12. package/dist/connectors/claude-code/src/collect.js.map +1 -0
  13. package/dist/{installer.d.ts → connectors/claude-code/src/installer.d.ts} +2 -1
  14. package/dist/connectors/claude-code/src/installer.d.ts.map +1 -0
  15. package/dist/connectors/claude-code/src/installer.js +190 -0
  16. package/dist/connectors/claude-code/src/installer.js.map +1 -0
  17. package/dist/connectors/claude-code/src/scanner.d.ts.map +1 -0
  18. package/dist/{scanner.js → connectors/claude-code/src/scanner.js} +1 -1
  19. package/dist/connectors/claude-code/src/scanner.js.map +1 -0
  20. package/dist/core/config.d.ts +239 -0
  21. package/dist/core/config.d.ts.map +1 -0
  22. package/dist/core/config.js +154 -0
  23. package/dist/core/config.js.map +1 -0
  24. package/dist/{constants.d.ts → core/constants.d.ts} +8 -3
  25. package/dist/core/constants.d.ts.map +1 -0
  26. package/dist/core/constants.js +54 -0
  27. package/dist/core/constants.js.map +1 -0
  28. package/dist/core/discover.d.ts +36 -0
  29. package/dist/core/discover.d.ts.map +1 -0
  30. package/dist/core/discover.js +154 -0
  31. package/dist/core/discover.js.map +1 -0
  32. package/dist/core/fingerprint.cjs +84 -0
  33. package/dist/core/inventory.d.ts +35 -0
  34. package/dist/core/inventory.d.ts.map +1 -0
  35. package/dist/core/inventory.js +69 -0
  36. package/dist/core/inventory.js.map +1 -0
  37. package/dist/core/keychain.d.ts.map +1 -0
  38. package/dist/{keychain.js → core/keychain.js} +53 -15
  39. package/dist/core/keychain.js.map +1 -0
  40. package/dist/core/mcp/guard-client.cjs +86 -0
  41. package/dist/core/mcp/interceptor.cjs +238 -0
  42. package/dist/core/mcp/jsonrpc.cjs +194 -0
  43. package/dist/core/mcp/transport/http-server-side.cjs +89 -0
  44. package/dist/core/mcp/transport/http-upstream.cjs +111 -0
  45. package/dist/core/mcp/transport/http_forward.cjs +40 -0
  46. package/dist/core/mcp/transport/http_input.cjs +46 -0
  47. package/dist/core/mcp/transport/http_reverse.cjs +33 -0
  48. package/dist/core/mcp/transport/index.cjs +32 -0
  49. package/dist/core/mcp/transport/sse_bridge.cjs +101 -0
  50. package/dist/core/mcp/transport/stdio.cjs +60 -0
  51. package/dist/core/mcp-interpose.cjs +141 -0
  52. package/dist/core/mcp-protect.d.ts +69 -0
  53. package/dist/core/mcp-protect.d.ts.map +1 -0
  54. package/dist/core/mcp-protect.js +205 -0
  55. package/dist/core/mcp-protect.js.map +1 -0
  56. package/dist/core/mcp-scan.d.ts +40 -0
  57. package/dist/core/mcp-scan.d.ts.map +1 -0
  58. package/dist/core/mcp-scan.js +201 -0
  59. package/dist/core/mcp-scan.js.map +1 -0
  60. package/dist/core/mock-server.d.ts.map +1 -0
  61. package/dist/{mock-server.js → core/mock-server.js} +60 -4
  62. package/dist/core/mock-server.js.map +1 -0
  63. package/package.json +9 -10
  64. package/config.yaml.example +0 -26
  65. package/dist/cli.d.ts.map +0 -1
  66. package/dist/cli.js +0 -289
  67. package/dist/cli.js.map +0 -1
  68. package/dist/config.d.ts +0 -28
  69. package/dist/config.d.ts.map +0 -1
  70. package/dist/config.js +0 -63
  71. package/dist/config.js.map +0 -1
  72. package/dist/constants.d.ts.map +0 -1
  73. package/dist/constants.js +0 -44
  74. package/dist/constants.js.map +0 -1
  75. package/dist/installer.d.ts.map +0 -1
  76. package/dist/installer.js +0 -137
  77. package/dist/installer.js.map +0 -1
  78. package/dist/keychain.d.ts.map +0 -1
  79. package/dist/keychain.js.map +0 -1
  80. package/dist/mock-server.d.ts.map +0 -1
  81. package/dist/mock-server.js.map +0 -1
  82. package/dist/scanner.d.ts.map +0 -1
  83. package/dist/scanner.js.map +0 -1
  84. package/hooks/guardion-hook.cjs +0 -202
  85. /package/dist/{cli.d.ts → bin/cli.d.ts} +0 -0
  86. /package/dist/{scanner.d.ts → connectors/claude-code/src/scanner.d.ts} +0 -0
  87. /package/dist/{keychain.d.ts → core/keychain.d.ts} +0 -0
  88. /package/{hooks → dist/core}/metadata.cjs +0 -0
  89. /package/dist/{mock-server.d.ts → core/mock-server.d.ts} +0 -0
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ /**
3
+ * P2 enforcement mapping (pure, testable) — Claude-Code-native equivalent of
4
+ * AGT's MCP gateway dual-stage pipeline. PreToolUse scans tool INPUT, PostToolUse
5
+ * scans tool OUTPUT. The decision FOLLOWS THE POLICY ACTION: the Guard server
6
+ * returns `deny` only when policy.action=block, so:
7
+ * res.deny → DENY (PreToolUse: permissionDecision deny / PostToolUse: block)
8
+ * res.flagged → FLAG (warn, non-blocking additionalContext) — action=flag, or
9
+ * a held step_up/defer
10
+ * Default fail-OPEN; fail-closed only when explicitly enabled.
11
+ */
12
+
13
+ /** Build the tool message sent to /v1/guard from a Claude Code hook payload. */
14
+ function toolMessage(event, payload) {
15
+ const toolName = payload.tool_name || payload.toolName || '';
16
+ if (event === 'PostToolUse') {
17
+ const resp = payload.tool_response != null ? payload.tool_response
18
+ : (payload.tool_output != null ? payload.tool_output : '');
19
+ // Canonical Guard role is `tool_response` (NOT `tool_output`, which is a
20
+ // Target name) — must match MessagesRole in guard/guard/core/schemas.py.
21
+ return { role: 'tool_response', name: toolName,
22
+ content: typeof resp === 'string' ? resp : JSON.stringify(resp || '') };
23
+ }
24
+ const input = payload.tool_input != null ? payload.tool_input : (payload.toolInput || {});
25
+ const inputText = typeof input === 'string' ? input : JSON.stringify(input || {});
26
+ return { role: 'tool_input', name: toolName, content: `${toolName} ${inputText}`.trim() };
27
+ }
28
+
29
+ function guardReason(res) {
30
+ try {
31
+ const b = res && Array.isArray(res.breakdown) ? res.breakdown[0] : null;
32
+ const label = b && (b.top_label || b.label);
33
+ return label ? `Guardion governance: ${label}` : 'Guardion governance policy';
34
+ } catch { return 'Guardion governance policy'; }
35
+ }
36
+
37
+ /** Map a Guard verdict → Claude Code hook decision JSON, or null to allow. */
38
+ function enforceDecision(event, res, failClosed) {
39
+ if (!res) { // Guard unreachable / timeout
40
+ if (!failClosed) return null; // fail-open (default): allow
41
+ return event === 'PreToolUse'
42
+ ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
43
+ permissionDecisionReason: 'Guardion governance unavailable (fail-closed)' } }
44
+ : { decision: 'block', reason: 'Guardion governance unavailable (fail-closed)' };
45
+ }
46
+ const reason = guardReason(res);
47
+ if (res.deny) { // policy.action=block → DENY
48
+ return event === 'PreToolUse'
49
+ ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }
50
+ : { decision: 'block', reason };
51
+ }
52
+ if (res.flagged) { // policy.action=flag → FLAG (warn, non-blocking)
53
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: `[guardion] flagged — ${reason}` } };
54
+ }
55
+ return null; // allow
56
+ }
57
+
58
+ module.exports = { toolMessage, guardReason, enforceDecision };
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guardion Claude Code hook — tier-aware event forwarder.
4
+ *
5
+ * Tiers:
6
+ * hooks — fire-and-forget POST to Guard API, no LLM proxy involvement
7
+ * full — same + on SessionStart injects x-guardion-trace-id into
8
+ * ANTHROPIC_CUSTOM_HEADERS so every LLM call through the
9
+ * Guardion gateway is correlated to this session
10
+ *
11
+ * Config (~/.guardion/config.json, written by `npx guardion init`):
12
+ * tier, api_url, policy, application, gateway.*, hooks.events, hooks.timeout_ms
13
+ *
14
+ * Token resolution (first match wins):
15
+ * GUARDION_TOKEN env → macOS Keychain → /etc/guardion/token → ~/.guardion/token
16
+ *
17
+ * Always exits 0 — never blocks Claude Code.
18
+ */
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const http = require('http');
25
+ const https = require('https');
26
+ const { execFileSync } = require('child_process');
27
+
28
+ // ── Paths ────────────────────────────────────────────────────────────────────
29
+
30
+ const HOME_DIR = os.homedir();
31
+ const GUARDION_DIR = path.join(HOME_DIR, '.guardion');
32
+ const CONFIG_JSON = path.join(GUARDION_DIR, 'config.json');
33
+ const CURRENT_SESSION = path.join(GUARDION_DIR, 'current-session');
34
+ const SESSION_DIR = path.join(GUARDION_DIR, 'sessions');
35
+ const SETTINGS_PATH = path.join(HOME_DIR, '.claude', 'settings.json');
36
+
37
+ // ── Config ───────────────────────────────────────────────────────────────────
38
+
39
+ function loadConfig() {
40
+ try {
41
+ return JSON.parse(fs.readFileSync(CONFIG_JSON, 'utf8'));
42
+ } catch {
43
+ return {
44
+ tier: process.env.GUARDION_TIER || 'hooks',
45
+ api_url: process.env.GUARDION_API_URL || 'https://api.guardion.ai',
46
+ policy: process.env.GUARDION_POLICY || '',
47
+ application: process.env.GUARDION_APPLICATION || 'claude-code',
48
+ };
49
+ }
50
+ }
51
+
52
+ // ── Token ────────────────────────────────────────────────────────────────────
53
+
54
+ function resolveToken() {
55
+ if (process.env.GUARDION_TOKEN) return process.env.GUARDION_TOKEN.trim();
56
+ if (process.platform === 'darwin') {
57
+ try {
58
+ const t = execFileSync('security',
59
+ ['find-generic-password', '-s', 'guardion', '-a', 'token', '-w'],
60
+ { timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
61
+ ).trim();
62
+ if (t) return t;
63
+ } catch { /* not found */ }
64
+ }
65
+ for (const p of ['/etc/guardion/token', path.join(GUARDION_DIR, 'token')]) {
66
+ try {
67
+ const t = fs.readFileSync(p, 'utf8').trim();
68
+ if (t) return t;
69
+ } catch { /* absent */ }
70
+ }
71
+ return '';
72
+ }
73
+
74
+ // ── Session files ────────────────────────────────────────────────────────────
75
+
76
+ function writeCurrentSession(id) {
77
+ try {
78
+ fs.mkdirSync(GUARDION_DIR, { recursive: true });
79
+ const tmp = CURRENT_SESSION + '.tmp';
80
+ fs.writeFileSync(tmp, id, 'utf8');
81
+ fs.renameSync(tmp, CURRENT_SESSION);
82
+ } catch { /* non-critical */ }
83
+ }
84
+
85
+ function readCurrentSession() {
86
+ try { return fs.readFileSync(CURRENT_SESSION, 'utf8').trim(); } catch { return ''; }
87
+ }
88
+
89
+ function deleteCurrentSession() {
90
+ try { fs.unlinkSync(CURRENT_SESSION); } catch { /* gone */ }
91
+ }
92
+
93
+ function writeSessionMeta(id, meta) {
94
+ try {
95
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
96
+ fs.writeFileSync(path.join(SESSION_DIR, `${id}.json`), JSON.stringify(meta, null, 2), 'utf8');
97
+ } catch { /* non-critical */ }
98
+ }
99
+
100
+ function deleteSessionMeta(id) {
101
+ if (!id) return;
102
+ try { fs.unlinkSync(path.join(SESSION_DIR, `${id}.json`)); } catch { /* gone */ }
103
+ }
104
+
105
+ // ── Full-tier: settings.json rewrite ─────────────────────────────────────────
106
+ // Injects x-guardion-trace-id (and optionally x-guardion-metadata) into
107
+ // ANTHROPIC_CUSTOM_HEADERS so the gateway correlates LLM calls to this session.
108
+ // Written atomically (tmp → rename) to avoid partial-write corruption.
109
+
110
+ function injectTraceHeaders(sessionId, metadata) {
111
+ try {
112
+ const raw = fs.readFileSync(SETTINGS_PATH, 'utf8');
113
+ const settings = JSON.parse(raw);
114
+ settings.env = settings.env || {};
115
+
116
+ const existing = (settings.env.ANTHROPIC_CUSTOM_HEADERS || '').split('\n');
117
+ const filtered = existing.filter(l =>
118
+ !l.startsWith('x-guardion-trace-id:') &&
119
+ !l.startsWith('x-guardion-metadata:')
120
+ );
121
+
122
+ filtered.push(`x-guardion-trace-id: ${sessionId}`);
123
+ if (metadata) {
124
+ const b64 = Buffer.from(JSON.stringify(metadata)).toString('base64');
125
+ filtered.push(`x-guardion-metadata: ${b64}`);
126
+ }
127
+
128
+ settings.env.ANTHROPIC_CUSTOM_HEADERS = filtered.join('\n');
129
+ settings.env.GUARDION_TRACE_ID = sessionId;
130
+
131
+ const tmp = SETTINGS_PATH + '.tmp';
132
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
133
+ fs.renameSync(tmp, SETTINGS_PATH);
134
+ } catch (e) {
135
+ process.stderr.write(`[guardion] settings rewrite error: ${e.message}\n`);
136
+ }
137
+ }
138
+
139
+ // ── HTTP POST ────────────────────────────────────────────────────────────────
140
+
141
+ function postEvent(apiUrl, token, payload, timeoutMs) {
142
+ return new Promise(resolve => {
143
+ let url;
144
+ try { url = new URL('/v1/hooks/events', apiUrl); } catch { return resolve(); }
145
+
146
+ const transport = url.protocol === 'https:' ? https : http;
147
+ const body = JSON.stringify(payload);
148
+
149
+ const req = transport.request({
150
+ hostname: url.hostname,
151
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
152
+ path: url.pathname,
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ 'Authorization': `Bearer ${token}`,
157
+ 'Content-Length': Buffer.byteLength(body),
158
+ },
159
+ timeout: timeoutMs,
160
+ }, res => { res.resume(); res.on('end', resolve); });
161
+
162
+ req.on('error', resolve);
163
+ req.on('timeout', () => { req.destroy(); resolve(); });
164
+ req.write(body);
165
+ req.end();
166
+ });
167
+ }
168
+
169
+ // ── Tool inventory snapshot ──────────────────────────────────────────────────
170
+ // Sends the local tool inventory (skills, MCP servers, plugins, built-ins) to
171
+ // Guard API at /v1/guard for scanning + storage in ai_tool_inventory.
172
+ // Fire-and-forget; never blocks or fails the session.
173
+
174
+ function postInventorySnapshot(apiUrl, token, payload, timeoutMs) {
175
+ return new Promise(resolve => {
176
+ let url;
177
+ try { url = new URL('/v1/guard', apiUrl); } catch { return resolve(); }
178
+
179
+ const transport = url.protocol === 'https:' ? https : http;
180
+ const body = JSON.stringify(payload);
181
+
182
+ const req = transport.request({
183
+ hostname: url.hostname,
184
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
185
+ path: url.pathname,
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ 'Authorization': `Bearer ${token}`,
190
+ 'Content-Length': Buffer.byteLength(body),
191
+ },
192
+ timeout: timeoutMs,
193
+ }, res => { res.resume(); res.on('end', resolve); });
194
+
195
+ req.on('error', resolve);
196
+ req.on('timeout', () => { req.destroy(); resolve(); });
197
+ req.write(body);
198
+ req.end();
199
+ });
200
+ }
201
+
202
+ function collectInventory(cwd, inventoryConfig) {
203
+ try {
204
+ const { collectLocalTools } = require('./tool-scanner.cjs');
205
+ return collectLocalTools(cwd || process.cwd(), inventoryConfig || {});
206
+ } catch {
207
+ return [];
208
+ }
209
+ }
210
+
211
+ // ── Metadata ─────────────────────────────────────────────────────────────────
212
+
213
+ function collectMetadata(payload) {
214
+ try {
215
+ const { collectMetadata: collect } = require('../../../core/metadata.cjs');
216
+ return collect(payload);
217
+ } catch {
218
+ return { session_id: payload.session_id || null, cwd: payload.cwd || process.cwd() };
219
+ }
220
+ }
221
+
222
+ // ── Enforcement (P2): synchronous Guard eval + Claude Code hook decision ──────
223
+ // PreToolUse scans the tool INPUT and can deny/ask; PostToolUse scans the tool
224
+ // OUTPUT and can block. This is the Claude-Code-native equivalent of AGT's MCP
225
+ // gateway dual-stage pipeline (intercept_tool_call / intercept_tool_response) —
226
+ // no proxy needed. Fires for native AND mcp__* tools.
227
+
228
+ function postGuardEval(apiUrl, token, payload, timeoutMs) {
229
+ return new Promise(resolve => {
230
+ let url;
231
+ try { url = new URL('/v1/guard', apiUrl); } catch { return resolve(null); }
232
+ const transport = url.protocol === 'https:' ? https : http;
233
+ const body = JSON.stringify(payload);
234
+ const req = transport.request({
235
+ hostname: url.hostname,
236
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
237
+ path: url.pathname,
238
+ method: 'POST',
239
+ headers: {
240
+ 'Content-Type': 'application/json',
241
+ 'Authorization': `Bearer ${token}`,
242
+ 'Content-Length': Buffer.byteLength(body),
243
+ },
244
+ timeout: timeoutMs,
245
+ }, res => {
246
+ let data = '';
247
+ res.setEncoding('utf8');
248
+ res.on('data', c => { data += c; });
249
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
250
+ });
251
+ req.on('error', () => resolve(null));
252
+ req.on('timeout', () => { req.destroy(); resolve(null); });
253
+ req.write(body);
254
+ req.end();
255
+ });
256
+ }
257
+
258
+ // Pure decision/mapping logic lives in enforce.cjs (testable; this file reads
259
+ // stdin + sets a timer on load, so it can't be required from tests).
260
+ const { toolMessage, enforceDecision } = require('./enforce.cjs');
261
+
262
+ // ── Main ─────────────────────────────────────────────────────────────────────
263
+
264
+ const config = loadConfig();
265
+ const timeoutMs = (config.hooks && config.hooks.timeout_ms) || 3000;
266
+
267
+ // Hard exit — never hang Claude Code
268
+ setTimeout(() => process.exit(0), timeoutMs + 1500);
269
+
270
+ let rawInput = '';
271
+ process.stdin.setEncoding('utf8');
272
+ process.stdin.on('data', chunk => { rawInput += chunk; });
273
+ process.stdin.on('end', async () => {
274
+ let payload;
275
+ try { payload = JSON.parse(rawInput); } catch { return process.exit(0); }
276
+
277
+ const token = resolveToken();
278
+ if (!token) return process.exit(0);
279
+
280
+ const event = payload.hook_event_name || payload.event || '';
281
+
282
+ // Attach policy + application to every event
283
+ if (config.policy) payload.policy = config.policy;
284
+ if (config.application) payload.application = config.application;
285
+
286
+ // ── P2: synchronous enforcement for tool input/output ──────────────────────
287
+ // OPT-IN: set config.enforce=true to block/warn on tool input+output. Default
288
+ // off (observability-first) so day-one false positives can't block real work.
289
+ // The /v1/guard call also logs, so we skip the separate event post here.
290
+ if (config.enforce === true && (event === 'PreToolUse' || event === 'PostToolUse')) {
291
+ const res = await postGuardEval(config.api_url, token, {
292
+ application: payload.application || 'claude-code',
293
+ policy: config.policy || undefined,
294
+ session: payload.session_id || readCurrentSession() || undefined,
295
+ messages: [toolMessage(event, payload)],
296
+ log: true,
297
+ }, timeoutMs);
298
+ const out = enforceDecision(event, res, config.fail_closed === true);
299
+ if (out) process.stdout.write(JSON.stringify(out));
300
+ return process.exit(0);
301
+ }
302
+
303
+ if (event === 'SessionStart') {
304
+ const meta = collectMetadata(payload);
305
+ const sessionId = payload.session_id || meta.session_id || `gs-${Date.now()}`;
306
+
307
+ payload.trace_id = sessionId;
308
+ payload.metadata = meta;
309
+
310
+ writeCurrentSession(sessionId);
311
+ writeSessionMeta(sessionId, meta);
312
+
313
+ // Full tier: inject trace-id into ANTHROPIC_CUSTOM_HEADERS so the gateway
314
+ // can correlate every subsequent LLM call to this session
315
+ if (config.tier === 'full') {
316
+ injectTraceHeaders(sessionId, meta);
317
+ }
318
+
319
+ // Tool inventory snapshot — scan local skills/MCP/plugins/built-ins and
320
+ // send to Guard API for scanning + inventory storage (fire-and-forget).
321
+ const inv = config.inventory || {};
322
+ if (inv.enabled !== false) {
323
+ const tools = collectInventory(payload.cwd, inv);
324
+ if (tools.length > 0) {
325
+ // Rug-pull pin (P1): attach prev_* fingerprints from the local pin so the
326
+ // server flags a changed tool description as drift. Never throws.
327
+ try {
328
+ const { pinAndEnrich } = require('../../../core/fingerprint.cjs');
329
+ pinAndEnrich(tools, path.join(GUARDION_DIR, 'fingerprints.json'));
330
+ } catch { /* fingerprint pin is best-effort */ }
331
+ await postInventorySnapshot(config.api_url, token, {
332
+ tools,
333
+ session: sessionId,
334
+ policy: config.policy || undefined,
335
+ application: config.application || 'claude-code',
336
+ log: true,
337
+ snapshot_source: 'plugin_scan',
338
+ }, timeoutMs);
339
+ }
340
+ }
341
+
342
+ } else if (event === 'SessionEnd') {
343
+ const sessionId = payload.session_id || readCurrentSession();
344
+ payload.trace_id = sessionId;
345
+ deleteCurrentSession();
346
+ deleteSessionMeta(sessionId);
347
+
348
+ } else {
349
+ const traceId = process.env.GUARDION_TRACE_ID || readCurrentSession();
350
+ if (traceId) payload.trace_id = traceId;
351
+ }
352
+
353
+ await postEvent(config.api_url, token, payload, timeoutMs);
354
+ process.exit(0);
355
+ });
@@ -0,0 +1,272 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ const HOME = os.homedir();
7
+
8
+ // ── SKILL.md parser ──────────────────────────────────────────────────────────
9
+ // Parse YAML frontmatter (---\nkey: value\n---) from a SKILL.md file.
10
+ // Returns { name, description } or null. Never throws.
11
+ function parseSkillFrontmatter(content) {
12
+ try {
13
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
14
+ if (!match) return null;
15
+ const front = match[1];
16
+ const name = (front.match(/^name:\s*(.+)$/m) || [])[1]?.trim();
17
+ // description can be multi-line (| or |- syntax) — grab just the first line after |-
18
+ const descMatch = front.match(/^description:\s*(.+)$/m)
19
+ || front.match(/^description:\s*[|>-]*\s*\n\s+(.+)$/m);
20
+ const description = descMatch ? descMatch[1].trim() : null;
21
+ return { name: name || null, description: description || null };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // ── Skills ───────────────────────────────────────────────────────────────────
28
+ function collectSkills(cwd) {
29
+ const tools = [];
30
+ const skillRoots = [
31
+ path.join(HOME, '.claude', 'skills'),
32
+ path.join(cwd, '.claude', 'skills'),
33
+ // plugin cache skills
34
+ path.join(HOME, '.claude', 'plugins', 'cache'),
35
+ path.join(HOME, '.claude', 'plugins', 'marketplaces'),
36
+ ];
37
+
38
+ for (const root of skillRoots) {
39
+ try {
40
+ // Recursively find SKILL.md files (max depth 6 to avoid runaway)
41
+ const found = findFiles(root, 'SKILL.md', 6);
42
+ for (const skillPath of found) {
43
+ try {
44
+ const content = fs.readFileSync(skillPath, 'utf8');
45
+ const meta = parseSkillFrontmatter(content);
46
+ if (!meta) continue;
47
+ const name = meta.name || path.basename(path.dirname(skillPath));
48
+ tools.push({
49
+ name,
50
+ description: meta.description || `Skill: ${name}`,
51
+ server: 'skill',
52
+ source: 'skill',
53
+ snapshot_source: 'plugin_scan',
54
+ });
55
+ } catch { /* skip unreadable files */ }
56
+ }
57
+ } catch { /* root dir absent */ }
58
+ }
59
+ return dedup(tools, t => `skill:${t.name}`);
60
+ }
61
+
62
+ // ── MCP servers ──────────────────────────────────────────────────────────────
63
+ function collectMCPServers(cwd) {
64
+ const tools = [];
65
+ const configPaths = [
66
+ path.join(HOME, '.claude', 'settings.json'),
67
+ path.join(cwd, '.mcp.json'),
68
+ path.join(cwd, '.claude', 'settings.json'),
69
+ ];
70
+
71
+ // Also scan plugin cache .mcp.json files
72
+ try {
73
+ const cacheRoot = path.join(HOME, '.claude', 'plugins', 'cache');
74
+ const mcpFiles = findFiles(cacheRoot, '.mcp.json', 5);
75
+ configPaths.push(...mcpFiles);
76
+ } catch { /* absent */ }
77
+
78
+ for (const cfgPath of configPaths) {
79
+ try {
80
+ const raw = fs.readFileSync(cfgPath, 'utf8');
81
+ const json = JSON.parse(raw);
82
+ const mcpServers = json.mcpServers || (cfgPath.endsWith('.mcp.json') ? json : null);
83
+ if (!mcpServers || typeof mcpServers !== 'object') continue;
84
+
85
+ for (const [serverName, serverCfg] of Object.entries(mcpServers)) {
86
+ const transport = serverCfg.type || (serverCfg.command ? 'stdio' : 'http');
87
+ const endpoint = serverCfg.url || serverCfg.command || 'unknown';
88
+ tools.push({
89
+ name: serverName,
90
+ description: `MCP server (${transport}): ${endpoint}`,
91
+ server: serverName,
92
+ source: 'mcp',
93
+ snapshot_source: 'plugin_scan',
94
+ });
95
+ }
96
+ } catch { /* skip invalid files */ }
97
+ }
98
+ return dedup(tools, t => `mcp:${t.name}`);
99
+ }
100
+
101
+ // ── Plugin agents ─────────────────────────────────────────────────────────────
102
+ // Report EVERY installed plugin in the Claude Code plugin cache — not only the
103
+ // ones declaring hooks — so the inventory reflects the agent's full installed-
104
+ // plugin surface. For each plugin we record what it provides (hooks / mcp /
105
+ // skills / commands / agents) both from the manifest and from the on-disk
106
+ // layout (Claude Code auto-discovers those directories).
107
+ //
108
+ // Identity is the install directory: a plugin that ships both a root and a
109
+ // `.claude-plugin/` manifest counts once, while distinct installs (different
110
+ // cache locations or versions) stay separate instead of being silently merged.
111
+ function collectPluginTools(cacheRootOverride) {
112
+ const tools = [];
113
+ const cacheRoot = cacheRootOverride || path.join(HOME, '.claude', 'plugins', 'cache');
114
+
115
+ let manifestFiles;
116
+ try { manifestFiles = findFiles(cacheRoot, 'plugin.json', 6); }
117
+ catch { return tools; }
118
+
119
+ // installDir -> aggregated info (collapses a plugin's root + .claude-plugin manifests)
120
+ const byInstall = new Map();
121
+
122
+ for (const mPath of manifestFiles) {
123
+ // Ignore stray manifests bundled inside a plugin's dependencies.
124
+ if (mPath.includes(`${path.sep}node_modules${path.sep}`)) continue;
125
+ try {
126
+ const manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
127
+
128
+ // Install dir = the plugin root (strip a trailing .claude-plugin/).
129
+ let installDir = path.dirname(mPath);
130
+ if (path.basename(installDir) === '.claude-plugin') installDir = path.dirname(installDir);
131
+
132
+ // Marketplace = first path segment under the cache root (e.g. claude-plugins-official).
133
+ const rel = path.relative(cacheRoot, installDir);
134
+ const marketplace = (rel.split(path.sep)[0]) || 'local';
135
+
136
+ const entry = byInstall.get(installDir) || {
137
+ marketplace,
138
+ name: path.basename(installDir),
139
+ version: '',
140
+ description: '',
141
+ caps: new Set(),
142
+ mcpServers: {},
143
+ };
144
+ if (manifest.name) entry.name = manifest.name;
145
+ if (manifest.version) entry.version = manifest.version;
146
+ if (manifest.description) entry.description = manifest.description;
147
+
148
+ // Capabilities — from the manifest …
149
+ if (manifest.hooks && typeof manifest.hooks === 'object') entry.caps.add('hooks');
150
+ if (manifest.commands) entry.caps.add('commands');
151
+ if (manifest.agents) entry.caps.add('agents');
152
+ if (manifest.mcpServers && typeof manifest.mcpServers === 'object') {
153
+ entry.caps.add('mcp');
154
+ Object.assign(entry.mcpServers, manifest.mcpServers);
155
+ }
156
+ // … and from the on-disk layout (auto-discovered directories / files).
157
+ for (const [dir, cap] of [['hooks', 'hooks'], ['commands', 'commands'], ['agents', 'agents'], ['skills', 'skills']]) {
158
+ try { if (fs.existsSync(path.join(installDir, dir))) entry.caps.add(cap); } catch { /* ignore */ }
159
+ }
160
+ try { if (fs.existsSync(path.join(installDir, '.mcp.json'))) entry.caps.add('mcp'); } catch { /* ignore */ }
161
+
162
+ byInstall.set(installDir, entry);
163
+ } catch { /* skip invalid manifests */ }
164
+ }
165
+
166
+ for (const p of byInstall.values()) {
167
+ const capabilities = [...p.caps].sort();
168
+ const verLabel = p.version ? `@${p.version}` : '';
169
+ tools.push({
170
+ name: p.name,
171
+ description: p.description
172
+ ? `${p.description} [${capabilities.join(', ') || 'no declared components'}]`
173
+ : `Plugin ${p.name}${verLabel} [${capabilities.join(', ') || 'no declared components'}] from ${p.marketplace}`,
174
+ server: 'plugin',
175
+ source: 'plugin',
176
+ snapshot_source: 'plugin_scan',
177
+ // Structured metadata (Guard's ToolDefinition allows extra fields).
178
+ version: p.version || undefined,
179
+ marketplace: p.marketplace,
180
+ capabilities,
181
+ has_hooks: p.caps.has('hooks'),
182
+ has_mcp: p.caps.has('mcp'),
183
+ has_skills: p.caps.has('skills'),
184
+ });
185
+ // MCP servers declared inline in the plugin manifest → their own entries.
186
+ for (const [serverName, serverCfg] of Object.entries(p.mcpServers)) {
187
+ const cfg = serverCfg && typeof serverCfg === 'object' ? serverCfg : {};
188
+ const transport = cfg.type || (cfg.command ? 'stdio' : 'http');
189
+ const endpoint = cfg.url || cfg.command || 'plugin';
190
+ tools.push({
191
+ name: serverName,
192
+ description: `Plugin MCP server (${transport}): ${endpoint}`,
193
+ server: serverName,
194
+ source: 'mcp',
195
+ snapshot_source: 'plugin_scan',
196
+ });
197
+ }
198
+ }
199
+
200
+ // Plugins are unique per install (name:version:marketplace); MCP entries dedup by name.
201
+ return dedup(tools, t => t.source === 'plugin'
202
+ ? `plugin:${t.name}:${t.version || ''}:${t.marketplace || ''}`
203
+ : `mcp:${t.name}`);
204
+ }
205
+
206
+ // ── Built-in Claude Code tools ────────────────────────────────────────────────
207
+ function builtinTools() {
208
+ return [
209
+ { name: 'Read', description: 'Read file contents from the local filesystem' },
210
+ { name: 'Write', description: 'Write content to a file on the local filesystem' },
211
+ { name: 'Edit', description: 'Make targeted edits to a specific file' },
212
+ { name: 'MultiEdit', description: 'Make multiple targeted edits to one or more files' },
213
+ { name: 'Bash', description: 'Execute a bash command in the shell environment' },
214
+ { name: 'Glob', description: 'Find files and directories using glob patterns' },
215
+ { name: 'Grep', description: 'Search for patterns within file contents' },
216
+ { name: 'LS', description: 'List files and directories at a path' },
217
+ { name: 'WebFetch', description: 'Fetch content from a URL' },
218
+ { name: 'WebSearch', description: 'Search the web for information' },
219
+ { name: 'TodoRead', description: 'Read the current todo list for this session' },
220
+ { name: 'TodoWrite', description: 'Create or update the todo list for this session' },
221
+ { name: 'NotebookRead', description: 'Read and display a Jupyter notebook' },
222
+ { name: 'NotebookEdit', description: 'Edit a cell in a Jupyter notebook' },
223
+ { name: 'Agent', description: 'Spawn a subagent to handle a complex subtask' },
224
+ ].map(t => ({
225
+ ...t,
226
+ server: 'claude-code',
227
+ source: 'native',
228
+ snapshot_source: 'plugin_scan',
229
+ }));
230
+ }
231
+
232
+ // ── Helpers ───────────────────────────────────────────────────────────────────
233
+ function findFiles(dir, filename, maxDepth) {
234
+ const results = [];
235
+ if (maxDepth <= 0) return results;
236
+ let entries;
237
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
238
+ for (const entry of entries) {
239
+ const fullPath = path.join(dir, entry.name);
240
+ if (entry.isDirectory()) {
241
+ results.push(...findFiles(fullPath, filename, maxDepth - 1));
242
+ } else if (entry.name === filename) {
243
+ results.push(fullPath);
244
+ }
245
+ }
246
+ return results;
247
+ }
248
+
249
+ function dedup(tools, keyFn) {
250
+ const seen = new Set();
251
+ return tools.filter(t => {
252
+ const k = keyFn(t);
253
+ if (seen.has(k)) return false;
254
+ seen.add(k);
255
+ return true;
256
+ });
257
+ }
258
+
259
+ // ── Entry point ───────────────────────────────────────────────────────────────
260
+ function collectLocalTools(cwd, inventoryConfig) {
261
+ const cfg = inventoryConfig || {};
262
+ const tools = [];
263
+ try {
264
+ if (cfg.scan_skills !== false) tools.push(...collectSkills(cwd || process.cwd()));
265
+ if (cfg.scan_mcp !== false) tools.push(...collectMCPServers(cwd || process.cwd()));
266
+ if (cfg.scan_plugins !== false) tools.push(...collectPluginTools());
267
+ if (cfg.scan_builtins !== false) tools.push(...builtinTools());
268
+ } catch { /* never crash the hook */ }
269
+ return tools;
270
+ }
271
+
272
+ module.exports = { collectLocalTools, parseSkillFrontmatter, collectSkills, collectMCPServers, collectPluginTools, builtinTools };