@guardion/guardion 0.2.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.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guardion Claude Code hook — monitoring-only event forwarder.
4
+ *
5
+ * Reads hook event JSON from stdin, resolves config + token, posts to Guard API.
6
+ * Always exits 0 — never blocks Claude Code.
7
+ *
8
+ * Config (in priority order):
9
+ * ~/.guardion/config.json written by `npx guardion init`
10
+ * GUARDION_API_URL env override for CI / testing
11
+ * GUARDION_POLICY env override for CI / testing
12
+ *
13
+ * Token (first match wins):
14
+ * GUARDION_TOKEN env CI / testing
15
+ * macOS Keychain service=guardion, account=token
16
+ * /etc/guardion/token enterprise MDM, root-owned
17
+ * ~/.guardion/token user-level fallback
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
+ // ── Constants ────────────────────────────────────────────────────────────────
29
+
30
+ const HOOK_TIMEOUT_MS = 3000;
31
+ const KEYCHAIN_TIMEOUT = 1000;
32
+ const GUARDION_DIR = path.join(os.homedir(), '.guardion');
33
+ const CONFIG_JSON_PATH = path.join(GUARDION_DIR, 'config.json');
34
+ const SESSION_DIR = path.join(GUARDION_DIR, 'sessions');
35
+ const CURRENT_SESSION = path.join(GUARDION_DIR, 'current-session');
36
+ const HOOK_EVENTS_PATH = '/v1/hooks/events';
37
+
38
+ // ── Config ───────────────────────────────────────────────────────────────────
39
+
40
+ function loadConfig() {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(CONFIG_JSON_PATH, 'utf8'));
43
+ } catch {
44
+ return {
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
+ tier: process.env.GUARDION_TIER || 'hooks',
49
+ };
50
+ }
51
+ }
52
+
53
+ // ── Token ────────────────────────────────────────────────────────────────────
54
+
55
+ function resolveToken() {
56
+ if (process.env.GUARDION_TOKEN) return process.env.GUARDION_TOKEN.trim();
57
+
58
+ if (process.platform === 'darwin') {
59
+ try {
60
+ const t = execFileSync(
61
+ 'security',
62
+ ['find-generic-password', '-s', 'guardion', '-a', 'token', '-w'],
63
+ { timeout: KEYCHAIN_TIMEOUT, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
64
+ ).trim();
65
+ if (t) return t;
66
+ } catch { /* not in keychain */ }
67
+ }
68
+
69
+ for (const p of ['/etc/guardion/token', path.join(GUARDION_DIR, 'token')]) {
70
+ try {
71
+ const t = fs.readFileSync(p, 'utf8').trim();
72
+ if (t) return t;
73
+ } catch { /* file absent */ }
74
+ }
75
+
76
+ return '';
77
+ }
78
+
79
+ // ── Session persistence ───────────────────────────────────────────────────────
80
+
81
+ function writeCurrentSession(sessionId) {
82
+ try {
83
+ fs.mkdirSync(GUARDION_DIR, { recursive: true });
84
+ const tmp = CURRENT_SESSION + '.tmp';
85
+ fs.writeFileSync(tmp, sessionId, 'utf8');
86
+ fs.renameSync(tmp, CURRENT_SESSION);
87
+ } catch { /* non-critical */ }
88
+ }
89
+
90
+ function readCurrentSession() {
91
+ try { return fs.readFileSync(CURRENT_SESSION, 'utf8').trim(); } catch { return ''; }
92
+ }
93
+
94
+ function deleteCurrentSession() {
95
+ try { fs.unlinkSync(CURRENT_SESSION); } catch { /* already gone */ }
96
+ }
97
+
98
+ function writeSessionMeta(sessionId, meta) {
99
+ try {
100
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
101
+ fs.writeFileSync(
102
+ path.join(SESSION_DIR, `${sessionId}.json`),
103
+ JSON.stringify(meta, null, 2),
104
+ 'utf8'
105
+ );
106
+ } catch { /* non-critical */ }
107
+ }
108
+
109
+ function deleteSessionMeta(sessionId) {
110
+ if (!sessionId) return;
111
+ try { fs.unlinkSync(path.join(SESSION_DIR, `${sessionId}.json`)); } catch { /* gone */ }
112
+ }
113
+
114
+ // ── HTTP POST ─────────────────────────────────────────────────────────────────
115
+
116
+ function postEvent(apiUrl, token, payload) {
117
+ return new Promise((resolve) => {
118
+ let url;
119
+ try { url = new URL(HOOK_EVENTS_PATH, apiUrl); } catch {
120
+ return resolve();
121
+ }
122
+ const transport = url.protocol === 'https:' ? https : http;
123
+ const body = JSON.stringify(payload);
124
+
125
+ const req = transport.request(
126
+ {
127
+ hostname: url.hostname,
128
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
129
+ path: url.pathname + url.search,
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': 'application/json',
133
+ 'Authorization': `Bearer ${token}`,
134
+ 'Content-Length': Buffer.byteLength(body),
135
+ },
136
+ timeout: HOOK_TIMEOUT_MS,
137
+ },
138
+ (res) => { res.resume(); res.on('end', resolve); }
139
+ );
140
+
141
+ req.on('error', resolve);
142
+ req.on('timeout', () => { req.destroy(); resolve(); });
143
+ req.write(body);
144
+ req.end();
145
+ });
146
+ }
147
+
148
+ // ── Metadata ──────────────────────────────────────────────────────────────────
149
+
150
+ function collectMetadata(payload) {
151
+ try {
152
+ const { collectMetadata: collect } = require('./metadata.cjs');
153
+ return collect(payload);
154
+ } catch {
155
+ return { session_id: payload.session_id || null, cwd: payload.cwd || process.cwd() };
156
+ }
157
+ }
158
+
159
+ // ── Main ──────────────────────────────────────────────────────────────────────
160
+
161
+ // Safety: hard exit after timeout regardless of network state
162
+ setTimeout(() => process.exit(0), HOOK_TIMEOUT_MS + 1500);
163
+
164
+ let rawInput = '';
165
+ process.stdin.setEncoding('utf8');
166
+ process.stdin.on('data', (chunk) => { rawInput += chunk; });
167
+ process.stdin.on('end', async () => {
168
+ let payload;
169
+ try { payload = JSON.parse(rawInput); } catch { return process.exit(0); }
170
+
171
+ const config = loadConfig();
172
+ const token = resolveToken();
173
+ if (!token) return process.exit(0);
174
+
175
+ const event = payload.hook_event_name || payload.event || '';
176
+
177
+ // Enrich payload with config context
178
+ if (config.policy) payload.policy = config.policy;
179
+ if (config.application) payload.application = config.application;
180
+
181
+ if (event === 'SessionStart') {
182
+ const meta = collectMetadata(payload);
183
+ const sessionId = payload.session_id || meta.session_id || `gs-${Date.now()}`;
184
+
185
+ payload.trace_id = sessionId;
186
+ payload.metadata = meta;
187
+
188
+ writeCurrentSession(sessionId);
189
+ writeSessionMeta(sessionId, meta);
190
+ } else if (event === 'SessionEnd') {
191
+ const sessionId = payload.session_id || readCurrentSession();
192
+ payload.trace_id = sessionId;
193
+ deleteCurrentSession();
194
+ deleteSessionMeta(sessionId);
195
+ } else {
196
+ const traceId = process.env.GUARDION_TRACE_ID || readCurrentSession();
197
+ if (traceId) payload.trace_id = traceId;
198
+ }
199
+
200
+ await postEvent(config.api_url, token, payload);
201
+ process.exit(0);
202
+ });
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+
8
+ const GIT_TIMEOUT_MS = 2000;
9
+ const SESSION_FILE_DIR = path.join(os.homedir(), '.claude', 'sessions');
10
+
11
+ function gitCmd(args, cwd) {
12
+ try {
13
+ return execFileSync('git', args, {
14
+ cwd: cwd || process.cwd(),
15
+ timeout: GIT_TIMEOUT_MS,
16
+ encoding: 'utf8',
17
+ stdio: ['pipe', 'pipe', 'pipe'],
18
+ }).trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function readSessionFile() {
25
+ const pids = [process.pid, process.ppid];
26
+ for (const pid of pids) {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(path.join(SESSION_FILE_DIR, `${pid}.json`), 'utf8'));
29
+ } catch { /* try next */ }
30
+ }
31
+ // Fallback: most recent session file
32
+ try {
33
+ const files = fs.readdirSync(SESSION_FILE_DIR)
34
+ .filter(f => f.endsWith('.json'))
35
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(SESSION_FILE_DIR, f)).mtimeMs }))
36
+ .sort((a, b) => b.mtime - a.mtime);
37
+ if (files.length > 0) {
38
+ return JSON.parse(fs.readFileSync(path.join(SESSION_FILE_DIR, files[0].name), 'utf8'));
39
+ }
40
+ } catch { /* dir absent or empty */ }
41
+ return null;
42
+ }
43
+
44
+ function collectMetadata(hookPayload) {
45
+ const cwd = hookPayload.cwd || process.cwd();
46
+ const sessionFile = readSessionFile();
47
+
48
+ const meta = {
49
+ session_id: hookPayload.session_id || sessionFile?.sessionId || null,
50
+ pid: sessionFile?.pid || process.ppid || null,
51
+ cwd,
52
+ started_at: sessionFile?.startedAt || null,
53
+ kind: sessionFile?.kind || null,
54
+ entrypoint: sessionFile?.entrypoint || null,
55
+ };
56
+
57
+ try { meta.os_user = os.userInfo().username; } catch {
58
+ meta.os_user = process.env.USER || process.env.USERNAME || null;
59
+ }
60
+ try { meta.os_uid = process.getuid ? process.getuid() : null; } catch { meta.os_uid = null; }
61
+
62
+ meta.git_user_name = gitCmd(['config', 'user.name'], cwd);
63
+ meta.git_user_email = gitCmd(['config', 'user.email'], cwd);
64
+
65
+ try { meta.hostname = os.hostname(); } catch { meta.hostname = null; }
66
+ meta.platform = process.platform;
67
+ meta.arch = process.arch;
68
+ try { meta.os_version = os.release(); } catch { meta.os_version = null; }
69
+ meta.node_version = process.version;
70
+ meta.shell = process.env.SHELL || null;
71
+
72
+ meta.git_remote = gitCmd(['remote', 'get-url', 'origin'], cwd);
73
+ meta.git_branch = gitCmd(['branch', '--show-current'], cwd);
74
+ meta.git_commit = gitCmd(['rev-parse', '--short', 'HEAD'], cwd);
75
+ const porcelain = gitCmd(['status', '--porcelain'], cwd);
76
+ meta.git_dirty = porcelain !== null ? porcelain.length > 0 : null;
77
+
78
+ // Strip null/undefined to keep payload compact
79
+ for (const k of Object.keys(meta)) {
80
+ if (meta[k] === null || meta[k] === undefined) delete meta[k];
81
+ }
82
+
83
+ return meta;
84
+ }
85
+
86
+ module.exports = { collectMetadata, readSessionFile, gitCmd };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@guardion/guardion",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "AI security monitoring for Claude Code — full agent observability via hooks",
6
+ "bin": {
7
+ "guardion": "dist/cli.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/GuardionAI/guardion.git"
15
+ },
16
+ "homepage": "https://guardion.ai",
17
+ "keywords": [
18
+ "security",
19
+ "monitoring",
20
+ "ai",
21
+ "claude",
22
+ "claude-code",
23
+ "guardrails",
24
+ "hooks",
25
+ "observability",
26
+ "llm"
27
+ ],
28
+ "author": {
29
+ "name": "Guardion AI",
30
+ "email": "rafael@guardion.ai",
31
+ "url": "https://guardion.ai"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "dev": "tsx src/cli.ts",
36
+ "hook": "tsx src/cli.ts hook",
37
+ "mock": "tsx src/cli.ts mock",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:integration": "vitest run --reporter=verbose __tests__/integration"
41
+ },
42
+ "dependencies": {
43
+ "chalk": "^5.3.0",
44
+ "commander": "^12.1.0",
45
+ "express": "^4.21.0",
46
+ "js-yaml": "^4.1.0",
47
+ "zod": "^3.23.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/express": "^4.17.21",
51
+ "@types/js-yaml": "^4.0.9",
52
+ "@types/node": "^22.0.0",
53
+ "tsx": "^4.19.0",
54
+ "typescript": "^5.6.0",
55
+ "vitest": "^4.1.4"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "files": [
61
+ "dist",
62
+ "hooks",
63
+ "plugin.json",
64
+ "config.yaml.example"
65
+ ],
66
+ "license": "MIT"
67
+ }