godcrm/scripts/agent-permission-hook.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

155 lines
5.6 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* ADR-0053 Phase C2 — PreToolUse permission hook for spawned Claude CLI.
*
* Wired into the spawned `claude` process via:
* --settings '{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[
* {"type":"command","command":"node /abs/path/to/scripts/agent-permission-hook.js"}
* ]}]}}'
*
* Contract (Claude Code hooks):
* stdin → JSON: { session_id, transcript_path, cwd, hook_event_name,
* tool_name, tool_input }
* stdout → JSON: { hookSpecificOutput: { hookEventName: "PreToolUse",
* permissionDecision: "allow"|"deny"|"ask",
* permissionDecisionReason: "..." } }
* exit code 0 in all normal paths; exit 2 reserved for hard "fail-closed"
* transport errors (which we DO NOT want — see fail-open below).
*
* Pipeline:
* 1. Parse stdin JSON. Bad input → fail-open allow + stderr log.
* 2. Check CRITICAL_DENIES locally (zero deps, can't be bypassed even if
* the server is down). Match → deny.
* 3. POST to localhost godcrm /api/v3/agent-permissions/check with the
* AGENT_PERMS_TOKEN inherited from parent env. 500ms timeout.
* Network/timeout error → fail-open allow + stderr log.
* 4. Emit hookSpecificOutput JSON, exit 0.
*
* Why fail-open: this hook gates a long-lived background worker. A flaky
* resolver shouldn't break Notion migrations or the Marketing kickoff.
* CRITICAL_DENIES already covers the cases where failing closed matters,
* and they're enforced before the network call. The server-side resolver
* also keeps an audit trail of every decision — denials remain visible.
*
* AGENT_ID / SPACE_ID context: cli-providers.js Phase C3 will set them as
* env vars when spawning claude. Until C3 lands, both are null and the
* resolver falls through to global-scope rules only (which is fine — the
* default-allow + CRITICAL_DENIES cover the safety floor).
*/
// IMPORTANT: keep this file zero-dep beyond Node core. We don't want a hook
// crash because of a stale node_modules in a worktree.
import { matchCriticalDeny } from '../backend/services/agent-permissions/critical-denies.js';
const HOOK_TIMEOUT_MS = 500;
const CHECK_URL = process.env.AGENT_PERMS_CHECK_URL || 'http://127.0.0.1:5000/api/v3/agent-permissions/check';
const TOKEN = process.env.AGENT_PERMS_TOKEN || '';
const AGENT_ID = process.env.AGENT_ID ? Number(process.env.AGENT_ID) : null;
const SPACE_ID = process.env.SPACE_ID ? Number(process.env.SPACE_ID) : null;
function emit(decision, reason) {
// Claude Code hook output schema (PreToolUse).
const out = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: decision, // 'allow' | 'deny' | 'ask'
permissionDecisionReason: reason || '',
},
};
process.stdout.write(JSON.stringify(out));
process.exit(0);
}
function logStderr(msg, extra) {
try {
const line = JSON.stringify({ ts: new Date().toISOString(), hook: 'agent-permission', msg, ...extra });
process.stderr.write(line + '\n');
} catch { /* ignore */ }
}
async function readStdin() {
return new Promise((resolve) => {
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { buf += chunk; });
process.stdin.on('end', () => resolve(buf));
process.stdin.on('error', () => resolve(buf));
});
}
async function postCheck(payload) {
// Use global fetch (Node 18+). Abort on timeout.
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), HOOK_TIMEOUT_MS);
try {
const r = await fetch(CHECK_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-agent-perms-token': TOKEN,
},
body: JSON.stringify(payload),
signal: ctrl.signal,
});
if (!r.ok) {
logStderr('check endpoint non-2xx', { status: r.status });
return null;
}
const j = await r.json();
// success() wraps data: { success: true, data: {...} }
return j?.data || j;
} catch (err) {
logStderr('check endpoint failed', { err: String(err?.message || err) });
return null;
} finally {
clearTimeout(t);
}
}
async function main() {
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
} catch {
logStderr('bad stdin JSON', { rawLen: raw.length });
return emit('allow', 'hook: bad stdin — fail-open');
}
const toolName = String(input.tool_name || '');
const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
// 1. Code-level CRITICAL_DENIES (defense-in-depth before any network call).
const crit = matchCriticalDeny(toolName, toolInput);
if (crit) {
return emit('deny', crit.reason);
}
// 2. DB rules via local server.
if (!TOKEN) {
// No token in env → can't even authenticate to local server. fail-open.
return emit('allow', 'hook: AGENT_PERMS_TOKEN unset — fail-open');
}
const resp = await postCheck({
tool_name: toolName,
tool_input: toolInput,
agent_id: AGENT_ID,
space_id: SPACE_ID,
session_id: input.session_id || null, // ADR-0181: per-loop holder identity for write-reservations
wp_id: process.env.WP_ID || null, // ADR-0181: optional work-package tag (session_id alone suffices)
});
if (!resp || !resp.decision) {
return emit('allow', 'hook: resolver unreachable — fail-open');
}
return emit(resp.decision, resp.reason || '');
}
main().catch((err) => {
logStderr('hook crashed', { err: String(err?.message || err), stack: err?.stack });
// Never let an uncaught crash become a deny.
emit('allow', 'hook: uncaught error — fail-open');
});