@erichll/pi-sandbox 0.3.0 → 0.4.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/CHANGELOG.md +26 -0
- package/README.md +60 -0
- package/package.json +3 -2
- package/src/approval.ts +28 -0
- package/src/config.ts +120 -10
- package/src/host-ipc.ts +160 -0
- package/src/index.ts +99 -38
- package/src/policy.ts +9 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0 - 2026-07-29
|
|
4
|
+
|
|
5
|
+
- Add an optional, approval-gated Host-IPC Bash backend for commands that need
|
|
6
|
+
access to host Unix sockets.
|
|
7
|
+
- Support trusted preflight command prefixes without treating a prefix as
|
|
8
|
+
authorization; every complete command still requires an exact one-shot
|
|
9
|
+
approval.
|
|
10
|
+
- Support one non-recursive host retry after a narrowly detected Unix-socket
|
|
11
|
+
`Operation not permitted` sandbox failure.
|
|
12
|
+
- Preserve the first attempt's output, apply only the original timeout's
|
|
13
|
+
remaining duration, and never retry successful, timed-out, or aborted
|
|
14
|
+
commands.
|
|
15
|
+
- Refuse Host-IPC forwarding for built-in subagents.
|
|
16
|
+
- Add deterministic execution and approval coverage plus a Linux tmux socket
|
|
17
|
+
smoke test.
|
|
18
|
+
|
|
19
|
+
## 0.3.1 - 2026-07-29
|
|
20
|
+
|
|
21
|
+
- Add trusted global filesystem read-path extensions while keeping default
|
|
22
|
+
write and configuration protections intact.
|
|
23
|
+
|
|
24
|
+
## 0.3.0 - 2026-07-28
|
|
25
|
+
|
|
26
|
+
- Initial public release.
|
package/README.md
CHANGED
|
@@ -20,6 +20,9 @@ also run complete process-backed subagent trees inside independent sandboxes.
|
|
|
20
20
|
independent policy, proxy, approval, and cleanup lifecycles.
|
|
21
21
|
- Project and global Pi security configuration, the installed package, Git
|
|
22
22
|
hooks, and other Sandbox Runtime mandatory paths remain write-protected.
|
|
23
|
+
- Optional host IPC execution is disabled by default. When enabled, every
|
|
24
|
+
complete host command still requires a one-shot `pi-auto-review` or human
|
|
25
|
+
approval and runs outside the OS sandbox.
|
|
23
26
|
|
|
24
27
|
Windows is not supported by this Pi adapter.
|
|
25
28
|
|
|
@@ -62,6 +65,63 @@ Supported modes:
|
|
|
62
65
|
The configuration parser rejects malformed JSON, unknown fields, and unknown
|
|
63
66
|
providers instead of silently weakening isolation.
|
|
64
67
|
|
|
68
|
+
## Optional host IPC fallback
|
|
69
|
+
|
|
70
|
+
Some host services expose only a Unix socket that the OS sandbox cannot use.
|
|
71
|
+
The trusted global configuration can enable an approval-driven local Bash
|
|
72
|
+
backend for those commands:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"hostIPC": {
|
|
77
|
+
"mode": "ask",
|
|
78
|
+
"preflightCommandPrefixes": ["tmux", "/usr/bin/tmux"],
|
|
79
|
+
"retryOnUnixSocketError": true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`mode` is `off` by default and only accepts `off` or `ask`. Prefixes are
|
|
85
|
+
trimmed, deduplicated, and must be non-empty. A prefix matches only at the
|
|
86
|
+
start of the trimmed command and must end at whitespace or the end of the
|
|
87
|
+
command. Prefixes select commands for review; they never authorize execution.
|
|
88
|
+
The reviewer and one-shot grant are bound to the full command and working
|
|
89
|
+
directory.
|
|
90
|
+
|
|
91
|
+
With `retryOnUnixSocketError` enabled, commands not selected for preflight
|
|
92
|
+
still run in Sandbox Runtime first. A single host retry is considered only
|
|
93
|
+
after a nonzero exit whose stderr identifies both `Operation not permitted`
|
|
94
|
+
and a socket/connect/IPC operation. The original output is retained, and the
|
|
95
|
+
approval warns that the first attempt may already have had partial side
|
|
96
|
+
effects. Successful, timed-out, or aborted commands are never retried.
|
|
97
|
+
|
|
98
|
+
Host forwarding is intentionally unavailable inside built-in subagents in
|
|
99
|
+
this version.
|
|
100
|
+
|
|
101
|
+
## Additional trusted read paths
|
|
102
|
+
|
|
103
|
+
The trusted global configuration may append absolute paths to the sandbox's
|
|
104
|
+
default read allowlist. This is useful for executables installed below the
|
|
105
|
+
otherwise-denied home directory:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"subagents": {
|
|
110
|
+
"provider": "builtin"
|
|
111
|
+
},
|
|
112
|
+
"filesystem": {
|
|
113
|
+
"additionalAllowRead": [
|
|
114
|
+
"/home/user/.local/bin/rtk"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`filesystem.additionalAllowRead` must be an array of absolute paths. These
|
|
121
|
+
paths extend the defaults; they do not replace the workspace, Node.js, or
|
|
122
|
+
Sandbox Runtime read permissions. Keep entries as narrow as possible and
|
|
123
|
+
prefer an exact executable path over allowing an entire bin directory.
|
|
124
|
+
|
|
65
125
|
Persistent built-in sessions support `start`, `follow_up`, `wait`, `status`,
|
|
66
126
|
`stop`, and nested `handoff` operations. At most four sessions are live at
|
|
67
127
|
once, and nesting depth is capped at three.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Sandbox Runtime-backed Linux and macOS sandbox with pi-auto-review approval",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"src",
|
|
32
|
+
"CHANGELOG.md",
|
|
32
33
|
"README.md",
|
|
33
34
|
"NOTICE"
|
|
34
35
|
],
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@anthropic-ai/sandbox-runtime": "0.0.67",
|
|
53
|
-
"@erichll/pi-auto-review": "0.3.
|
|
54
|
+
"@erichll/pi-auto-review": "0.3.1"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
|
56
57
|
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
package/src/approval.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
} from "@erichll/pi-auto-review/broker";
|
|
5
5
|
import { sandboxTrapToBoundaryRequest } from "@erichll/pi-auto-review/sandbox";
|
|
6
6
|
import type { SandboxApprovalTrap } from "./traps.ts";
|
|
7
|
+
import type { HostIPCTrigger } from "./host-ipc.ts";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
export type NetworkEndpoint = {
|
|
9
10
|
hostname: string;
|
|
@@ -56,6 +57,33 @@ export async function approveDomainEndpoint(
|
|
|
56
57
|
return approveBoundaryRequest(request, context);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export async function approveHostIPCExecution(
|
|
61
|
+
trigger: HostIPCTrigger,
|
|
62
|
+
context: TrapApprovalContext,
|
|
63
|
+
): Promise<TrapApprovalResult> {
|
|
64
|
+
const request: BoundaryRequest = {
|
|
65
|
+
id: `sandbox-runtime-host-ipc-${randomUUID()}`,
|
|
66
|
+
source: "sandbox-runtime",
|
|
67
|
+
surface: "host-ipc",
|
|
68
|
+
operation: "execute-host",
|
|
69
|
+
cwd: context.cwd,
|
|
70
|
+
command: context.command,
|
|
71
|
+
matchedPolicy: {
|
|
72
|
+
decision: "ask",
|
|
73
|
+
rule:
|
|
74
|
+
trigger.reason === "preflight-prefix"
|
|
75
|
+
? `preflight-prefix:${trigger.rule}`
|
|
76
|
+
: "unix-socket-eperm",
|
|
77
|
+
},
|
|
78
|
+
toolInputPreview:
|
|
79
|
+
trigger.reason === "unix-socket-eperm"
|
|
80
|
+
? "The sandboxed attempt failed and may already have had partial side effects."
|
|
81
|
+
: "A configured prefix requested approval before sandbox execution.",
|
|
82
|
+
agentName: context.agentName,
|
|
83
|
+
};
|
|
84
|
+
return approveBoundaryRequest(request, context);
|
|
85
|
+
}
|
|
86
|
+
|
|
59
87
|
async function askHuman(
|
|
60
88
|
request: BoundaryRequest,
|
|
61
89
|
context: TrapApprovalContext,
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
export const SUBAGENT_PROVIDERS = [
|
|
6
6
|
"builtin",
|
|
@@ -10,10 +10,23 @@ export const SUBAGENT_PROVIDERS = [
|
|
|
10
10
|
|
|
11
11
|
export type SubagentProvider = (typeof SUBAGENT_PROVIDERS)[number];
|
|
12
12
|
|
|
13
|
+
export const HOST_IPC_MODES = ["off", "ask"] as const;
|
|
14
|
+
export type HostIPCMode = (typeof HOST_IPC_MODES)[number];
|
|
15
|
+
|
|
16
|
+
export type HostIPCConfig = {
|
|
17
|
+
mode: HostIPCMode;
|
|
18
|
+
preflightCommandPrefixes: readonly string[];
|
|
19
|
+
retryOnUnixSocketError: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
13
22
|
export type PiSandboxConfig = {
|
|
14
23
|
subagents: {
|
|
15
24
|
provider: SubagentProvider;
|
|
16
25
|
};
|
|
26
|
+
filesystem: {
|
|
27
|
+
additionalAllowRead: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
hostIPC: HostIPCConfig;
|
|
17
30
|
};
|
|
18
31
|
|
|
19
32
|
export type LoadPiSandboxConfigOptions = {
|
|
@@ -25,6 +38,14 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
|
|
|
25
38
|
subagents: Object.freeze({
|
|
26
39
|
provider: "builtin",
|
|
27
40
|
}),
|
|
41
|
+
filesystem: Object.freeze({
|
|
42
|
+
additionalAllowRead: Object.freeze([]),
|
|
43
|
+
}),
|
|
44
|
+
hostIPC: Object.freeze({
|
|
45
|
+
mode: "off",
|
|
46
|
+
preflightCommandPrefixes: Object.freeze([]),
|
|
47
|
+
retryOnUnixSocketError: false,
|
|
48
|
+
}),
|
|
28
49
|
},
|
|
29
50
|
);
|
|
30
51
|
|
|
@@ -53,22 +74,18 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
53
74
|
if (!isRecord(value)) {
|
|
54
75
|
throw new Error("invalid pi-sandbox configuration: root must be an object");
|
|
55
76
|
}
|
|
56
|
-
rejectUnknownKeys(value, ["subagents"], "root");
|
|
77
|
+
rejectUnknownKeys(value, ["subagents", "filesystem", "hostIPC"], "root");
|
|
57
78
|
|
|
58
|
-
if (value.subagents
|
|
59
|
-
return {
|
|
60
|
-
subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
if (!isRecord(value.subagents)) {
|
|
79
|
+
if (value.subagents !== undefined && !isRecord(value.subagents)) {
|
|
64
80
|
throw new Error(
|
|
65
81
|
"invalid pi-sandbox configuration: subagents must be an object",
|
|
66
82
|
);
|
|
67
83
|
}
|
|
68
|
-
|
|
84
|
+
const subagents = value.subagents ?? {};
|
|
85
|
+
rejectUnknownKeys(subagents, ["provider"], "subagents");
|
|
69
86
|
|
|
70
87
|
const provider =
|
|
71
|
-
|
|
88
|
+
subagents.provider ?? DEFAULT_PI_SANDBOX_CONFIG.subagents.provider;
|
|
72
89
|
if (
|
|
73
90
|
typeof provider !== "string" ||
|
|
74
91
|
!SUBAGENT_PROVIDERS.includes(provider as SubagentProvider)
|
|
@@ -78,10 +95,90 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
78
95
|
);
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
if (value.filesystem !== undefined && !isRecord(value.filesystem)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"invalid pi-sandbox configuration: filesystem must be an object",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const filesystem = value.filesystem ?? {};
|
|
104
|
+
rejectUnknownKeys(
|
|
105
|
+
filesystem,
|
|
106
|
+
["additionalAllowRead"],
|
|
107
|
+
"filesystem",
|
|
108
|
+
);
|
|
109
|
+
const additionalAllowRead =
|
|
110
|
+
filesystem.additionalAllowRead ??
|
|
111
|
+
DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead;
|
|
112
|
+
if (
|
|
113
|
+
!Array.isArray(additionalAllowRead) ||
|
|
114
|
+
additionalAllowRead.some(
|
|
115
|
+
(path) =>
|
|
116
|
+
typeof path !== "string" ||
|
|
117
|
+
path.trim() === "" ||
|
|
118
|
+
!isAbsolute(path),
|
|
119
|
+
)
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"invalid pi-sandbox configuration: filesystem.additionalAllowRead must be an array of absolute paths",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (value.hostIPC !== undefined && !isRecord(value.hostIPC)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"invalid pi-sandbox configuration: hostIPC must be an object",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const hostIPC = value.hostIPC ?? {};
|
|
132
|
+
rejectUnknownKeys(
|
|
133
|
+
hostIPC,
|
|
134
|
+
["mode", "preflightCommandPrefixes", "retryOnUnixSocketError"],
|
|
135
|
+
"hostIPC",
|
|
136
|
+
);
|
|
137
|
+
const hostIPCMode = hostIPC.mode ?? DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode;
|
|
138
|
+
if (
|
|
139
|
+
typeof hostIPCMode !== "string" ||
|
|
140
|
+
!HOST_IPC_MODES.includes(hostIPCMode as HostIPCMode)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`invalid pi-sandbox configuration: hostIPC.mode must be one of ${HOST_IPC_MODES.join(", ")}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const preflightCommandPrefixes =
|
|
147
|
+
hostIPC.preflightCommandPrefixes ??
|
|
148
|
+
DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes;
|
|
149
|
+
if (
|
|
150
|
+
!Array.isArray(preflightCommandPrefixes) ||
|
|
151
|
+
preflightCommandPrefixes.some(
|
|
152
|
+
(prefix) => typeof prefix !== "string" || prefix.trim() === "",
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"invalid pi-sandbox configuration: hostIPC.preflightCommandPrefixes must be an array of non-empty strings",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const retryOnUnixSocketError =
|
|
160
|
+
hostIPC.retryOnUnixSocketError ??
|
|
161
|
+
DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError;
|
|
162
|
+
if (typeof retryOnUnixSocketError !== "boolean") {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"invalid pi-sandbox configuration: hostIPC.retryOnUnixSocketError must be a boolean",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
81
168
|
return {
|
|
82
169
|
subagents: {
|
|
83
170
|
provider: provider as SubagentProvider,
|
|
84
171
|
},
|
|
172
|
+
filesystem: {
|
|
173
|
+
additionalAllowRead: [...new Set(additionalAllowRead)],
|
|
174
|
+
},
|
|
175
|
+
hostIPC: {
|
|
176
|
+
mode: hostIPCMode as HostIPCMode,
|
|
177
|
+
preflightCommandPrefixes: [
|
|
178
|
+
...new Set(preflightCommandPrefixes.map((prefix) => prefix.trim())),
|
|
179
|
+
],
|
|
180
|
+
retryOnUnixSocketError,
|
|
181
|
+
},
|
|
85
182
|
};
|
|
86
183
|
}
|
|
87
184
|
|
|
@@ -101,6 +198,19 @@ export function loadPiSandboxConfig(
|
|
|
101
198
|
) {
|
|
102
199
|
return {
|
|
103
200
|
subagents: { provider: DEFAULT_PI_SANDBOX_CONFIG.subagents.provider },
|
|
201
|
+
filesystem: {
|
|
202
|
+
additionalAllowRead: [
|
|
203
|
+
...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
hostIPC: {
|
|
207
|
+
mode: DEFAULT_PI_SANDBOX_CONFIG.hostIPC.mode,
|
|
208
|
+
preflightCommandPrefixes: [
|
|
209
|
+
...DEFAULT_PI_SANDBOX_CONFIG.hostIPC.preflightCommandPrefixes,
|
|
210
|
+
],
|
|
211
|
+
retryOnUnixSocketError:
|
|
212
|
+
DEFAULT_PI_SANDBOX_CONFIG.hostIPC.retryOnUnixSocketError,
|
|
213
|
+
},
|
|
104
214
|
};
|
|
105
215
|
}
|
|
106
216
|
throw new Error(`failed to read pi-sandbox configuration at ${path}`, {
|
package/src/host-ipc.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { HostIPCConfig } from "./config.ts";
|
|
2
|
+
import type { TrapApprovalResult } from "./approval.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_STDERR_BYTES = 64 * 1024;
|
|
5
|
+
const SUBAGENT_DEPTH_ENV = "PI_SANDBOX_SUBAGENT_DEPTH";
|
|
6
|
+
|
|
7
|
+
export type HostIPCTrigger =
|
|
8
|
+
| {
|
|
9
|
+
reason: "preflight-prefix";
|
|
10
|
+
rule: string;
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
reason: "unix-socket-eperm";
|
|
14
|
+
rule: "unix-socket-eperm";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CommandResult = { exitCode: number | null };
|
|
18
|
+
|
|
19
|
+
export type HostIPCCommandOptions = {
|
|
20
|
+
command: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
timeout?: number;
|
|
25
|
+
onData(data: Buffer): void;
|
|
26
|
+
config: HostIPCConfig;
|
|
27
|
+
approve(trigger: HostIPCTrigger): Promise<TrapApprovalResult>;
|
|
28
|
+
runSandbox(onStderr: (data: Buffer) => void): Promise<CommandResult>;
|
|
29
|
+
runHost(timeout: number | undefined): Promise<CommandResult>;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function matchingPreflightPrefix(
|
|
34
|
+
command: string,
|
|
35
|
+
prefixes: readonly string[],
|
|
36
|
+
): string | undefined {
|
|
37
|
+
const trimmed = command.trim();
|
|
38
|
+
return prefixes.find(
|
|
39
|
+
(prefix) =>
|
|
40
|
+
trimmed.startsWith(prefix) &&
|
|
41
|
+
(trimmed.length === prefix.length ||
|
|
42
|
+
/\s/.test(trimmed.charAt(prefix.length))),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isUnixSocketPermissionError(stderr: Buffer | string): boolean {
|
|
47
|
+
const text = typeof stderr === "string" ? stderr : stderr.toString("utf8");
|
|
48
|
+
return (
|
|
49
|
+
/operation not permitted/i.test(text) &&
|
|
50
|
+
/socket|connect(?:ing)?|ipc/i.test(text)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function appendBounded(current: Buffer, chunk: Buffer): Buffer {
|
|
55
|
+
if (chunk.length >= MAX_STDERR_BYTES) {
|
|
56
|
+
return chunk.subarray(chunk.length - MAX_STDERR_BYTES);
|
|
57
|
+
}
|
|
58
|
+
const combined = Buffer.concat([current, chunk]);
|
|
59
|
+
return combined.length <= MAX_STDERR_BYTES
|
|
60
|
+
? combined
|
|
61
|
+
: combined.subarray(combined.length - MAX_STDERR_BYTES);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isBuiltinSubagent(env: NodeJS.ProcessEnv | undefined): boolean {
|
|
65
|
+
return Number(env?.[SUBAGENT_DEPTH_ENV] ?? process.env[SUBAGENT_DEPTH_ENV]) > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function remainingTimeout(
|
|
69
|
+
timeout: number | undefined,
|
|
70
|
+
startedAt: number,
|
|
71
|
+
now: () => number,
|
|
72
|
+
): number | undefined {
|
|
73
|
+
if (timeout === undefined) return undefined;
|
|
74
|
+
const remaining = timeout - (now() - startedAt) / 1_000;
|
|
75
|
+
if (remaining <= 0) throw new Error(`timeout:${timeout}`);
|
|
76
|
+
return remaining;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function refusal(options: HostIPCCommandOptions, reason?: string): CommandResult {
|
|
80
|
+
options.onData(
|
|
81
|
+
Buffer.from(
|
|
82
|
+
`pi-sandbox: host-IPC execution denied${reason ? `: ${reason}` : ""}\n`,
|
|
83
|
+
"utf8",
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
return { exitCode: 1 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runCommandWithHostIPC(
|
|
90
|
+
options: HostIPCCommandOptions,
|
|
91
|
+
): Promise<CommandResult> {
|
|
92
|
+
const now = options.now ?? Date.now;
|
|
93
|
+
const startedAt = now();
|
|
94
|
+
const enabled = options.config.mode === "ask";
|
|
95
|
+
const prefix = enabled
|
|
96
|
+
? matchingPreflightPrefix(
|
|
97
|
+
options.command,
|
|
98
|
+
options.config.preflightCommandPrefixes,
|
|
99
|
+
)
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
if (prefix !== undefined) {
|
|
103
|
+
if (isBuiltinSubagent(options.env)) {
|
|
104
|
+
return refusal(
|
|
105
|
+
options,
|
|
106
|
+
"builtin subagents do not support host forwarding",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const approval = await options.approve({
|
|
110
|
+
reason: "preflight-prefix",
|
|
111
|
+
rule: prefix,
|
|
112
|
+
});
|
|
113
|
+
if (approval.action === "deny") {
|
|
114
|
+
return refusal(options, approval.reason);
|
|
115
|
+
}
|
|
116
|
+
return options.runHost(
|
|
117
|
+
remainingTimeout(options.timeout, startedAt, now),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
122
|
+
const sandboxResult = await options.runSandbox((data) => {
|
|
123
|
+
stderr = appendBounded(stderr, data);
|
|
124
|
+
});
|
|
125
|
+
if (
|
|
126
|
+
!enabled ||
|
|
127
|
+
!options.config.retryOnUnixSocketError ||
|
|
128
|
+
sandboxResult.exitCode === 0 ||
|
|
129
|
+
sandboxResult.exitCode === null ||
|
|
130
|
+
options.signal?.aborted ||
|
|
131
|
+
!isUnixSocketPermissionError(stderr)
|
|
132
|
+
) {
|
|
133
|
+
return sandboxResult;
|
|
134
|
+
}
|
|
135
|
+
if (isBuiltinSubagent(options.env)) {
|
|
136
|
+
options.onData(
|
|
137
|
+
Buffer.from(
|
|
138
|
+
"pi-sandbox: host-IPC retry refused: builtin subagents do not support host forwarding\n",
|
|
139
|
+
"utf8",
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
return sandboxResult;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const approval = await options.approve({
|
|
146
|
+
reason: "unix-socket-eperm",
|
|
147
|
+
rule: "unix-socket-eperm",
|
|
148
|
+
});
|
|
149
|
+
if (approval.action === "deny") return sandboxResult;
|
|
150
|
+
|
|
151
|
+
options.onData(
|
|
152
|
+
Buffer.from(
|
|
153
|
+
"\n--- pi-sandbox: approved host-IPC retry (outside OS sandbox) ---\n",
|
|
154
|
+
"utf8",
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
return options.runHost(
|
|
158
|
+
remainingTimeout(options.timeout, startedAt, now),
|
|
159
|
+
);
|
|
160
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,22 +5,27 @@ import type {
|
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import {
|
|
7
7
|
createBashToolDefinition,
|
|
8
|
+
createLocalBashOperations,
|
|
8
9
|
SettingsManager,
|
|
9
10
|
} from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { getBoundaryBroker } from "@erichll/pi-auto-review/broker";
|
|
11
12
|
import {
|
|
12
13
|
approveDomainEndpoint,
|
|
14
|
+
approveHostIPCExecution,
|
|
13
15
|
approveSandboxTrap,
|
|
14
16
|
type HumanApproval,
|
|
15
17
|
} from "./approval.ts";
|
|
16
18
|
import {
|
|
17
19
|
loadPiSandboxConfig,
|
|
20
|
+
type HostIPCConfig,
|
|
18
21
|
type SubagentProvider,
|
|
19
22
|
} from "./config.ts";
|
|
23
|
+
import { runCommandWithHostIPC } from "./host-ipc.ts";
|
|
20
24
|
import {
|
|
21
25
|
runSandboxedCommand,
|
|
22
26
|
type SandboxCommandOptions,
|
|
23
27
|
} from "./runner.ts";
|
|
28
|
+
import { createDefaultPolicy } from "./policy.ts";
|
|
24
29
|
import type {
|
|
25
30
|
ProcessBackedSubagentManager,
|
|
26
31
|
ProcessBackedSubagentSession,
|
|
@@ -35,6 +40,8 @@ export type PiSandboxExtensionOptions = {
|
|
|
35
40
|
subagentManager?: ProcessBackedSubagentManager;
|
|
36
41
|
createSubagentManager?: () => ProcessBackedSubagentManager;
|
|
37
42
|
sandbox?: Pick<SandboxCommandOptions, "broker" | "platform">;
|
|
43
|
+
/** Test/embedding override. Normal extension loading uses trusted global config. */
|
|
44
|
+
hostIPC?: HostIPCConfig;
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
function sessionId(ctx: ExtensionContext): string {
|
|
@@ -81,13 +88,21 @@ function humanApproval(ctx: ExtensionContext): HumanApproval {
|
|
|
81
88
|
return async (request, reason, signal) => {
|
|
82
89
|
if (!ctx.hasUI) return "deny";
|
|
83
90
|
const target =
|
|
84
|
-
request.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
request.surface === "host-ipc"
|
|
92
|
+
? request.command ?? request.operation
|
|
93
|
+
: request.resolvedPath ??
|
|
94
|
+
request.path ??
|
|
95
|
+
request.destination ??
|
|
96
|
+
request.operation;
|
|
88
97
|
const suffix = reason ? `\n${reason}` : "";
|
|
98
|
+
const hostWarning =
|
|
99
|
+
request.surface === "host-ipc"
|
|
100
|
+
? request.matchedPolicy?.rule === "unix-socket-eperm"
|
|
101
|
+
? "\nWarning: the first sandboxed attempt may already have had partial side effects. The retry runs on the host outside the OS sandbox."
|
|
102
|
+
: "\nWarning: this command will run on the host outside the OS sandbox."
|
|
103
|
+
: "";
|
|
89
104
|
const selected = await ctx.ui.select(
|
|
90
|
-
`Sandbox approval required: ${request.operation} ${target}${suffix}`,
|
|
105
|
+
`Sandbox approval required: ${request.operation} ${target}${hostWarning}${suffix}`,
|
|
91
106
|
["Allow this exact operation once", "Deny"],
|
|
92
107
|
{ signal },
|
|
93
108
|
);
|
|
@@ -100,52 +115,82 @@ function humanApproval(ctx: ExtensionContext): HumanApproval {
|
|
|
100
115
|
function sandboxOperations(
|
|
101
116
|
ctx: ExtensionContext,
|
|
102
117
|
turnIndex: () => number,
|
|
118
|
+
additionalAllowRead: readonly string[],
|
|
119
|
+
hostIPC: HostIPCConfig,
|
|
103
120
|
sandbox?: PiSandboxExtensionOptions["sandbox"],
|
|
104
121
|
): BashOperations {
|
|
105
122
|
return {
|
|
106
123
|
exec(command, cwd, options) {
|
|
107
124
|
const currentSessionId = sessionId(ctx);
|
|
108
|
-
|
|
125
|
+
const shellPath = SettingsManager.create(cwd).getShellPath();
|
|
126
|
+
const approvalContext = {
|
|
127
|
+
broker: getBoundaryBroker(),
|
|
128
|
+
command,
|
|
129
|
+
cwd,
|
|
130
|
+
sessionId: currentSessionId,
|
|
131
|
+
scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
|
|
132
|
+
signal: options.signal,
|
|
133
|
+
humanApproval: humanApproval(ctx),
|
|
134
|
+
};
|
|
135
|
+
const local = createLocalBashOperations({ shellPath });
|
|
136
|
+
return runCommandWithHostIPC({
|
|
109
137
|
command,
|
|
110
138
|
cwd,
|
|
111
139
|
env: options.env,
|
|
112
140
|
signal: options.signal,
|
|
113
141
|
timeout: options.timeout,
|
|
114
|
-
...sandbox,
|
|
115
142
|
onData: options.onData,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const result = await
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
signal: options.signal,
|
|
125
|
-
humanApproval: humanApproval(ctx),
|
|
126
|
-
});
|
|
127
|
-
if (result.action === "deny" && result.reason) {
|
|
128
|
-
if (ctx.hasUI) {
|
|
129
|
-
ctx.ui.notify(`Sandbox denied: ${result.reason}`, "warning");
|
|
130
|
-
}
|
|
143
|
+
config: hostIPC,
|
|
144
|
+
approve: async (trigger) => {
|
|
145
|
+
const result = await approveHostIPCExecution(
|
|
146
|
+
trigger,
|
|
147
|
+
approvalContext,
|
|
148
|
+
);
|
|
149
|
+
if (result.action === "deny" && result.reason && ctx.hasUI) {
|
|
150
|
+
ctx.ui.notify(`Host-IPC denied: ${result.reason}`, "warning");
|
|
131
151
|
}
|
|
132
|
-
return result
|
|
152
|
+
return result;
|
|
133
153
|
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
runHost: (timeout) =>
|
|
155
|
+
local.exec(command, cwd, {
|
|
156
|
+
...options,
|
|
157
|
+
timeout,
|
|
158
|
+
}),
|
|
159
|
+
runSandbox: (onStderr) =>
|
|
160
|
+
runSandboxedCommand({
|
|
137
161
|
command,
|
|
138
162
|
cwd,
|
|
139
|
-
|
|
140
|
-
scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
|
|
163
|
+
env: options.env,
|
|
141
164
|
signal: options.signal,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
timeout: options.timeout,
|
|
166
|
+
...sandbox,
|
|
167
|
+
onData: options.onData,
|
|
168
|
+
onStderr,
|
|
169
|
+
shellPath,
|
|
170
|
+
policy: createDefaultPolicy(cwd, { additionalAllowRead }),
|
|
171
|
+
review: async (trap) => {
|
|
172
|
+
const result = await approveSandboxTrap(trap, approvalContext);
|
|
173
|
+
if (result.action === "deny" && result.reason) {
|
|
174
|
+
if (ctx.hasUI) {
|
|
175
|
+
ctx.ui.notify(`Sandbox denied: ${result.reason}`, "warning");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return result.action;
|
|
179
|
+
},
|
|
180
|
+
reviewDomain: async (endpoint) => {
|
|
181
|
+
const result = await approveDomainEndpoint(
|
|
182
|
+
endpoint,
|
|
183
|
+
approvalContext,
|
|
184
|
+
);
|
|
185
|
+
if (result.action === "deny" && result.reason && ctx.hasUI) {
|
|
186
|
+
ctx.ui.notify(
|
|
187
|
+
`Domain proxy denied: ${result.reason}`,
|
|
188
|
+
"warning",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return result.action;
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
149
194
|
});
|
|
150
195
|
},
|
|
151
196
|
};
|
|
@@ -156,8 +201,11 @@ async function performRegistration(
|
|
|
156
201
|
options: PiSandboxExtensionOptions,
|
|
157
202
|
): Promise<void> {
|
|
158
203
|
let currentTurn = 0;
|
|
204
|
+
const config = loadPiSandboxConfig();
|
|
159
205
|
const subagentProvider =
|
|
160
|
-
options.subagentProvider ??
|
|
206
|
+
options.subagentProvider ?? config.subagents.provider;
|
|
207
|
+
const additionalAllowRead = config.filesystem.additionalAllowRead;
|
|
208
|
+
const hostIPC = options.hostIPC ?? config.hostIPC;
|
|
161
209
|
const subagents =
|
|
162
210
|
subagentProvider === "builtin"
|
|
163
211
|
? (options.subagentManager ??
|
|
@@ -176,7 +224,13 @@ async function performRegistration(
|
|
|
176
224
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
177
225
|
if (!ctx) throw new Error(`${EXTENSION_NAME}: missing extension context`);
|
|
178
226
|
const tool = createBashToolDefinition(ctx.cwd, {
|
|
179
|
-
operations: sandboxOperations(
|
|
227
|
+
operations: sandboxOperations(
|
|
228
|
+
ctx,
|
|
229
|
+
() => currentTurn,
|
|
230
|
+
additionalAllowRead,
|
|
231
|
+
hostIPC,
|
|
232
|
+
options.sandbox,
|
|
233
|
+
),
|
|
180
234
|
});
|
|
181
235
|
return tool.execute(id, params, signal, onUpdate, ctx);
|
|
182
236
|
},
|
|
@@ -262,6 +316,7 @@ async function performRegistration(
|
|
|
262
316
|
cwd: ctx.cwd,
|
|
263
317
|
model: params.model,
|
|
264
318
|
tools: pi.getActiveTools().filter((name) => name !== "subagent"),
|
|
319
|
+
policy: createDefaultPolicy(ctx.cwd, { additionalAllowRead }),
|
|
265
320
|
sandbox: options.sandbox,
|
|
266
321
|
review: async (trap: Parameters<typeof approveSandboxTrap>[0]) =>
|
|
267
322
|
(await approveSandboxTrap(trap, approvalContext)).action,
|
|
@@ -408,7 +463,13 @@ async function performRegistration(
|
|
|
408
463
|
});
|
|
409
464
|
|
|
410
465
|
pi.on("user_bash", (_event, ctx) => ({
|
|
411
|
-
operations: sandboxOperations(
|
|
466
|
+
operations: sandboxOperations(
|
|
467
|
+
ctx,
|
|
468
|
+
() => currentTurn,
|
|
469
|
+
additionalAllowRead,
|
|
470
|
+
hostIPC,
|
|
471
|
+
options.sandbox,
|
|
472
|
+
),
|
|
412
473
|
}));
|
|
413
474
|
|
|
414
475
|
pi.on("turn_start", (event) => {
|
package/src/policy.ts
CHANGED
|
@@ -35,7 +35,14 @@ export type SandboxPolicy = {
|
|
|
35
35
|
};
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
export
|
|
38
|
+
export type CreateDefaultPolicyOptions = {
|
|
39
|
+
additionalAllowRead?: readonly string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function createDefaultPolicy(
|
|
43
|
+
cwd: string,
|
|
44
|
+
options: CreateDefaultPolicyOptions = {},
|
|
45
|
+
): SandboxPolicy {
|
|
39
46
|
const workspace = resolve(cwd);
|
|
40
47
|
const home = resolve(homedir());
|
|
41
48
|
const denyRead = home === parse(home).root ? [] : [home];
|
|
@@ -53,6 +60,7 @@ export function createDefaultPolicy(cwd: string): SandboxPolicy {
|
|
|
53
60
|
join(home, ".gitconfig"),
|
|
54
61
|
join(home, ".config", "git", "config"),
|
|
55
62
|
"/dev/null",
|
|
63
|
+
...(options.additionalAllowRead ?? []),
|
|
56
64
|
],
|
|
57
65
|
allowWrite: [workspace, "/dev/null"],
|
|
58
66
|
denyWrite: [
|