@erichll/pi-sandbox 0.3.1 → 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 +36 -0
- package/package.json +3 -2
- package/src/approval.ts +28 -0
- package/src/config.ts +73 -1
- package/src/host-ipc.ts +160 -0
- package/src/index.ts +81 -36
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,39 @@ 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
|
+
|
|
65
101
|
## Additional trusted read paths
|
|
66
102
|
|
|
67
103
|
The trusted global configuration may append absolute paths to the sandbox's
|
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
|
@@ -10,6 +10,15 @@ 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;
|
|
@@ -17,6 +26,7 @@ export type PiSandboxConfig = {
|
|
|
17
26
|
filesystem: {
|
|
18
27
|
additionalAllowRead: readonly string[];
|
|
19
28
|
};
|
|
29
|
+
hostIPC: HostIPCConfig;
|
|
20
30
|
};
|
|
21
31
|
|
|
22
32
|
export type LoadPiSandboxConfigOptions = {
|
|
@@ -31,6 +41,11 @@ export const DEFAULT_PI_SANDBOX_CONFIG: Readonly<PiSandboxConfig> = Object.freez
|
|
|
31
41
|
filesystem: Object.freeze({
|
|
32
42
|
additionalAllowRead: Object.freeze([]),
|
|
33
43
|
}),
|
|
44
|
+
hostIPC: Object.freeze({
|
|
45
|
+
mode: "off",
|
|
46
|
+
preflightCommandPrefixes: Object.freeze([]),
|
|
47
|
+
retryOnUnixSocketError: false,
|
|
48
|
+
}),
|
|
34
49
|
},
|
|
35
50
|
);
|
|
36
51
|
|
|
@@ -59,7 +74,7 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
59
74
|
if (!isRecord(value)) {
|
|
60
75
|
throw new Error("invalid pi-sandbox configuration: root must be an object");
|
|
61
76
|
}
|
|
62
|
-
rejectUnknownKeys(value, ["subagents", "filesystem"], "root");
|
|
77
|
+
rejectUnknownKeys(value, ["subagents", "filesystem", "hostIPC"], "root");
|
|
63
78
|
|
|
64
79
|
if (value.subagents !== undefined && !isRecord(value.subagents)) {
|
|
65
80
|
throw new Error(
|
|
@@ -108,6 +123,48 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
108
123
|
);
|
|
109
124
|
}
|
|
110
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
|
+
|
|
111
168
|
return {
|
|
112
169
|
subagents: {
|
|
113
170
|
provider: provider as SubagentProvider,
|
|
@@ -115,6 +172,13 @@ export function parsePiSandboxConfig(value: unknown): PiSandboxConfig {
|
|
|
115
172
|
filesystem: {
|
|
116
173
|
additionalAllowRead: [...new Set(additionalAllowRead)],
|
|
117
174
|
},
|
|
175
|
+
hostIPC: {
|
|
176
|
+
mode: hostIPCMode as HostIPCMode,
|
|
177
|
+
preflightCommandPrefixes: [
|
|
178
|
+
...new Set(preflightCommandPrefixes.map((prefix) => prefix.trim())),
|
|
179
|
+
],
|
|
180
|
+
retryOnUnixSocketError,
|
|
181
|
+
},
|
|
118
182
|
};
|
|
119
183
|
}
|
|
120
184
|
|
|
@@ -139,6 +203,14 @@ export function loadPiSandboxConfig(
|
|
|
139
203
|
...DEFAULT_PI_SANDBOX_CONFIG.filesystem.additionalAllowRead,
|
|
140
204
|
],
|
|
141
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
|
+
},
|
|
142
214
|
};
|
|
143
215
|
}
|
|
144
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,18 +5,22 @@ 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,
|
|
@@ -36,6 +40,8 @@ export type PiSandboxExtensionOptions = {
|
|
|
36
40
|
subagentManager?: ProcessBackedSubagentManager;
|
|
37
41
|
createSubagentManager?: () => ProcessBackedSubagentManager;
|
|
38
42
|
sandbox?: Pick<SandboxCommandOptions, "broker" | "platform">;
|
|
43
|
+
/** Test/embedding override. Normal extension loading uses trusted global config. */
|
|
44
|
+
hostIPC?: HostIPCConfig;
|
|
39
45
|
};
|
|
40
46
|
|
|
41
47
|
function sessionId(ctx: ExtensionContext): string {
|
|
@@ -82,13 +88,21 @@ function humanApproval(ctx: ExtensionContext): HumanApproval {
|
|
|
82
88
|
return async (request, reason, signal) => {
|
|
83
89
|
if (!ctx.hasUI) return "deny";
|
|
84
90
|
const target =
|
|
85
|
-
request.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
request.surface === "host-ipc"
|
|
92
|
+
? request.command ?? request.operation
|
|
93
|
+
: request.resolvedPath ??
|
|
94
|
+
request.path ??
|
|
95
|
+
request.destination ??
|
|
96
|
+
request.operation;
|
|
89
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
|
+
: "";
|
|
90
104
|
const selected = await ctx.ui.select(
|
|
91
|
-
`Sandbox approval required: ${request.operation} ${target}${suffix}`,
|
|
105
|
+
`Sandbox approval required: ${request.operation} ${target}${hostWarning}${suffix}`,
|
|
92
106
|
["Allow this exact operation once", "Deny"],
|
|
93
107
|
{ signal },
|
|
94
108
|
);
|
|
@@ -102,53 +116,81 @@ function sandboxOperations(
|
|
|
102
116
|
ctx: ExtensionContext,
|
|
103
117
|
turnIndex: () => number,
|
|
104
118
|
additionalAllowRead: readonly string[],
|
|
119
|
+
hostIPC: HostIPCConfig,
|
|
105
120
|
sandbox?: PiSandboxExtensionOptions["sandbox"],
|
|
106
121
|
): BashOperations {
|
|
107
122
|
return {
|
|
108
123
|
exec(command, cwd, options) {
|
|
109
124
|
const currentSessionId = sessionId(ctx);
|
|
110
|
-
|
|
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({
|
|
111
137
|
command,
|
|
112
138
|
cwd,
|
|
113
139
|
env: options.env,
|
|
114
140
|
signal: options.signal,
|
|
115
141
|
timeout: options.timeout,
|
|
116
|
-
...sandbox,
|
|
117
142
|
onData: options.onData,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
|
|
127
|
-
signal: options.signal,
|
|
128
|
-
humanApproval: humanApproval(ctx),
|
|
129
|
-
});
|
|
130
|
-
if (result.action === "deny" && result.reason) {
|
|
131
|
-
if (ctx.hasUI) {
|
|
132
|
-
ctx.ui.notify(`Sandbox denied: ${result.reason}`, "warning");
|
|
133
|
-
}
|
|
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");
|
|
134
151
|
}
|
|
135
|
-
return result
|
|
152
|
+
return result;
|
|
136
153
|
},
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
runHost: (timeout) =>
|
|
155
|
+
local.exec(command, cwd, {
|
|
156
|
+
...options,
|
|
157
|
+
timeout,
|
|
158
|
+
}),
|
|
159
|
+
runSandbox: (onStderr) =>
|
|
160
|
+
runSandboxedCommand({
|
|
140
161
|
command,
|
|
141
162
|
cwd,
|
|
142
|
-
|
|
143
|
-
scopeKey: `${currentSessionId}:turn:${turnIndex()}`,
|
|
163
|
+
env: options.env,
|
|
144
164
|
signal: options.signal,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}),
|
|
152
194
|
});
|
|
153
195
|
},
|
|
154
196
|
};
|
|
@@ -163,6 +205,7 @@ async function performRegistration(
|
|
|
163
205
|
const subagentProvider =
|
|
164
206
|
options.subagentProvider ?? config.subagents.provider;
|
|
165
207
|
const additionalAllowRead = config.filesystem.additionalAllowRead;
|
|
208
|
+
const hostIPC = options.hostIPC ?? config.hostIPC;
|
|
166
209
|
const subagents =
|
|
167
210
|
subagentProvider === "builtin"
|
|
168
211
|
? (options.subagentManager ??
|
|
@@ -185,6 +228,7 @@ async function performRegistration(
|
|
|
185
228
|
ctx,
|
|
186
229
|
() => currentTurn,
|
|
187
230
|
additionalAllowRead,
|
|
231
|
+
hostIPC,
|
|
188
232
|
options.sandbox,
|
|
189
233
|
),
|
|
190
234
|
});
|
|
@@ -423,6 +467,7 @@ async function performRegistration(
|
|
|
423
467
|
ctx,
|
|
424
468
|
() => currentTurn,
|
|
425
469
|
additionalAllowRead,
|
|
470
|
+
hostIPC,
|
|
426
471
|
options.sandbox,
|
|
427
472
|
),
|
|
428
473
|
}));
|