Skip to content

Commit 0d41c19

Browse files
authored
Merge pull request #43 from openagentlock/feat/install-host-side-writes
Feat/install host side writes
2 parents 42d4098 + 7c0030c commit 0d41c19

20 files changed

Lines changed: 1438 additions & 1126 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ Full walkthrough at <https://openagentlock.github.io/OpenAgentLock/guide/getting
5252
| Surface | Status |
5353
|---|---|
5454
| `agentlock detect` | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
55-
| `agentlock install` (Claude Code, Codex CLI) | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
55+
| `agentlock install` (Claude Code, Codex CLI, Cursor) | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
5656
| `agentlock install --tier {unattested,software,totp}` | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
57-
| `agentlock install` (Cursor, OpenCode, Cline, Gemini CLI, Continue, VS Code Copilot) | ![not yet](https://img.shields.io/badge/-not%20yet-f59e0b?style=flat-square) |
57+
| `agentlock install` (OpenCode, Cline, Gemini CLI, Continue, VS Code Copilot) | ![not yet](https://img.shields.io/badge/-not%20yet-f59e0b?style=flat-square) |
5858
| Five baseline gates in monitor mode | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
5959
| Tamper-evident Merkle ledger | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |
6060
| Local web dashboard | ![shipped](https://img.shields.io/badge/-shipped-16a34a?style=flat-square) |

cli/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ The CLI talks to a local control plane (Go service in Docker, port 7878). Pull a
1212

1313
```bash
1414
docker pull ghcr.io/openagentlock/agentlockd:latest
15-
docker run -d --name agentlock -p 127.0.0.1:7878:7878 ghcr.io/openagentlock/agentlockd:latest
15+
docker run -d --name agentlock \
16+
-p 127.0.0.1:7878:7878 \
17+
-p 127.0.0.1:7879:7879 \
18+
-v agentlock-state:/var/lib/agentlock \
19+
ghcr.io/openagentlock/agentlockd:latest
1620
```
1721

1822
Then use the CLI:
@@ -23,6 +27,8 @@ agentlock install # plan + apply hooks for selected harnesses
2327
agentlock status # control-plane health
2428
```
2529

30+
For attested install (TOTP, hardware signers) and policies, see [openagentlock.github.io/OpenAgentLock](https://openagentlock.github.io/OpenAgentLock/).
31+
2632
Full documentation: <https://openagentlock.github.io/OpenAgentLock/>
2733

2834
Source: <https://github.com/openagentlock/OpenAgentLock>

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openagentlock/cli",
3-
"version": "0.1.12",
3+
"version": "0.1.14",
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.",

cli/src/commands/install.ts

Lines changed: 78 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ import { detectAll } from "../detect/index.ts";
3232
import type { HarnessId } from "../detect/types.ts";
3333
import { multiselect } from "../tui/multiselect.tsx";
3434
import { apiClient, type InstallFileOp } from "../util/api.ts";
35+
import {
36+
checkSafeTarget,
37+
executeFileOps,
38+
executeUninstallOps,
39+
readExistingFiles,
40+
} from "../util/install-fs.ts";
3541
import { home } from "../util/paths.ts";
3642
import { mintAttestedSession, type AttestedTier } from "../util/session-mint.ts";
3743

@@ -262,35 +268,16 @@ export async function runInstall(argv: string[] = []): Promise<void> {
262268
const daemonUrl = flags.daemonUrl ?? api.baseUrl;
263269

264270
// 3.5. Capabilities probe -------------------------------------------
265-
// Read-only check against the daemon so we fail fast — before fetching
266-
// the plan, before showing y/N — when apply is disabled or when the
267-
// daemon's view of the host filesystem won't match the user's. Older
268-
// daemons (pre-0.1.10) don't expose this endpoint; treat 404/network
269-
// errors as "unknown" and fall through to the existing post-action
270-
// error handling.
271+
// The daemon no longer writes host files (the CLI does), so there's
272+
// no apply / real-home gate to check. We still call the endpoint so
273+
// unattested-disabled daemons surface early — but the check above
274+
// (createUnattestedSession) already covers that case for tier=unattested.
275+
// Older daemons (pre-0.1.10) don't expose the endpoint; ignore.
271276
try {
272-
const caps = await api.installCapabilities();
273-
if (!caps.apply_enabled) {
274-
process.stderr.write(
275-
"\ninstall apply is disabled on this daemon.\n" +
276-
"restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
277-
"see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n",
278-
);
279-
process.exitCode = 2;
280-
return;
281-
}
282-
if (caps.container && !flags.configDirOverride) {
283-
process.stdout.write(
284-
"\nwarning: daemon is running in a container.\n" +
285-
" install will reference your host paths (e.g. ~/.claude). For\n" +
286-
" writes to actually land on the host, the daemon must have\n" +
287-
" same-path bind mounts (e.g. -v $HOME/.claude:$HOME/.claude).\n" +
288-
" see https://openagentlock.github.io/OpenAgentLock/guide/installation/\n",
289-
);
290-
}
277+
await api.installCapabilities();
291278
} catch {
292279
// Probe failed — older daemon or transient. Continue; downstream
293-
// calls will surface specific errors with the same hints.
280+
// calls will surface specific errors.
294281
}
295282

296283
// 3a. Per-harness uninstall for rows the user just deselected. Runs
@@ -300,12 +287,26 @@ export async function runInstall(argv: string[] = []): Promise<void> {
300287
process.stdout.write(
301288
`\nuninstalling deselected harnesses: ${toUninstall.join(", ")}\n`,
302289
);
290+
// Pass the current contents of every per-harness file so the daemon
291+
// can compute the post-strip bytes without reading host paths.
292+
const uninstallPaths: string[] = [];
293+
for (const id of toUninstall) {
294+
const dir = hostConfigDirs[id];
295+
if (!dir) continue;
296+
if (id === "claude-code") {
297+
uninstallPaths.push(resolve(join(dir, "settings.json")));
298+
} else if (id === "codex" || id === "cursor") {
299+
uninstallPaths.push(resolve(join(dir, "hooks.json")));
300+
}
301+
}
302+
const uninstallExisting = await readExistingFiles(uninstallPaths);
303303
try {
304304
const u = await api.installUninstallHarnesses({
305305
session_id: sessionId,
306306
harnesses: toUninstall,
307307
config_dir_override: flags.configDirOverride,
308308
harness_config_dirs: hostConfigDirs,
309+
existing_files: uninstallExisting,
309310
});
310311
for (const op of u.operations) {
311312
const note = op.error ? ` ERROR: ${op.error}` : "";
@@ -317,18 +318,13 @@ export async function runInstall(argv: string[] = []): Promise<void> {
317318
process.stderr.write(
318319
`\n${u.failures} uninstall op(s) failed; see above. Continuing with install.\n`,
319320
);
321+
} else {
322+
// Execute the strip / remove ops on the host now that the daemon
323+
// has signed the diff into the ledger.
324+
await executeUninstallOps(u.operations);
320325
}
321326
} catch (err) {
322327
const msg = (err as Error).message;
323-
if (msg.includes("apply_disabled")) {
324-
process.stderr.write(
325-
"\nuninstall is disabled on this daemon.\n" +
326-
"restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
327-
"see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n",
328-
);
329-
process.exitCode = 2;
330-
return;
331-
}
332328
process.stderr.write(`\nuninstall failed: ${msg}\n`);
333329
// Continue: a failed uninstall shouldn't block re-installing the
334330
// ones the user kept selected.
@@ -340,6 +336,22 @@ export async function runInstall(argv: string[] = []): Promise<void> {
340336
return;
341337
}
342338

339+
// Read every host file the daemon needs to merge against, so the plan
340+
// ops carry the final byte-for-byte content the CLI will write. Missing
341+
// files are silently skipped (readExistingFiles drops ENOENT).
342+
const claudeSettings = resolve(
343+
join(hostConfigDirs["claude-code"], "settings.json"),
344+
);
345+
const codexHooks = resolve(join(hostConfigDirs["codex"], "hooks.json"));
346+
const codexConfig = resolve(join(hostConfigDirs["codex"], "config.toml"));
347+
const cursorHooks = resolve(join(hostConfigDirs["cursor"], "hooks.json"));
348+
const existingFiles = await readExistingFiles([
349+
claudeSettings,
350+
codexHooks,
351+
codexConfig,
352+
cursorHooks,
353+
]);
354+
343355
const planReq = {
344356
session_id: sessionId,
345357
harnesses: chosen,
@@ -351,6 +363,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
351363
// override (e.g. point at the compiled single-file binary).
352364
agentlock_binary: process.env.AGENTLOCK_BINARY ?? defaultAgentlockBinary(),
353365
harness_config_dirs: hostConfigDirs,
366+
existing_files: existingFiles,
354367
};
355368

356369
// 4. Plan dry-run ------------------------------------------------------
@@ -404,7 +417,36 @@ export async function runInstall(argv: string[] = []): Promise<void> {
404417
}
405418
}
406419

420+
// 5.5. Safety check + execute on the host ---------------------------
421+
// The plan was returned by the daemon but it never touched disk. We
422+
// refuse paths that don't resolve under one of the real harness home
423+
// subtrees unless the caller explicitly opted into a dev sandbox via
424+
// --config-dir or AGENTLOCK_DEV_HOME.
425+
const bypass =
426+
!!flags.configDirOverride || !!process.env.AGENTLOCK_DEV_HOME;
427+
try {
428+
for (const op of plan.operations) {
429+
checkSafeTarget(op.path, { bypass });
430+
}
431+
} catch (err) {
432+
process.stderr.write(`\n${(err as Error).message}\n`);
433+
process.stderr.write(
434+
"use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor) for dev runs.\n",
435+
);
436+
process.exitCode = 2;
437+
return;
438+
}
439+
try {
440+
await executeFileOps(plan.operations);
441+
} catch (err) {
442+
process.stderr.write(`\nfile write failed: ${(err as Error).message}\n`);
443+
process.exitCode = 2;
444+
return;
445+
}
446+
407447
// 6. Apply -------------------------------------------------------------
448+
// Files are already on disk; this call records the manifest + signs
449+
// the install into the ledger.
408450
process.stdout.write("\napplying...\n");
409451
try {
410452
const result = await api.installApply(planReq);
@@ -416,21 +458,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
416458
}
417459
} catch (err) {
418460
const msg = (err as Error).message;
419-
if (msg.includes("apply_disabled")) {
420-
process.stderr.write(
421-
"\ninstall apply is disabled on this daemon.\n" +
422-
"restart the daemon with -e AGENTLOCK_ALLOW_APPLY=1 added.\n" +
423-
"see https://openagentlock.github.io/OpenAgentLock/guide/daemon-flags/\n",
424-
);
425-
} else if (msg.includes("unsafe_target")) {
426-
process.stderr.write(
427-
"\ndaemon refused to write to a path under real ~/.claude, ~/.codex, or ~/.cursor.\n" +
428-
"use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor) for dev runs, or set\n" +
429-
"AGENTLOCK_ALLOW_APPLY_REAL_HOME=1 on the daemon for real installs.\n",
430-
);
431-
} else {
432-
process.stderr.write(`\napply failed: ${msg}\n`);
433-
}
461+
process.stderr.write(`\napply failed: ${msg}\n`);
434462
process.exitCode = 2;
435463
return;
436464
}

cli/src/util/api.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ export interface ApiClient {
2121
installCapabilities(): Promise<InstallCapabilitiesResponse>;
2222
installPlan(req: InstallPlanRequest): Promise<InstallPlanResponse>;
2323
installApply(req: InstallPlanRequest): Promise<InstallApplyResponse>;
24-
installUninstall(sessionId: string): Promise<InstallUninstallResponse>;
24+
installUninstall(req: {
25+
session_id: string;
26+
existing_files?: Record<string, string>;
27+
}): Promise<InstallUninstallResponse>;
2528
installUninstallHarnesses(req: {
2629
session_id: string;
2730
harnesses: string[];
2831
config_dir_override?: string;
2932
harness_config_dirs?: Record<string, string>;
33+
existing_files?: Record<string, string>;
3034
}): Promise<InstallUninstallResponse>;
3135
listSessions(): Promise<SessionsListResponse>;
3236
getMode(): Promise<ModeResponse>;
@@ -112,11 +116,15 @@ export interface InstallPlanRequest {
112116
// makes the install flow honest under Docker, where the daemon's HOME
113117
// is /home/nonroot but the user expects writes under their real home.
114118
harness_config_dirs?: Record<string, string>;
119+
// Existing host file contents the daemon needs to merge against when
120+
// computing ops. Keys are absolute paths; missing keys mean "file does
121+
// not exist." The CLI populates this for ~/.claude/settings.json,
122+
// ~/.codex/hooks.json, ~/.codex/config.toml, and ~/.cursor/hooks.json
123+
// so the daemon never has to read host paths itself.
124+
existing_files?: Record<string, string>;
115125
}
116126

117127
export interface InstallCapabilitiesResponse {
118-
apply_enabled: boolean;
119-
real_home_allowed: boolean;
120128
unattested_allowed: boolean;
121129
container: boolean;
122130
}
@@ -151,6 +159,10 @@ export interface InstallUninstallOp {
151159
op: string;
152160
path: string;
153161
entries_removed: number;
162+
// Post-strip file contents the CLI should write back to `path`.
163+
// Empty when the file had no agentlock entries (the CLI then leaves
164+
// the file untouched).
165+
content?: string;
154166
error?: string;
155167
}
156168

@@ -420,11 +432,14 @@ export function apiClient(baseUrl?: string, initialToken?: string | null): ApiCl
420432
return (await res.json()) as InstallApplyResponse;
421433
},
422434

423-
async installUninstall(sessionId: string): Promise<InstallUninstallResponse> {
435+
async installUninstall(req: {
436+
session_id: string;
437+
existing_files?: Record<string, string>;
438+
}): Promise<InstallUninstallResponse> {
424439
const res = await fetch(`${url}/v1/install/uninstall`, {
425440
method: "POST",
426441
headers: { "Content-Type": "application/json", ...authHeaders() },
427-
body: JSON.stringify({ session_id: sessionId }),
442+
body: JSON.stringify(req),
428443
});
429444
if (!res.ok && res.status !== 207) {
430445
const body = await res.text();
@@ -440,6 +455,7 @@ export function apiClient(baseUrl?: string, initialToken?: string | null): ApiCl
440455
harnesses: string[];
441456
config_dir_override?: string;
442457
harness_config_dirs?: Record<string, string>;
458+
existing_files?: Record<string, string>;
443459
}): Promise<InstallUninstallResponse> {
444460
const res = await fetch(`${url}/v1/install/uninstall-harnesses`, {
445461
method: "POST",

0 commit comments

Comments
 (0)