@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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/dist/bin/cli.d.ts.map +1 -0
- package/dist/bin/cli.js +590 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/connectors/claude-code/hooks/enforce.cjs +58 -0
- package/dist/connectors/claude-code/hooks/guardion-hook.cjs +355 -0
- package/dist/connectors/claude-code/hooks/tool-scanner.cjs +272 -0
- package/dist/connectors/claude-code/src/collect.d.ts +5 -0
- package/dist/connectors/claude-code/src/collect.d.ts.map +1 -0
- package/dist/connectors/claude-code/src/collect.js +17 -0
- package/dist/connectors/claude-code/src/collect.js.map +1 -0
- package/dist/{installer.d.ts → connectors/claude-code/src/installer.d.ts} +2 -1
- package/dist/connectors/claude-code/src/installer.d.ts.map +1 -0
- package/dist/connectors/claude-code/src/installer.js +190 -0
- package/dist/connectors/claude-code/src/installer.js.map +1 -0
- package/dist/connectors/claude-code/src/scanner.d.ts.map +1 -0
- package/dist/{scanner.js → connectors/claude-code/src/scanner.js} +1 -1
- package/dist/connectors/claude-code/src/scanner.js.map +1 -0
- package/dist/core/config.d.ts +239 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +154 -0
- package/dist/core/config.js.map +1 -0
- package/dist/{constants.d.ts → core/constants.d.ts} +8 -3
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/core/constants.js +54 -0
- package/dist/core/constants.js.map +1 -0
- package/dist/core/discover.d.ts +36 -0
- package/dist/core/discover.d.ts.map +1 -0
- package/dist/core/discover.js +154 -0
- package/dist/core/discover.js.map +1 -0
- package/dist/core/fingerprint.cjs +84 -0
- package/dist/core/inventory.d.ts +35 -0
- package/dist/core/inventory.d.ts.map +1 -0
- package/dist/core/inventory.js +69 -0
- package/dist/core/inventory.js.map +1 -0
- package/dist/core/keychain.d.ts.map +1 -0
- package/dist/{keychain.js → core/keychain.js} +53 -15
- package/dist/core/keychain.js.map +1 -0
- package/dist/core/mcp/guard-client.cjs +86 -0
- package/dist/core/mcp/interceptor.cjs +238 -0
- package/dist/core/mcp/jsonrpc.cjs +194 -0
- package/dist/core/mcp/transport/http-server-side.cjs +89 -0
- package/dist/core/mcp/transport/http-upstream.cjs +111 -0
- package/dist/core/mcp/transport/http_forward.cjs +40 -0
- package/dist/core/mcp/transport/http_input.cjs +46 -0
- package/dist/core/mcp/transport/http_reverse.cjs +33 -0
- package/dist/core/mcp/transport/index.cjs +32 -0
- package/dist/core/mcp/transport/sse_bridge.cjs +101 -0
- package/dist/core/mcp/transport/stdio.cjs +60 -0
- package/dist/core/mcp-interpose.cjs +141 -0
- package/dist/core/mcp-protect.d.ts +69 -0
- package/dist/core/mcp-protect.d.ts.map +1 -0
- package/dist/core/mcp-protect.js +205 -0
- package/dist/core/mcp-protect.js.map +1 -0
- package/dist/core/mcp-scan.d.ts +40 -0
- package/dist/core/mcp-scan.d.ts.map +1 -0
- package/dist/core/mcp-scan.js +201 -0
- package/dist/core/mcp-scan.js.map +1 -0
- package/dist/core/mock-server.d.ts.map +1 -0
- package/dist/{mock-server.js → core/mock-server.js} +60 -4
- package/dist/core/mock-server.js.map +1 -0
- package/package.json +9 -10
- package/config.yaml.example +0 -26
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -289
- package/dist/cli.js.map +0 -1
- package/dist/config.d.ts +0 -28
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -63
- package/dist/config.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -44
- package/dist/constants.js.map +0 -1
- package/dist/installer.d.ts.map +0 -1
- package/dist/installer.js +0 -137
- package/dist/installer.js.map +0 -1
- package/dist/keychain.d.ts.map +0 -1
- package/dist/keychain.js.map +0 -1
- package/dist/mock-server.d.ts.map +0 -1
- package/dist/mock-server.js.map +0 -1
- package/dist/scanner.d.ts.map +0 -1
- package/dist/scanner.js.map +0 -1
- package/hooks/guardion-hook.cjs +0 -202
- /package/dist/{cli.d.ts → bin/cli.d.ts} +0 -0
- /package/dist/{scanner.d.ts → connectors/claude-code/src/scanner.d.ts} +0 -0
- /package/dist/{keychain.d.ts → core/keychain.d.ts} +0 -0
- /package/{hooks → dist/core}/metadata.cjs +0 -0
- /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 };
|