Skip to content

Commit 2ebff48

Browse files
committed
feat(unrestricted): power-user opt-outs for the three soft guards
\"Trust the developer\" wedge: three env vars + a single CLI flag let power users disable the restrictions that exist for new-user safety but are friction in their own context. Defaults stay conservative; opting out is explicit and the CLI prints a yellow banner at session start enumerating what's off. --unrestricted (alias --yolo) sets all three: CODEBASE_NO_PROJECT_ROOT=1 file/shell tools can reach anywhere the running user can (read /etc, cd /var/log, etc.) CODEBASE_NO_VALIDATOR=1 shell tool skips the rm -rf /, dd, fork-bomb hard blocks CODEBASE_NO_READ_BEFORE_WRITE=1 write_file / edit_file proceed without a read-in-same-turn requirement 10 new tests verify the default-on-and-flag-relaxes behavior for each toggle. CLAUDE.md documents the philosophy + the three knobs. Philosophy: most CC paternalism we already avoid. The three guards above are the ones we DO ship because they're load-bearing for new users. But devs on their own machine, with their own model, running their own prompt, deserve a single switch that says \"get out of the way.\" That's what --unrestricted is.
1 parent b5ef811 commit 2ebff48

8 files changed

Lines changed: 233 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,34 @@ SEARXNG_URL self-hosted SearXNG instance
9595

9696
### Behavior toggles
9797
```
98-
CODEBASE_FRESH=1 skip auto-resume of prior session (same as --new)
99-
CODEBASE_NO_SUGGESTIONS=1 disable ghost-text prompt suggestions
100-
CODEBASE_DEBUG=1 verbose stderr logging
101-
CODEBASE_DEBUG_INPUT=1 log every keystroke to ~/.codebase/logs/input.log
102-
NO_HYPERLINK=1 disable OSC 8 clickable file paths
98+
CODEBASE_FRESH=1 skip auto-resume of prior session (same as --new)
99+
CODEBASE_NO_SUGGESTIONS=1 disable ghost-text prompt suggestions
100+
CODEBASE_DEBUG=1 verbose stderr logging
101+
CODEBASE_DEBUG_INPUT=1 log every keystroke to ~/.codebase/logs/input.log
102+
NO_HYPERLINK=1 disable OSC 8 clickable file paths
103103
```
104104

