pi-better-sandbox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/commands.ts +214 -0
- package/deny-rules.ts +623 -0
- package/events.ts +55 -0
- package/files.ts +211 -0
- package/index.ts +233 -0
- package/package.json +62 -0
- package/policy.ts +102 -0
- package/rules-page.ts +176 -0
- package/shared-sandbox-core.ts +462 -0
- package/shell.ts +126 -0
- package/state.ts +300 -0
- package/status.ts +72 -0
package/files.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground file-mutation confinement for the built-in `write` and `edit`
|
|
3
|
+
* tools.
|
|
4
|
+
*
|
|
5
|
+
* These two tools never spawn a child process — they call `fs` in Pi's own
|
|
6
|
+
* process — so the argv wrapping that confines `bash` cannot reach them. The
|
|
7
|
+
* enforcement here is therefore an in-process containment check against the
|
|
8
|
+
* same compiled write policy the kernel backends are built from, run on the
|
|
9
|
+
* absolute path Pi itself resolved.
|
|
10
|
+
*
|
|
11
|
+
* Placement is the whole point. Pi's built-in `write`/`edit` implementations
|
|
12
|
+
* take `WriteOperations`/`EditOperations` and call them *inside*
|
|
13
|
+
* `withFileMutationQueue(absolutePath, ...)`, after their own abort checks. By
|
|
14
|
+
* replacing only those operations, every check below runs inside Pi's per-file
|
|
15
|
+
* mutation queue and inside its cancellation window, and no denied mutation can
|
|
16
|
+
* be re-targeted between the check and the syscall: the guard and the `fs` call
|
|
17
|
+
* are the same operation.
|
|
18
|
+
*
|
|
19
|
+
* Reads stay unrestricted. `edit`'s `readFile` is delegated untouched; only the
|
|
20
|
+
* operations that can change the host filesystem are guarded.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { constants } from "node:fs";
|
|
24
|
+
import {
|
|
25
|
+
access as fsAccess,
|
|
26
|
+
mkdir as fsMkdir,
|
|
27
|
+
readFile as fsReadFile,
|
|
28
|
+
writeFile as fsWriteFile,
|
|
29
|
+
} from "node:fs/promises";
|
|
30
|
+
|
|
31
|
+
import type { EditOperations, WriteOperations } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
type CompiledSandboxWritePolicy,
|
|
35
|
+
compileWritePolicy,
|
|
36
|
+
evaluateWriteAccess,
|
|
37
|
+
type SandboxSeams,
|
|
38
|
+
type SandboxWritePolicy,
|
|
39
|
+
type WriteAccessDecision,
|
|
40
|
+
} from "./shared-sandbox-core.ts";
|
|
41
|
+
import type { ForegroundSandboxController } from "./state.ts";
|
|
42
|
+
|
|
43
|
+
/** The refusing half of a write decision. */
|
|
44
|
+
export type DeniedWriteAccess = Extract<WriteAccessDecision, { allowed: false }>;
|
|
45
|
+
|
|
46
|
+
/** What the refused operation would have done, so the message names it. */
|
|
47
|
+
export type MutationKind = "write" | "directory";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Thrown instead of mutating a path the foreground sandbox does not allow.
|
|
51
|
+
*
|
|
52
|
+
* It surfaces through Pi's normal tool-error path — the built-in `write` and
|
|
53
|
+
* `edit` implementations already let operation errors propagate — so the model
|
|
54
|
+
* sees an ordinary failed tool call and the host filesystem is untouched.
|
|
55
|
+
*/
|
|
56
|
+
export class ForegroundSandboxWriteDeniedError extends Error {
|
|
57
|
+
readonly decision: DeniedWriteAccess;
|
|
58
|
+
/** The compiled policy that refused the write. */
|
|
59
|
+
readonly policy: CompiledSandboxWritePolicy;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
decision: DeniedWriteAccess,
|
|
63
|
+
policy: CompiledSandboxWritePolicy,
|
|
64
|
+
kind: MutationKind = "write",
|
|
65
|
+
) {
|
|
66
|
+
super(explainDenial(decision, policy, kind));
|
|
67
|
+
this.name = "ForegroundSandboxWriteDeniedError";
|
|
68
|
+
this.decision = decision;
|
|
69
|
+
this.policy = policy;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function explainDenial(
|
|
74
|
+
decision: DeniedWriteAccess,
|
|
75
|
+
policy: CompiledSandboxWritePolicy,
|
|
76
|
+
kind: MutationKind,
|
|
77
|
+
): string {
|
|
78
|
+
const attempt = kind === "directory" ? "create directory" : "write";
|
|
79
|
+
const refused = `Foreground sandbox refused to ${attempt} ${decision.path}; nothing was changed on disk.`;
|
|
80
|
+
return decision.reason === "outside-writable-root"
|
|
81
|
+
? `${refused} Writes are confined to ${policy.writableRoot}.`
|
|
82
|
+
: `${refused} ${decision.deniedBy} is a write-denied path.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Assert one mutation target is writable under the current effective policy.
|
|
87
|
+
*
|
|
88
|
+
* Throws `ForegroundSandboxBlockedError` when the sandbox is enabled but cannot
|
|
89
|
+
* be applied (no backend, unsafe launch root, no session yet), and
|
|
90
|
+
* `ForegroundSandboxWriteDeniedError` when the canonical target is outside the
|
|
91
|
+
* writable root or write-denied. Returns silently when a human explicitly
|
|
92
|
+
* disabled the sandbox, which is the only path to an unconfined mutation.
|
|
93
|
+
*/
|
|
94
|
+
export type ForegroundWriteGuard = (absolutePath: string, kind?: MutationKind) => string;
|
|
95
|
+
|
|
96
|
+
/** Identity of a compiled policy, so it is recompiled on change and not per mutation. */
|
|
97
|
+
function policyKey(policy: SandboxWritePolicy): string {
|
|
98
|
+
return JSON.stringify([policy.writableRoot, policy.denyWrite ?? [], policy.home]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the guard the file operations run before every mutation.
|
|
103
|
+
*
|
|
104
|
+
* The launch decision is taken per mutation, so `/sandbox on` and `/sandbox off`
|
|
105
|
+
* take effect for mutations attempted after the toggle. The policy behind it is
|
|
106
|
+
* compiled once and reused until the policy itself changes.
|
|
107
|
+
*
|
|
108
|
+
* The canonical path comes back so callers mutate the path that was checked
|
|
109
|
+
* rather than the one they were handed. A path-based check can otherwise be
|
|
110
|
+
* raced by a symlink swapped in after the check and before the syscall; writing
|
|
111
|
+
* the already-resolved path removes that final-component race.
|
|
112
|
+
*/
|
|
113
|
+
export function createForegroundWriteGuard(
|
|
114
|
+
controller: ForegroundSandboxController,
|
|
115
|
+
seams: SandboxSeams = {},
|
|
116
|
+
): ForegroundWriteGuard {
|
|
117
|
+
let compiledKey: string | undefined;
|
|
118
|
+
let compiled: CompiledSandboxWritePolicy | undefined;
|
|
119
|
+
|
|
120
|
+
return function assertWritable(absolutePath: string, kind: MutationKind = "write"): string {
|
|
121
|
+
// Throws when the sandbox is enabled but unusable: an unavailable or
|
|
122
|
+
// failed backend blocks the mutation instead of quietly delegating.
|
|
123
|
+
const plan = controller.requireLaunchPlan();
|
|
124
|
+
if (!plan.confined) return absolutePath;
|
|
125
|
+
|
|
126
|
+
const key = policyKey(plan.policy);
|
|
127
|
+
if (compiled === undefined || key !== compiledKey) {
|
|
128
|
+
compiled = compileWritePolicy(plan.policy, seams);
|
|
129
|
+
compiledKey = key;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const decision = evaluateWriteAccess(absolutePath, compiled, seams);
|
|
133
|
+
if (!decision.allowed) {
|
|
134
|
+
throw new ForegroundSandboxWriteDeniedError(decision, compiled, kind);
|
|
135
|
+
}
|
|
136
|
+
return decision.path;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Pi's own default local backends, which the guarded operations delegate to. */
|
|
141
|
+
const localWriteOperations: WriteOperations = {
|
|
142
|
+
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
143
|
+
mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const localEditOperations: EditOperations = {
|
|
147
|
+
readFile: (path) => fsReadFile(path),
|
|
148
|
+
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
|
|
149
|
+
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type SandboxedWriteOperationsOptions = SandboxSeams & {
|
|
153
|
+
/** The local filesystem backend to delegate allowed mutations to. */
|
|
154
|
+
localOperations?: WriteOperations;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type SandboxedEditOperationsOptions = SandboxSeams & {
|
|
158
|
+
/** The local filesystem backend to delegate allowed mutations to. */
|
|
159
|
+
localOperations?: EditOperations;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Write operations that confine every mutation they perform.
|
|
164
|
+
*
|
|
165
|
+
* `mkdir` is guarded as well as `writeFile`: the built-in `write` tool creates
|
|
166
|
+
* parent directories before writing, so an unguarded `mkdir` would materialise
|
|
167
|
+
* host directories outside the project root before the write itself was
|
|
168
|
+
* refused.
|
|
169
|
+
*/
|
|
170
|
+
export function createSandboxedWriteOperations(
|
|
171
|
+
controller: ForegroundSandboxController,
|
|
172
|
+
options: SandboxedWriteOperationsOptions = {},
|
|
173
|
+
): WriteOperations {
|
|
174
|
+
const assertWritable = createForegroundWriteGuard(controller, options);
|
|
175
|
+
const local = options.localOperations ?? localWriteOperations;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
async mkdir(dir) {
|
|
179
|
+
return local.mkdir(assertWritable(dir, "directory"));
|
|
180
|
+
},
|
|
181
|
+
async writeFile(absolutePath, content) {
|
|
182
|
+
return local.writeFile(assertWritable(absolutePath), content);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Edit operations that confine every mutation they perform.
|
|
189
|
+
*
|
|
190
|
+
* `access` is Pi's own pre-flight gate for `edit`, so guarding it refuses a
|
|
191
|
+
* denied target before the file is read or a diff is computed; `writeFile` is
|
|
192
|
+
* guarded because it is the mutation. `readFile` is delegated untouched — this
|
|
193
|
+
* sandbox never restricts reads.
|
|
194
|
+
*/
|
|
195
|
+
export function createSandboxedEditOperations(
|
|
196
|
+
controller: ForegroundSandboxController,
|
|
197
|
+
options: SandboxedEditOperationsOptions = {},
|
|
198
|
+
): EditOperations {
|
|
199
|
+
const assertWritable = createForegroundWriteGuard(controller, options);
|
|
200
|
+
const local = options.localOperations ?? localEditOperations;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
readFile: (absolutePath) => local.readFile(absolutePath),
|
|
204
|
+
async access(absolutePath) {
|
|
205
|
+
return local.access(assertWritable(absolutePath));
|
|
206
|
+
},
|
|
207
|
+
async writeFile(absolutePath, content) {
|
|
208
|
+
return local.writeFile(assertWritable(absolutePath), content);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-better-sandbox - a default-on write sandbox for foreground tool execution.
|
|
3
|
+
*
|
|
4
|
+
* Installing this package loads an extension; it ships no launcher, so users
|
|
5
|
+
* keep starting Pi with plain `pi`. While enabled, the built-in `bash` tool and
|
|
6
|
+
* user-entered `!` / `!!` commands run under macOS Seatbelt or Linux Bubblewrap
|
|
7
|
+
* with one writable root — the canonical directory Pi was launched from — and
|
|
8
|
+
* the packaged write-denied paths carved back out of it. The built-in `write`
|
|
9
|
+
* and `edit` tools mutate files in Pi's own process rather than in a child, so
|
|
10
|
+
* no argv wrapping can reach them; they are held to the same policy by an
|
|
11
|
+
* in-process containment check instead. Reads and network are untouched.
|
|
12
|
+
*
|
|
13
|
+
* This is a tool-execution sandbox. Pi's own process, `pi.exec` calls, and
|
|
14
|
+
* unrelated third-party extension code are not confined by it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createBashToolDefinition,
|
|
19
|
+
createEditToolDefinition,
|
|
20
|
+
createWriteToolDefinition,
|
|
21
|
+
SettingsManager,
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createSandboxCommandHandler,
|
|
28
|
+
SANDBOX_COMMAND_DESCRIPTION,
|
|
29
|
+
SANDBOX_COMMAND_NAME,
|
|
30
|
+
sandboxArgumentCompletions,
|
|
31
|
+
} from "./commands.ts";
|
|
32
|
+
import { DenyRuleManager } from "./deny-rules.ts";
|
|
33
|
+
import {
|
|
34
|
+
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
|
|
35
|
+
publishForegroundSandboxPolicy,
|
|
36
|
+
} from "./events.ts";
|
|
37
|
+
import {
|
|
38
|
+
createSandboxedEditOperations,
|
|
39
|
+
createSandboxedWriteOperations,
|
|
40
|
+
} from "./files.ts";
|
|
41
|
+
import { createSandboxedBashOperations } from "./shell.ts";
|
|
42
|
+
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
43
|
+
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
44
|
+
|
|
45
|
+
const FOOTER_KEY = "sandbox";
|
|
46
|
+
|
|
47
|
+
export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
48
|
+
const controller = new ForegroundSandboxController();
|
|
49
|
+
|
|
50
|
+
// Pi's shell setting is only readable once a session directory is known, so
|
|
51
|
+
// it is resolved lazily and re-read on every session start.
|
|
52
|
+
let shellPath: string | undefined;
|
|
53
|
+
const operations = createSandboxedBashOperations(controller, { shellPath: () => shellPath });
|
|
54
|
+
|
|
55
|
+
// Overriding the built-in bash tool by name. Only `operations` changes:
|
|
56
|
+
// Pi's own definition still owns the schema, streaming, timeout,
|
|
57
|
+
// cancellation, truncation, session environment, result details, and both
|
|
58
|
+
// renderers, so every bash contract stays the built-in one.
|
|
59
|
+
pi.registerTool(createBashToolDefinition(process.cwd(), { operations }));
|
|
60
|
+
|
|
61
|
+
// The same backend for user-entered ! and !! commands.
|
|
62
|
+
pi.on("user_bash", () => ({ operations }));
|
|
63
|
+
|
|
64
|
+
// Overriding the built-in write and edit tools the same way: only their
|
|
65
|
+
// file operations change, so Pi's own definitions keep the parameter
|
|
66
|
+
// schemas, prompt guidance, call rendering, write previews, edit diffs,
|
|
67
|
+
// result details, file-mutation queue, and cancellation checks. The guarded
|
|
68
|
+
// operations run inside that queue, which is where the enforcement belongs.
|
|
69
|
+
const writeOperations = createSandboxedWriteOperations(controller);
|
|
70
|
+
const editOperations = createSandboxedEditOperations(controller);
|
|
71
|
+
|
|
72
|
+
// `cwd` is what these tools resolve a relative `path` against, so it has to
|
|
73
|
+
// be the directory Pi itself resolves against. Registration is re-run when
|
|
74
|
+
// a session reports a different cwd (`pi --cwd ...`), which Pi supports and
|
|
75
|
+
// refreshes in the same session.
|
|
76
|
+
let fileToolCwd: string | undefined;
|
|
77
|
+
const registerFileTools = (cwd: string): void => {
|
|
78
|
+
if (fileToolCwd === cwd) return;
|
|
79
|
+
fileToolCwd = cwd;
|
|
80
|
+
pi.registerTool(createWriteToolDefinition(cwd, { operations: writeOperations }));
|
|
81
|
+
pi.registerTool(createEditToolDefinition(cwd, { operations: editOperations }));
|
|
82
|
+
};
|
|
83
|
+
registerFileTools(process.cwd());
|
|
84
|
+
|
|
85
|
+
let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
|
|
86
|
+
|
|
87
|
+
const announce = (status: ForegroundSandboxStatus): void => {
|
|
88
|
+
publishForegroundSandboxPolicy(pi.events, status);
|
|
89
|
+
paintFooter?.(status);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// A consumer that loaded after the last publication can ask for the current
|
|
93
|
+
// policy instead of waiting for the next change.
|
|
94
|
+
pi.events.on(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, () => {
|
|
95
|
+
publishForegroundSandboxPolicy(pi.events, controller.status());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The one validation and persistence path for write-deny rules, shared by
|
|
99
|
+
// `/sandbox deny ...` and the `/sandbox rules` page.
|
|
100
|
+
const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
|
|
101
|
+
|
|
102
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
103
|
+
shellPath = resolveShellPath(ctx.cwd);
|
|
104
|
+
registerFileTools(ctx.cwd);
|
|
105
|
+
paintFooter = (status) => {
|
|
106
|
+
ctx.ui.setStatus(
|
|
107
|
+
FOOTER_KEY,
|
|
108
|
+
formatFooterStatus(status, (tone, text) => ctx.ui.theme.fg(tone, text)),
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Every session start re-captures the project root and re-arms
|
|
113
|
+
// protection, so an earlier /sandbox off never survives into a new,
|
|
114
|
+
// resumed, forked, or reloaded session.
|
|
115
|
+
controller.beginSession(ctx.cwd);
|
|
116
|
+
|
|
117
|
+
// Then the rules are re-read and re-resolved, because the same global
|
|
118
|
+
// template set means different absolute paths in a different project.
|
|
119
|
+
// Loading is what announces the session's first policy, so consumers and
|
|
120
|
+
// the footer never see the pre-rule state.
|
|
121
|
+
const report = denyRules.load();
|
|
122
|
+
const status = report.status;
|
|
123
|
+
|
|
124
|
+
if (report.overrideProblem !== undefined) {
|
|
125
|
+
ctx.ui.notify(report.overrideProblem, "warning");
|
|
126
|
+
}
|
|
127
|
+
for (const rule of report.inert) {
|
|
128
|
+
ctx.ui.notify(
|
|
129
|
+
`Write-deny rule ${rule.template} is not applied in this project: ${rule.reason}`,
|
|
130
|
+
"warning",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (status.state !== "enabled") {
|
|
134
|
+
ctx.ui.notify(
|
|
135
|
+
`Foreground sandbox ${status.state}: ${status.reason}`,
|
|
136
|
+
status.state === "disabled" ? "info" : "warning",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.on("session_shutdown", () => {
|
|
142
|
+
controller.dispose();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.registerCommand(SANDBOX_COMMAND_NAME, {
|
|
146
|
+
description: SANDBOX_COMMAND_DESCRIPTION,
|
|
147
|
+
getArgumentCompletions: sandboxArgumentCompletions,
|
|
148
|
+
handler: createSandboxCommandHandler({ controller, denyRules, onStateChange: announce }),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveShellPath(cwd: string): string | undefined {
|
|
153
|
+
try {
|
|
154
|
+
return SettingsManager.create(cwd).getShellPath();
|
|
155
|
+
} catch {
|
|
156
|
+
// A malformed or unreadable settings file must not decide whether the
|
|
157
|
+
// sandbox runs; fall back to Pi's own shell resolution.
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { footerTone, formatFooterStatus, formatSandboxReport } from "./status.ts";
|
|
163
|
+
export {
|
|
164
|
+
FOREGROUND_SANDBOX_REMEDY,
|
|
165
|
+
ForegroundSandboxBlockedError,
|
|
166
|
+
ForegroundSandboxController,
|
|
167
|
+
type ForegroundSandboxLaunchPlan,
|
|
168
|
+
type ForegroundSandboxSeams,
|
|
169
|
+
type ForegroundSandboxState,
|
|
170
|
+
type ForegroundSandboxStatus,
|
|
171
|
+
} from "./state.ts";
|
|
172
|
+
export {
|
|
173
|
+
FOREGROUND_SANDBOX_POLICY_CHANNEL,
|
|
174
|
+
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
|
|
175
|
+
type ForegroundSandboxPolicyEvent,
|
|
176
|
+
freezePolicy,
|
|
177
|
+
publishForegroundSandboxPolicy,
|
|
178
|
+
requestForegroundSandboxPolicy,
|
|
179
|
+
subscribeForegroundSandboxPolicy,
|
|
180
|
+
} from "./events.ts";
|
|
181
|
+
export {
|
|
182
|
+
clearDenyRuleOverride,
|
|
183
|
+
DENY_RULES_FILE_NAME,
|
|
184
|
+
DENY_RULES_FORMAT_VERSION,
|
|
185
|
+
type DenyRule,
|
|
186
|
+
DenyRuleError,
|
|
187
|
+
type DenyRuleErrorKind,
|
|
188
|
+
DenyRuleManager,
|
|
189
|
+
type DenyRuleManagerDeps,
|
|
190
|
+
type DenyRuleReport,
|
|
191
|
+
denyRuleOverridePath,
|
|
192
|
+
type DenyRuleSeams,
|
|
193
|
+
type DenyRuleStoreSeams,
|
|
194
|
+
describeDenyRules,
|
|
195
|
+
formatDenyRuleReport,
|
|
196
|
+
type InertDenyRule,
|
|
197
|
+
normalizeDenyRuleTemplate,
|
|
198
|
+
partitionDenyRules,
|
|
199
|
+
planDenyRuleAddition,
|
|
200
|
+
planDenyRuleRemoval,
|
|
201
|
+
readDenyRuleOverride,
|
|
202
|
+
writeDenyRuleOverride,
|
|
203
|
+
} from "./deny-rules.ts";
|
|
204
|
+
export { openSandboxRulesPage, RULES_PAGE_NO_UI_REJECTION } from "./rules-page.ts";
|
|
205
|
+
export {
|
|
206
|
+
describeUnsafeProjectRoot,
|
|
207
|
+
PACKAGED_DENY_WRITE_TEMPLATES,
|
|
208
|
+
resolveDenyWriteTemplate,
|
|
209
|
+
resolveDenyWriteTemplates,
|
|
210
|
+
unsafeProjectRoots,
|
|
211
|
+
} from "./policy.ts";
|
|
212
|
+
export {
|
|
213
|
+
createForegroundWriteGuard,
|
|
214
|
+
createSandboxedEditOperations,
|
|
215
|
+
createSandboxedWriteOperations,
|
|
216
|
+
type DeniedWriteAccess,
|
|
217
|
+
ForegroundSandboxWriteDeniedError,
|
|
218
|
+
type ForegroundWriteGuard,
|
|
219
|
+
type MutationKind,
|
|
220
|
+
type SandboxedEditOperationsOptions,
|
|
221
|
+
type SandboxedWriteOperationsOptions,
|
|
222
|
+
} from "./files.ts";
|
|
223
|
+
export {
|
|
224
|
+
buildSandboxedShellCommand,
|
|
225
|
+
createSandboxedBashOperations,
|
|
226
|
+
quoteForPosixShell,
|
|
227
|
+
} from "./shell.ts";
|
|
228
|
+
export {
|
|
229
|
+
createSandboxCommandHandler,
|
|
230
|
+
SANDBOX_COMMAND_DESCRIPTION,
|
|
231
|
+
SANDBOX_COMMAND_NAME,
|
|
232
|
+
sandboxArgumentCompletions,
|
|
233
|
+
} from "./commands.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-better-sandbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that confines foreground shell execution to the project directory with a kernel-enforced write sandbox.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"sandbox",
|
|
13
|
+
"seatbelt",
|
|
14
|
+
"bubblewrap"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/1aboveio/pi-better-harness.git",
|
|
24
|
+
"directory": "packages/pi-better-sandbox"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/1aboveio/pi-better-harness/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
35
|
+
"pretest": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
36
|
+
"prepack": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
39
|
+
"verify": "npm run typecheck && npm test"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"*.ts",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "^0.82.0",
|
|
53
|
+
"@earendil-works/pi-tui": "^0.82.0",
|
|
54
|
+
"@types/node": "^25.0.0",
|
|
55
|
+
"tsx": "^4.22.3",
|
|
56
|
+
"typebox": "^1.1.39",
|
|
57
|
+
"typescript": "^6.0.0"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=22.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/policy.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground write policy: which directory is writable, and which concrete
|
|
3
|
+
* paths stay non-writable inside it.
|
|
4
|
+
*
|
|
5
|
+
* The mechanism (backends, profiles, argv wrapping) lives in `sandbox-core`.
|
|
6
|
+
* This module owns the product decisions layered on top of it: the packaged
|
|
7
|
+
* deny defaults, how a rule template resolves against a project, and which
|
|
8
|
+
* launch directories are too broad to confine usefully.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { canonicalizePath, type SandboxSeams } from "./shared-sandbox-core.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Paths that stay non-writable inside every project root, shipped in source.
|
|
18
|
+
*
|
|
19
|
+
* Entries are `PROJECT_ROOT`-relative templates, so the same relative path is
|
|
20
|
+
* denied in every project. Persisting and editing this set is not this module's
|
|
21
|
+
* job — it hands out the packaged defaults and resolves whatever set it is
|
|
22
|
+
* given.
|
|
23
|
+
*/
|
|
24
|
+
export const PACKAGED_DENY_WRITE_TEMPLATES: readonly string[] = Object.freeze([
|
|
25
|
+
".git/hooks",
|
|
26
|
+
".env",
|
|
27
|
+
".env.local",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Seams for resolving a policy without touching the real home or filesystem. */
|
|
31
|
+
export type PolicySeams = SandboxSeams & {
|
|
32
|
+
/** Defaults to `os.homedir()`. */
|
|
33
|
+
home?: () => string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function homeOf(seams: PolicySeams): string {
|
|
37
|
+
return (seams.home ?? homedir)();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve one deny-write template against a project root.
|
|
42
|
+
*
|
|
43
|
+
* `~` resolves against the user's home, an absolute entry is taken as written,
|
|
44
|
+
* and everything else resolves against `PROJECT_ROOT`. The result is
|
|
45
|
+
* canonicalized so a symlinked alias cannot slip past the same rule.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveDenyWriteTemplate(
|
|
48
|
+
template: string,
|
|
49
|
+
projectRoot: string,
|
|
50
|
+
seams: PolicySeams = {},
|
|
51
|
+
): string {
|
|
52
|
+
const trimmed = template.trim();
|
|
53
|
+
if (trimmed === "") throw new Error("A write-denied path may not be empty.");
|
|
54
|
+
|
|
55
|
+
const home = homeOf(seams);
|
|
56
|
+
let expanded = trimmed;
|
|
57
|
+
if (trimmed === "~") expanded = home;
|
|
58
|
+
else if (trimmed.startsWith(`~${sep}`)) expanded = resolve(home, trimmed.slice(2));
|
|
59
|
+
|
|
60
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(projectRoot, expanded);
|
|
61
|
+
return canonicalizePath(absolute, seams);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a whole deny-write template set against a project root. */
|
|
65
|
+
export function resolveDenyWriteTemplates(
|
|
66
|
+
templates: readonly string[],
|
|
67
|
+
projectRoot: string,
|
|
68
|
+
seams: PolicySeams = {},
|
|
69
|
+
): string[] {
|
|
70
|
+
return [
|
|
71
|
+
...new Set(templates.map((template) => resolveDenyWriteTemplate(template, projectRoot, seams))),
|
|
72
|
+
].sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Launch directories that must never become the writable root.
|
|
77
|
+
*
|
|
78
|
+
* Confining writes to `/` or to the whole home directory would present the
|
|
79
|
+
* sandbox as active while protecting nothing, so those launches fail closed
|
|
80
|
+
* instead.
|
|
81
|
+
*/
|
|
82
|
+
export function unsafeProjectRoots(seams: PolicySeams = {}): string[] {
|
|
83
|
+
const roots = [resolve(sep), homeOf(seams)];
|
|
84
|
+
return [...new Set(roots.map((root) => canonicalizePath(root, seams)))];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Explain why a canonical project root is too broad to confine, or return
|
|
89
|
+
* `undefined` when it is a usable writable root.
|
|
90
|
+
*/
|
|
91
|
+
export function describeUnsafeProjectRoot(
|
|
92
|
+
projectRoot: string,
|
|
93
|
+
seams: PolicySeams = {},
|
|
94
|
+
): string | undefined {
|
|
95
|
+
if (!unsafeProjectRoots(seams).includes(projectRoot)) return undefined;
|
|
96
|
+
return [
|
|
97
|
+
`The sandbox will not treat ${projectRoot} as a writable project root:`,
|
|
98
|
+
"it is broad enough that confining writes to it would protect nothing.",
|
|
99
|
+
"Relaunch pi from the directory you are actually working in, or disable",
|
|
100
|
+
"the foreground sandbox on purpose with /sandbox off.",
|
|
101
|
+
].join(" ");
|
|
102
|
+
}
|