Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openagentlock/cli",
"version": "0.1.14",
"version": "0.1.15",
"type": "module",
"license": "SEE LICENSE IN LICENSE",
"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.",
Expand Down Expand Up @@ -43,7 +43,8 @@
"detect": "bun run src/index.ts detect",
"install-into": "bun run src/index.ts install",
"typecheck": "tsc --noEmit",
"build": "bun build src/index.ts --target=bun --outdir=dist"
"build": "bun build src/index.ts --target=bun --outdir=dist",
"prepublishOnly": "test -x ./agentlock && npm pack --dry-run --json | node -e \"const m=JSON.parse(require('fs').readFileSync(0,'utf8'));if(!m[0].files.some(f=>f.path==='agentlock'))throw new Error('agentlock wrapper missing from tarball — see 0.1.12 regression');\""
},
"dependencies": {
"@noble/ed25519": "^3.1.0",
Expand Down
25 changes: 12 additions & 13 deletions cli/src/commands/hook-claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
// as `type: "http"` directly at the daemon, every hook fire became a
// browser-style fetch from inside Claude. A daemon outage rendered as a
// red "PreToolUse:Bash hook error / ECONNREFUSED" banner on every tool
// call. Routing through this shim lets us fail-open on transport errors
// (exit 0) and emit a one-time friendly nudge instead — matching the UX
// codex and cursor already get.
// call. Routing through this shim lets us fail-open silently on transport
// errors (exit 0). The user-visible "daemon is down" signal is provided
// out-of-band by Claude Code's `statusLine` config, which the installer
// wires to <agentlockHome>/bin/agentlock-status — that surface lives
// outside the model's input stream so it doesn't read as a prompt-
// injection attempt the way `additionalContext` did in earlier designs.
//
// Wire shape (Claude Code → daemon → Claude Code):
// stdin: Claude's native event JSON (session_id, hook_event_name,
Expand All @@ -19,9 +22,8 @@
// hook was HTTP-typed
// stdout: on deny, the JSON envelope (Claude Code accepts either an
// exit code OR JSON, we emit both for safety)
// exit: 0 on allow / observability, 2 on deny
// exit: 0 on allow / observability / fail-open, 2 on deny

import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";

const ALLOWED_EVENTS = new Set([
"session-start",
Expand Down Expand Up @@ -73,8 +75,6 @@ export async function runHookClaudeCode(argv: string[]): Promise<void> {
process.exit(0);
}

// Validate JSON locally so a malformed body doesn't waste a daemon
// round trip. Pass through verbatim if it parses.
try {
JSON.parse(raw);
} catch (e) {
Expand All @@ -94,14 +94,13 @@ export async function runHookClaudeCode(argv: string[]): Promise<void> {
headers: { "Content-Type": "application/json" },
body: raw,
});
} catch (e) {
warnDaemonDownOnce("claude-code", url, e as Error);
process.exit(0); // fail-open
} catch {
// Daemon unreachable. Silent fail-open — never write to stdout
// (Claude would read it as model-input text). The statusLine UI
// element is the user-visible "daemon offline" signal.
process.exit(0);
}

// Successful round-trip — re-arm the nudge for the next outage.
clearDaemonDownMarker();

if (!res.ok) {
process.stderr.write(
`agentlock hook claude-code ${event}: daemon returned ${res.status}\n`,
Expand Down
27 changes: 17 additions & 10 deletions cli/src/commands/hook-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@
// daemon outage from soft-bricking the user's coding session. The
// daemon-side ledger is the source of truth; if it can't be reached,
// monitor mode is the safer default than blocking everything.

import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";
//
// Codex has no `statusLine` analog (Claude's persistent UI element under
// the chat) and no chat-injection field that's safe from prompt-injection
// flags. Empirically Codex also hides hook stderr on exit-0 — it only
// surfaces hook output as a "(failed)" banner when the hook exits non-
// zero. Which means there's no in-Codex surface we can write a live or
// once-per-session OAL status indicator into without making it look like
// an error. We stay silent here; users wanting a live indicator wire the
// `agentlock-status` script into their shell prompt (visible when they
// start/stop a Codex session) or rely on Claude Code's statusLine in
// parallel chats.

const ALLOWED_EVENTS = new Set([
"session-start",
Expand Down Expand Up @@ -68,8 +77,6 @@ export async function runHookCodex(argv: string[]): Promise<void> {
process.exit(0);
}

// Validate JSON locally so a malformed body doesn't waste a daemon
// round trip. Pass through verbatim if it parses.
try {
JSON.parse(raw);
} catch (e) {
Expand All @@ -88,14 +95,14 @@ export async function runHookCodex(argv: string[]): Promise<void> {
headers: { "Content-Type": "application/json" },
body: raw,
});
} catch (e) {
warnDaemonDownOnce("codex", url, e as Error);
process.exit(0); // fail-open
} catch {
// Daemon unreachable — silent fail-open. No stdout (would land in
// model input). No stderr (Codex would render exit-0 stderr as a
// failed-hook banner if it surfaced it at all, and on success it's
// hidden — so the line would either be alarming or invisible).
process.exit(0);
}

// Successful round-trip — re-arm the nudge for the next outage.
clearDaemonDownMarker();

if (!res.ok) {
process.stderr.write(
`agentlock hook codex ${event}: daemon returned ${res.status}\n`,
Expand Down
21 changes: 10 additions & 11 deletions cli/src/commands/hook-cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
// a fixed reason pointing at the dashboard.
//
// Failure modes are fail-open (exit 0). The daemon's ledger is the source
// of truth; a missing daemon should not soft-brick the user's IDE.
// of truth; a missing daemon should not soft-brick the user's IDE. On a
// transport failure we keep stdout to a plain `{permission: "allow"}`
// envelope — Cursor's hook spec offers no UI surface that's outside the
// model's input stream (no statusLine equivalent), so users wanting a
// live "daemon offline" indicator should rely on Claude Code's statusLine
// or a shell-prompt integration of <agentlockHome>/bin/agentlock-status.
// Operators who want fail-closed semantics can install with
// `failClosed: true` in the wired hook entries — Cursor will then treat
// any exit-code error as a deny regardless of what we write to stdout.

import { clearDaemonDownMarker, warnDaemonDownOnce } from "../util/daemon-warn.ts";

const ALLOWED_EVENTS = new Set([
"session-start",
"pre-tool-use",
Expand Down Expand Up @@ -89,8 +92,6 @@ export async function runHookCursor(argv: string[]): Promise<void> {
process.exit(0);
}

// Validate JSON locally so a malformed body doesn't waste a daemon
// round trip. Pass through verbatim if it parses.
try {
JSON.parse(raw);
} catch (e) {
Expand All @@ -110,14 +111,12 @@ export async function runHookCursor(argv: string[]): Promise<void> {
headers: { "Content-Type": "application/json" },
body: raw,
});
} catch (e) {
warnDaemonDownOnce("cursor", url, e as Error);
process.exit(0); // fail-open
} catch {
// Daemon unreachable — silent fail-open. Cursor has no UI surface
// outside the model's input stream we can write to safely.
emitAllow();
}

// Successful round-trip — re-arm the nudge for the next outage.
clearDaemonDownMarker();

if (!res.ok) {
process.stderr.write(
`agentlock hook cursor ${event}: daemon returned ${res.status}\n`,
Expand Down
76 changes: 61 additions & 15 deletions cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// For multi-harness dev runs prefer AGENTLOCK_DEV_HOME=./dev which
// re-roots every detector AND the daemon's apply paths.

import { existsSync } from "node:fs";
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";

import { detectAll } from "../detect/index.ts";
Expand All @@ -38,18 +38,58 @@ import {
executeUninstallOps,
readExistingFiles,
} from "../util/install-fs.ts";
import { home } from "../util/paths.ts";
import { binDir, home, isWin } from "../util/paths.ts";
import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts";

// Source-tree default: cli/agentlock is a bash wrapper that does
// `exec bun run cli/src/index.ts "$@"`, so harnesses can spawn
// `agentlock hook codex <event>` without needing a compiled binary.
// `process.execPath` alone points at `bun`, which crashes (exit 1) when
// invoked as `bun hook codex pre-tool-use` because `hook` isn't a script.
function defaultAgentlockBinary(): string {
const wrapper = resolve(import.meta.dir, "..", "..", "agentlock");
if (existsSync(wrapper)) return wrapper;
return process.execPath;
// Stable wrapper path: <agentlockHome>/bin/agentlock. Lives in our state dir,
// not in the package manager's volatile node_modules tree, so package
// upgrades / reinstalls don't strand the wired hook command at a path the
// shell can't spawn (which renders as red "PreToolUse hook error" banners
// in Claude Code, and similar in Cursor / Codex). Re-running `agentlock
// install` rewrites the wrapper, picking up any new index.ts location.
//
// The wrapper itself is bash-only — Windows wiring lands separately.
export function installAndResolveAgentlockBinary(): string {
if (isWin()) {
throw new Error(
"agentlock install: Windows wrapper not yet supported. Use macOS/Linux for now.",
);
}
const indexPath = resolve(import.meta.dir, "..", "index.ts");
const dir = binDir();
const wrapper = join(dir, "agentlock");
const body = `#!/usr/bin/env bash\nexec bun run "${indexPath}" "$@"\n`;
mkdirSync(dir, { recursive: true });
writeFileSync(wrapper, body, { flag: "w" });
chmodSync(wrapper, 0o755);
return wrapper;
}

// Tiny health-check script wired into Claude Code's `statusLine` config.
// Output renders as a UI element under the chat — never injected into the
// model's input stream — so the user sees live "is the daemon up?" without
// a prompt-injection vector. Curl with a 200ms timeout keeps the status
// line snappy; a hung daemon fails to "offline" instead of stalling the UI.
export function installStatusLineScript(): string {
if (isWin()) {
throw new Error(
"agentlock install: Windows status-line not yet supported. Use macOS/Linux for now.",
);
}
const dir = binDir();
const script = join(dir, "agentlock-status");
const body = `#!/usr/bin/env bash
url="\${AGENTLOCK_DAEMON_URL:-http://127.0.0.1:7878}"
if curl --max-time 1 -fs "$url/v1/health" >/dev/null 2>&1; then
printf 'OpenAgentLock \\xe2\\x9c\\x93'
else
printf 'OpenAgentLock \\xe2\\x9a\\xa0 daemon offline'
fi
`;
mkdirSync(dir, { recursive: true });
writeFileSync(script, body, { flag: "w" });
chmodSync(script, 0o755);
return script;
}

type InstallTier = "unattested" | AttestedTier;
Expand Down Expand Up @@ -352,16 +392,22 @@ export async function runInstall(argv: string[] = []): Promise<void> {
cursorHooks,
]);

// Write the status-line script alongside the binary wrapper. Daemon
// wires this path into ~/.claude/settings.json `statusLine` so users
// see live OAL health without any chat injection.
const statusLineScript = installStatusLineScript();

const planReq = {
session_id: sessionId,
harnesses: chosen,
daemon_url: daemonUrl,
config_dir_override: flags.configDirOverride,
// Pass an absolute path so Codex's command-hook spawn doesn't depend
// on PATH at hook-fire time. Source-tree dev runs use the
// `cli/agentlock` wrapper; AGENTLOCK_BINARY lets release builds
// override (e.g. point at the compiled single-file binary).
agentlock_binary: process.env.AGENTLOCK_BINARY ?? defaultAgentlockBinary(),
// on PATH at hook-fire time. The wrapper lives under agentlockHome()
// so it survives package-manager upgrades; AGENTLOCK_BINARY lets
// release builds override (e.g. point at a compiled single-file binary).
agentlock_binary: process.env.AGENTLOCK_BINARY ?? installAndResolveAgentlockBinary(),
status_line_script: statusLineScript,
harness_config_dirs: hostConfigDirs,
existing_files: existingFiles,
};
Expand Down
60 changes: 0 additions & 60 deletions cli/src/util/daemon-warn.ts

This file was deleted.

5 changes: 5 additions & 0 deletions cli/src/util/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export function agentlockHome(): string {
return process.env.AGENTLOCK_HOME ?? join(appSupport(), "OpenAgentLock");
}

/** Stable wrapper-script home; survives package upgrades that move node_modules. */
export function binDir(): string {
return join(agentlockHome(), "bin");
}

/** VS Code user dir (extension globalStorage lives under this). */
export function vscodeUserDir(): string | null {
if (isMac()) return join(home(), "Library", "Application Support", "Code", "User");
Expand Down
Loading
Loading