105+
### Unrestricted mode (trust-the-developer escape hatches)
106+
107+
By default the agent has three soft guards. Each one has an opt-out
108+
env var, and `--unrestricted` (alias `--yolo`) sets all three:
109+
110+
```
111+
CODEBASE_NO_PROJECT_ROOT=1 file/shell tools can read/write/cd anywhere
112+
the running user can. Default: clamped to cwd.
113+
CODEBASE_NO_VALIDATOR=1 shell tool skips the rm -rf / dd / fork-bomb
114+
hard blocks. Default: those patterns refuse.
115+
CODEBASE_NO_READ_BEFORE_WRITE=1 write_file / edit_file proceed even when the
116+
model never read the file in this turn.
117+
Default: refused with FileNotReadFirstError.
118+
```
119+
120+
When ANY of these are set, the CLI prints a yellow banner at session
121+
start enumerating which restrictions are off — so you don't run
122+
unrestricted by accident. Philosophy: defaults are conservative so
123+
new users can't accidentally trash their machine; opt-outs let power
124+
users tell us "I trust this agent on this box, get out of the way."
125+
105126
### OAuth (only override if you know why)
106127
```
107128
CODEBASE_CLIENT_ID override OAuth client id

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.61",
3+
"version": "2.0.0-pre.62",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/cli.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ for (const a of rawArgv) {
4646
process.env.CODEBASE_FRESH = "1";
4747
continue;
4848
}
49+
if (a === "--unrestricted" || a === "--yolo") {
50+
// Power-user mode: drops every soft-guard restriction. Equivalent
51+
// to setting CODEBASE_NO_PROJECT_ROOT=1 + CODEBASE_NO_VALIDATOR=1
52+
// + CODEBASE_NO_READ_BEFORE_WRITE=1. The agent can then read/write
53+
// anywhere, run any shell command, and overwrite files without
54+
// reading them first. Use when you trust the model + the prompt
55+
// (e.g. your own machine, your own project). The warning banner
56+
// at session start enumerates what's off so it's never accidental.
57+
process.env.CODEBASE_NO_PROJECT_ROOT = "1";
58+
process.env.CODEBASE_NO_VALIDATOR = "1";
59+
process.env.CODEBASE_NO_READ_BEFORE_WRITE = "1";
60+
continue;
61+
}
4962
argv.push(a);
5063
}
5164

@@ -83,6 +96,11 @@ if (argv[0] === "--version" || argv[0] === "-v") {
8396
runHeadless({ prompt, outputFormat, autoApprove }).then((code) => process.exit(code));
8497
} else {
8598
setTerminalTitle("codebase");
99+
// Print a one-line warning if any restriction is off so the user can't
100+
// accidentally launch a session in unrestricted mode without realizing.
101+
// Written before ink takes over the screen so it appears once at the
102+
// top, then scrolls away as normal output replaces it.
103+
printUnrestrictedBanner();
86104
// Enable bracketed paste mode so the terminal wraps pasted content in
87105
// CSI 200~ / 201~ markers. The Input component listens for them and
88106
// collapses the content into a placeholder. terminal-restore.ts emits
@@ -145,6 +163,18 @@ function readPackageVersion(): string {
145163
}
146164
}
147165

166+
function printUnrestrictedBanner(): void {
167+
const off: string[] = [];
168+
if (process.env.CODEBASE_NO_PROJECT_ROOT === "1") off.push("project-root clamp");
169+
if (process.env.CODEBASE_NO_VALIDATOR === "1") off.push("shell validator");
170+
if (process.env.CODEBASE_NO_READ_BEFORE_WRITE === "1") off.push("read-before-write");
171+
if (off.length === 0) return;
172+
if (!process.stdout.isTTY) return;
173+
// Yellow background, black text — visible without being scary-red.
174+
const banner = `\x1b[43;30m⚠ UNRESTRICTED MODE — ${off.join(" + ")} disabled\x1b[0m`;
175+
process.stdout.write(`${banner}\n`);
176+
}
177+
148178
function printHelp(): void {
149179
process.stdout.write(
150180
[
@@ -172,6 +202,8 @@ function printHelp(): void {
172202
"Session:",
173203
" codebase resume the prior session for this directory if recent (≤7d)",
174204
" codebase --new start a fresh session, ignoring saved history",
205+
" codebase --unrestricted drop the project-root clamp, shell validator, and",
206+
" read-before-write check. Trust mode for your own machine.",
175207
"",
176208
"Diagnostics:",
177209
" --debug-input log every keystroke to ~/.codebase/logs/input.log",

src/tools/file-ops.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ import type { FileSnapshot, FileStateCache } from "./file-state-cache.js";
1616
* Symlinks are followed at read/stat time, so this is a best-effort guard
1717
* against absolute paths and `..` traversal — combine with realpath checks
1818
* before sensitive operations if you don't trust the working tree.
19+
*
20+
* Opt-out: `CODEBASE_NO_PROJECT_ROOT=1` (set explicitly or via the
21+
* `--unrestricted` CLI flag) skips the clamp entirely. The agent can
22+
* then reach anywhere the running user can — useful for system-admin
23+
* sessions, debugging across multiple projects, comparing against
24+
* ~/.config, etc. Default stays clamped to keep new-user blast radius
25+
* small.
1926
*/
2027
export function resolveInsideCwd(cwd: string, requested: string): string {
2128
const absCwd = resolve(cwd);
2229
const candidate = isAbsolute(requested) ? resolve(requested) : resolve(absCwd, requested);
30+
if (process.env.CODEBASE_NO_PROJECT_ROOT === "1") return candidate;
2331
if (candidate !== absCwd && !candidate.startsWith(absCwd + sep)) {
2432
throw new PathOutsideCwdError(requested);
2533
}
@@ -56,14 +64,52 @@ export function isLikelyBinary(buf: Buffer): boolean {
5664
* - missing snapshot → FileNotReadFirstError ("read it first")
5765
* - mtime/size drift → FileUnexpectedlyModifiedError ("read it again")
5866
* - partial-view read → PartialViewEditError ("read the full file")
67+
*
68+
* Opt-out: `CODEBASE_NO_READ_BEFORE_WRITE=1` (or `--unrestricted`)
69+
* returns a synthetic snapshot so write_file / edit_file / multi_edit
70+
* proceed even if the model never read the file in this turn. Useful
71+
* for "generate this file from scratch" workflows where the read-first
72+
* check is friction. Drift detection still fires when the snapshot
73+
* is present and stale.
5974
*/
6075
export function validateForOverwrite(absPath: string, cache: FileStateCache): FileSnapshot {
6176
const status = cache.check(absPath);
62-
if (status === "missing") throw new FileNotReadFirstError(absPath);
77+
if (status === "missing") {
78+
if (process.env.CODEBASE_NO_READ_BEFORE_WRITE === "1") {
79+
// Synthesize a "we never looked" snapshot so the caller proceeds.
80+
// Drift detection only matters when we DO have a prior snapshot;
81+
// without one, the user has explicitly told us to skip the check.
82+
return {
83+
path: absPath,
84+
content: "",
85+
mtimeMs: 0,
86+
size: 0,
87+
hasBOM: false,
88+
eol: "\n",
89+
isPartialView: false,
90+
storedAt: Date.now(),
91+
};
92+
}
93+
throw new FileNotReadFirstError(absPath);
94+
}
6395
if (status === "modified") throw new FileUnexpectedlyModifiedError(absPath);
6496

6597
const snap = cache.get(absPath);
66-
if (!snap) throw new FileNotReadFirstError(absPath);
98+
if (!snap) {
99+
if (process.env.CODEBASE_NO_READ_BEFORE_WRITE === "1") {
100+
return {
101+
path: absPath,
102+
content: "",
103+
mtimeMs: 0,
104+
size: 0,
105+
hasBOM: false,
106+
eol: "\n",
107+
isPartialView: false,
108+
storedAt: Date.now(),
109+
};
110+
}
111+
throw new FileNotReadFirstError(absPath);
112+
}
67113
if (snap.isPartialView) throw new PartialViewEditError(absPath);
68114
return snap;
69115
}

src/tools/shell-validator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ const WARN_PATTERNS: readonly PatternRule[] = [
116116
export function validateShellCommand(command: string): ShellValidationResult {
117117
const normalized = command.trim();
118118
if (!normalized) return { verdict: "allow" };
119+
// Opt-out for power users: CODEBASE_NO_VALIDATOR=1 (set explicitly
120+
// or via --unrestricted) bypasses every hard-block AND warn pattern.
121+
// You're telling us the agent can run whatever — we trust the user.
122+
// Banner at session start makes sure they didn't set it accidentally.
123+
if (process.env.CODEBASE_NO_VALIDATOR === "1") return { verdict: "allow" };
119124
for (const rule of BLOCK_PATTERNS) {
120125
if (rule.regex.test(normalized)) return { verdict: "block", reason: rule.reason };
121126
}

src/tools/shell.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
22
import { randomBytes } from "node:crypto";
33
import { writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
5-
import { join, resolve } from "node:path";
5+
import { isAbsolute, join, resolve } from "node:path";
66
import type { AgentTool, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
77
import { type Static, Type } from "typebox";
88
import { TimeoutError } from "./errors.js";
@@ -231,9 +231,15 @@ export function createShell(ctx: ToolContext): AgentTool<typeof Params, ShellDet
231231

232232
function resolveSubCwd(projectCwd: string, requested: string | undefined): string {
233233
if (!requested) return resolve(projectCwd);
234-
const abs = resolve(projectCwd, requested);
234+
// Allow absolute paths when present, otherwise resolve against the
235+
// project root. `CODEBASE_NO_PROJECT_ROOT=1` skips the clamp — see
236+
// resolveInsideCwd in file-ops.ts for the rationale.
237+
const abs = isAbsolute(requested) ? resolve(requested) : resolve(projectCwd, requested);
238+
if (process.env.CODEBASE_NO_PROJECT_ROOT === "1") return abs;
235239
if (abs !== resolve(projectCwd) && !abs.startsWith(`${resolve(projectCwd)}/`)) {
236-
throw new Error(`cwd ${requested} is outside the project root.`);
240+
throw new Error(
241+
`cwd ${requested} is outside the project root. Set CODEBASE_NO_PROJECT_ROOT=1 or use --unrestricted to allow this.`,
242+
);
237243
}
238244
return abs;
239245
}

src/tools/unrestricted.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { resolveInsideCwd, validateForOverwrite } from "./file-ops.js";
6+
import { FileStateCache } from "./file-state-cache.js";
7+
import { validateShellCommand } from "./shell-validator.js";
8+
9+
/**
10+
* Tests for the unrestricted opt-outs. Each one verifies BOTH that the
11+
* restriction holds by default AND that the env flag relaxes it, so a
12+
* future refactor that accidentally drops the gate is caught.
13+
*/
14+
15+
describe("CODEBASE_NO_PROJECT_ROOT", () => {
16+
let saved: string | undefined;
17+
let cwd: string;
18+
19+
beforeEach(() => {
20+
saved = process.env.CODEBASE_NO_PROJECT_ROOT;
21+
delete process.env.CODEBASE_NO_PROJECT_ROOT;
22+
cwd = mkdtempSync(join(tmpdir(), "codebase-unrestricted-"));
23+
});
24+
25+
afterEach(() => {
26+
rmSync(cwd, { recursive: true, force: true });
27+
if (saved === undefined) delete process.env.CODEBASE_NO_PROJECT_ROOT;
28+
else process.env.CODEBASE_NO_PROJECT_ROOT = saved;
29+
});
30+
31+
it("default: rejects /etc/passwd as outside the project root", () => {
32+
expect(() => resolveInsideCwd(cwd, "/etc/passwd")).toThrow(/outside/i);
33+
});
34+
35+
it("default: rejects ../escape paths", () => {
36+
expect(() => resolveInsideCwd(cwd, "../../../etc/passwd")).toThrow(/outside/i);
37+
});
38+
39+
it("unrestricted=1: lets /etc/passwd through verbatim", () => {
40+
process.env.CODEBASE_NO_PROJECT_ROOT = "1";
41+
expect(resolveInsideCwd(cwd, "/etc/passwd")).toBe("/etc/passwd");
42+
});
43+
44+
it("unrestricted=1: lets ../escape resolve normally", () => {
45+
process.env.CODEBASE_NO_PROJECT_ROOT = "1";
46+
const out = resolveInsideCwd(cwd, "../sibling/file");
47+
expect(out).not.toContain(cwd);
48+
expect(out.endsWith("/sibling/file")).toBe(true);
49+
});
50+
});
51+
52+
describe("CODEBASE_NO_VALIDATOR", () => {
53+
let saved: string | undefined;
54+
55+
beforeEach(() => {
56+
saved = process.env.CODEBASE_NO_VALIDATOR;
57+
delete process.env.CODEBASE_NO_VALIDATOR;
58+
});
59+
60+
afterEach(() => {
61+
if (saved === undefined) delete process.env.CODEBASE_NO_VALIDATOR;
62+
else process.env.CODEBASE_NO_VALIDATOR = saved;
63+
});
64+
65+
it("default: blocks `rm -rf /`", () => {
66+
expect(validateShellCommand("rm -rf /").verdict).toBe("block");
67+
});
68+
69+
it("default: warns on `sudo apt update`", () => {
70+
expect(validateShellCommand("sudo apt update").verdict).toBe("warn");
71+
});
72+
73+
it("unrestricted=1: allows `rm -rf /`", () => {
74+
process.env.CODEBASE_NO_VALIDATOR = "1";
75+
expect(validateShellCommand("rm -rf /").verdict).toBe("allow");
76+
});
77+
78+
it("unrestricted=1: allows `sudo apt update` without warn", () => {
79+
process.env.CODEBASE_NO_VALIDATOR = "1";
80+
expect(validateShellCommand("sudo apt update").verdict).toBe("allow");
81+
});
82+
});
83+
84+
describe("CODEBASE_NO_READ_BEFORE_WRITE", () => {
85+
let saved: string | undefined;
86+
let cache: FileStateCache;
87+
88+
beforeEach(() => {
89+
saved = process.env.CODEBASE_NO_READ_BEFORE_WRITE;
90+
delete process.env.CODEBASE_NO_READ_BEFORE_WRITE;
91+
cache = new FileStateCache();
92+
});
93+
94+
afterEach(() => {
95+
if (saved === undefined) delete process.env.CODEBASE_NO_READ_BEFORE_WRITE;
96+
else process.env.CODEBASE_NO_READ_BEFORE_WRITE = saved;
97+
});
98+
99+
it("default: throws FileNotReadFirstError when the file wasn't read", () => {
100+
expect(() => validateForOverwrite("/tmp/never-read.txt", cache)).toThrow();
101+
});
102+
103+
it("unrestricted=1: returns a synthetic snapshot without throwing", () => {
104+
process.env.CODEBASE_NO_READ_BEFORE_WRITE = "1";
105+
const snap = validateForOverwrite("/tmp/never-read.txt", cache);
106+
expect(snap.path).toBe("/tmp/never-read.txt");
107+
expect(snap.isPartialView).toBe(false);
108+
expect(snap.size).toBe(0);
109+
});
110+
});

0 commit comments

Comments
 (0)