Skip to content

Commit 42d4098

Browse files
authored
Merge pull request #41 from openagentlock/feat/cursor-harness
Feat/cursor harness
2 parents ba48ff6 + 7a6c1b4 commit 42d4098

25 files changed

Lines changed: 2493 additions & 106 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ dev/
3939

4040
# CLI build
4141
cli/dist/
42-
cli/agentlock
4342

4443
# OS
4544
Thumbs.db
@@ -54,3 +53,4 @@ Thumbs.db
5453

5554
# Auto-generated by editor tooling
5655
graphify-out/
56+
cli/openagentlock-cli-*.tgz

cli/agentlock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env bash
2+
exec bun run "$(dirname "$0")/src/index.ts" "$@"

cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openagentlock/cli",
3-
"version": "0.1.11",
3+
"version": "0.1.12",
44
"type": "module",
55
"license": "SEE LICENSE IN LICENSE",
66
"description": "OpenAgentLock CLI — a firewall for AI coding agents. Detects local agent harnesses (Claude Code, Codex CLI, Cursor, OpenCode, Cline, Gemini CLI, Continue, Copilot), gates risky tool calls via a Go control plane, anchors decisions in a Rust Merkle ledger.",
@@ -33,6 +33,7 @@
3333
},
3434
"files": [
3535
"src/",
36+
"agentlock",
3637
"tsconfig.json",
3738
"README.md",
3839
"LICENSE"
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// `agentlock hook claude-code <event>` — Claude Code's command-hook shim.
2+
// Mirrors the codex/cursor pattern: stdin JSON in, daemon round-trip,
3+
// exit code + (on deny) reason on stderr.
4+
//
5+
// Why a shim instead of Claude's native HTTP hooks: when Claude was wired
6+
// as `type: "http"` directly at the daemon, every hook fire became a
7+
// browser-style fetch from inside Claude. A daemon outage rendered as a
8+
// red "PreToolUse:Bash hook error / ECONNREFUSED" banner on every tool
9+
// call. Routing through this shim lets us fail-open on transport errors
10+
// (exit 0) and emit a one-time friendly nudge instead — matching the UX
11+
// codex and cursor already get.
12+
//
13+
// Wire shape (Claude Code → daemon → Claude Code):
14+
// stdin: Claude's native event JSON (session_id, hook_event_name,
15+
// tool_name, tool_input, tool_use_id, cwd)
16+
// POST: /v1/hooks/claude-code/<event> (raw payload forwarded verbatim)
17+
// reply: daemon emits claudeHookOutput (continue/stopReason/
18+
// hookSpecificOutput) — the same shape Claude consumed when the
19+
// hook was HTTP-typed
20+
// stdout: on deny, the JSON envelope (Claude Code accepts either an
21+
// exit code OR JSON, we emit both for safety)
22+
// exit: 0 on allow / observability, 2 on deny
23+
24+
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
25+
26+
const ALLOWED_EVENTS = new Set([
27+
"session-start",
28+
"pre-tool-use",
29+
"post-tool-use",
30+
"stop",
31+
]);
32+
33+
interface ClaudeHookSpecifics {
34+
hookEventName?: string;
35+
permissionDecision?: "allow" | "deny" | "ask";
36+
permissionDecisionReason?: string;
37+
}
38+
39+
interface DaemonResponse {
40+
continue?: boolean;
41+
stopReason?: string;
42+
hookSpecificOutput?: ClaudeHookSpecifics;
43+
}
44+
45+
function defaultDaemonUrl(): string {
46+
return (
47+
process.env.AGENTLOCK_DAEMON_URL ??
48+
process.env.AGENTLOCK_CONTROL_PLANE_URL ??
49+
"http://127.0.0.1:7878"
50+
);
51+
}
52+
53+
async function readStdin(): Promise<string> {
54+
const chunks: Buffer[] = [];
55+
for await (const chunk of process.stdin) {
56+
chunks.push(chunk as Buffer);
57+
}
58+
return Buffer.concat(chunks).toString("utf8");
59+
}
60+
61+
export async function runHookClaudeCode(argv: string[]): Promise<void> {
62+
const event = argv[0];
63+
if (!event || !ALLOWED_EVENTS.has(event)) {
64+
process.stderr.write(
65+
`usage: agentlock hook claude-code <session-start|pre-tool-use|post-tool-use|stop>\n`,
66+
);
67+
process.exit(2);
68+
}
69+
70+
const raw = await readStdin();
71+
if (!raw.trim()) {
72+
// No payload — nothing to forward. Fail-open: let Claude continue.
73+
process.exit(0);
74+
}
75+
76+
// Validate JSON locally so a malformed body doesn't waste a daemon
77+
// round trip. Pass through verbatim if it parses.
78+
try {
79+
JSON.parse(raw);
80+
} catch (e) {
81+
process.stderr.write(
82+
`agentlock hook claude-code ${event}: invalid JSON on stdin: ${(e as Error).message}\n`,
83+
);
84+
// Fail-open: invalid payload is the harness's bug, not policy.
85+
process.exit(0);
86+
}
87+
88+
const url =
89+
defaultDaemonUrl().replace(/\/+$/, "") + `/v1/hooks/claude-code/${event}`;
90+
let res: Response;
91+
try {
92+
res = await fetch(url, {
93+
method: "POST",
94+
headers: { "Content-Type": "application/json" },
95+
body: raw,
96+
});
97+
} catch (e) {
98+
warnDaemonDownOnce("claude-code", url, e as Error);
99+
process.exit(0); // fail-open
100+
}
101+
102+
// Successful round-trip — re-arm the nudge for the next outage.
103+
clearDaemonDownMarker();
104+
105+
if (!res.ok) {
106+
process.stderr.write(
107+
`agentlock hook claude-code ${event}: daemon returned ${res.status}\n`,
108+
);
109+
process.exit(0); // fail-open
110+
}
111+
112+
let parsed: DaemonResponse;
113+
try {
114+
parsed = (await res.json()) as DaemonResponse;
115+
} catch (e) {
116+
process.stderr.write(
117+
`agentlock hook claude-code ${event}: malformed daemon response: ${(e as Error).message}\n`,
118+
);
119+
process.exit(0);
120+
}
121+
122+
// SessionStart / PostToolUse / Stop are observability — the daemon
123+
// returns {continue: true} unconditionally. Only PreToolUse can deny.
124+
const decision = parsed.hookSpecificOutput?.permissionDecision;
125+
if (decision === "deny") {
126+
const reason =
127+
parsed.hookSpecificOutput?.permissionDecisionReason ??
128+
parsed.stopReason ??
129+
"blocked by OpenAgentLock policy";
130+
process.stderr.write(`${reason}\n`);
131+
// Claude Code reads either the JSON body OR the exit code. Emit both
132+
// so future Claude versions that prefer one over the other still see
133+
// a deny.
134+
process.stdout.write(JSON.stringify(parsed) + "\n");
135+
process.exit(2);
136+
}
137+
138+
// Allow path. Some Claude events don't carry hookSpecificOutput; just
139+
// exit 0 silently. Stdout stays empty so we don't perturb Claude's
140+
// own logging.
141+
process.exit(0);
142+
}

cli/src/commands/hook-codex.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
// daemon-side ledger is the source of truth; if it can't be reached,
1717
// monitor mode is the safer default than blocking everything.
1818

19+
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
20+
1921
const ALLOWED_EVENTS = new Set([
2022
"session-start",
2123
"pre-tool-use",
@@ -87,12 +89,13 @@ export async function runHookCodex(argv: string[]): Promise<void> {
8789
body: raw,
8890
});
8991
} catch (e) {
90-
process.stderr.write(
91-
`agentlock hook codex ${event}: daemon unreachable at ${url}: ${(e as Error).message}\n`,
92-
);
92+
warnDaemonDownOnce("codex", url, e as Error);
9393
process.exit(0); // fail-open
9494
}
9595

96+
// Successful round-trip — re-arm the nudge for the next outage.
97+
clearDaemonDownMarker();
98+
9699
if (!res.ok) {
97100
process.stderr.write(
98101
`agentlock hook codex ${event}: daemon returned ${res.status}\n`,

cli/src/commands/hook-cursor.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// `agentlock hook cursor <event>` — Cursor IDE's command-hook bridge to
2+
// the daemon. Cursor (≥1.7) spawns this binary, writes the hook payload
3+
// to stdin, and reads exit code + stdout JSON to decide whether the tool
4+
// call (or shell exec, or MCP call) proceeds.
5+
//
6+
// Wire shape (Cursor → daemon → Cursor):
7+
// stdin: Cursor's native event JSON (e.g. preToolUse with
8+
// conversation_id / generation_id / tool_use_id)
9+
// POST: /v1/hooks/cursor/<event> (raw payload forwarded verbatim)
10+
// reply: daemon emits the shared claudeHookOutput envelope
11+
// stdout: Cursor's expected {permission, agent_message?} shape
12+
// exit: 0 on allow, 2 on deny — Cursor reads either, both for safety
13+
//
14+
// Cursor exposes a dedicated `ask` permission, but ADR 0018 forbids
15+
// in-harness approval prompts. We map daemon `ask` → Cursor `deny` with
16+
// a fixed reason pointing at the dashboard.
17+
//
18+
// Failure modes are fail-open (exit 0). The daemon's ledger is the source
19+
// of truth; a missing daemon should not soft-brick the user's IDE.
20+
// Operators who want fail-closed semantics can install with
21+
// `failClosed: true` in the wired hook entries — Cursor will then treat
22+
// any exit-code error as a deny regardless of what we write to stdout.
23+
24+
import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
25+
26+
const ALLOWED_EVENTS = new Set([
27+
"session-start",
28+
"pre-tool-use",
29+
"before-shell-execution",
30+
"before-mcp-execution",
31+
"after-mcp-execution",
32+
"post-tool-use",
33+
"stop",
34+
]);
35+
36+
interface ClaudeHookSpecifics {
37+
hookEventName?: string;
38+
permissionDecision?: "allow" | "deny" | "ask";
39+
permissionDecisionReason?: string;
40+
}
41+
42+
interface DaemonResponse {
43+
continue?: boolean;
44+
stopReason?: string;
45+
hookSpecificOutput?: ClaudeHookSpecifics;
46+
}
47+
48+
function defaultDaemonUrl(): string {
49+
return (
50+
process.env.AGENTLOCK_DAEMON_URL ??
51+
process.env.AGENTLOCK_CONTROL_PLANE_URL ??
52+
"http://127.0.0.1:7878"
53+
);
54+
}
55+
56+
async function readStdin(): Promise<string> {
57+
const chunks: Buffer[] = [];
58+
for await (const chunk of process.stdin) {
59+
chunks.push(chunk as Buffer);
60+
}
61+
return Buffer.concat(chunks).toString("utf8");
62+
}
63+
64+
function emitAllow(): never {
65+
process.stdout.write(JSON.stringify({ permission: "allow" }) + "\n");
66+
process.exit(0);
67+
}
68+
69+
function emitDeny(reason: string): never {
70+
process.stderr.write(`${reason}\n`);
71+
process.stdout.write(
72+
JSON.stringify({ permission: "deny", agent_message: reason }) + "\n",
73+
);
74+
process.exit(2);
75+
}
76+
77+
export async function runHookCursor(argv: string[]): Promise<void> {
78+
const event = argv[0];
79+
if (!event || !ALLOWED_EVENTS.has(event)) {
80+
process.stderr.write(
81+
`usage: agentlock hook cursor <session-start|pre-tool-use|before-shell-execution|before-mcp-execution|after-mcp-execution|post-tool-use|stop>\n`,
82+
);
83+
process.exit(2);
84+
}
85+
86+
const raw = await readStdin();
87+
if (!raw.trim()) {
88+
// No payload — nothing to forward. Fail-open: let Cursor continue.
89+
process.exit(0);
90+
}
91+
92+
// Validate JSON locally so a malformed body doesn't waste a daemon
93+
// round trip. Pass through verbatim if it parses.
94+
try {
95+
JSON.parse(raw);
96+
} catch (e) {
97+
process.stderr.write(
98+
`agentlock hook cursor ${event}: invalid JSON on stdin: ${(e as Error).message}\n`,
99+
);
100+
// Fail-open: invalid payload is the harness's bug, not policy.
101+
process.exit(0);
102+
}
103+
104+
const url =
105+
defaultDaemonUrl().replace(/\/+$/, "") + `/v1/hooks/cursor/${event}`;
106+
let res: Response;
107+
try {
108+
res = await fetch(url, {
109+
method: "POST",
110+
headers: { "Content-Type": "application/json" },
111+
body: raw,
112+
});
113+
} catch (e) {
114+
warnDaemonDownOnce("cursor", url, e as Error);
115+
process.exit(0); // fail-open
116+
}
117+
118+
// Successful round-trip — re-arm the nudge for the next outage.
119+
clearDaemonDownMarker();
120+
121+
if (!res.ok) {
122+
process.stderr.write(
123+
`agentlock hook cursor ${event}: daemon returned ${res.status}\n`,
124+
);
125+
process.exit(0); // fail-open
126+
}
127+
128+
let parsed: DaemonResponse;
129+
try {
130+
parsed = (await res.json()) as DaemonResponse;
131+
} catch (e) {
132+
process.stderr.write(
133+
`agentlock hook cursor ${event}: malformed daemon response: ${(e as Error).message}\n`,
134+
);
135+
process.exit(0);
136+
}
137+
138+
// Only PreToolUse / shell / MCP gates can deny. Observability events
139+
// (session-start, post-tool-use, after-mcp-execution, stop) come back
140+
// with {continue: true} and no permissionDecision — exit 0 silently.
141+
const decision = parsed.hookSpecificOutput?.permissionDecision;
142+
if (decision === "deny") {
143+
const reason =
144+
parsed.hookSpecificOutput?.permissionDecisionReason ??
145+
parsed.stopReason ??
146+
"blocked by OpenAgentLock policy";
147+
emitDeny(reason);
148+
}
149+
if (decision === "ask") {
150+
// ADR 0018: no in-harness approval prompts. Map to deny with a
151+
// fixed reason directing the user to the dashboard.
152+
emitDeny("approval pending — use dashboard");
153+
}
154+
155+
// Allow path. Some Cursor events don't carry hookSpecificOutput at
156+
// all (the daemon returns plain {continue: true}); treat that the
157+
// same as an explicit allow.
158+
if (decision === "allow") {
159+
emitAllow();
160+
}
161+
process.exit(0);
162+
}

0 commit comments

Comments
 (0)