auto-permission-system 3.9.1 → 3.12.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/extensions/guard.ts +16 -1
- package/package.json +1 -1
- package/sandbox.claude.json +1 -0
- package/sandbox.default.json +12 -2
- package/skills/auto-permission-system/SKILL.md +1 -0
- package/src/core/policy/patterns.ts +57 -0
- package/src/detect/sensitive-file-scanner/patterns.ts +27 -0
- package/src/l1-sandbox/bash-ops.ts +75 -4
- package/src/l1-sandbox/config.ts +18 -1
- package/src/l1-sandbox/host-bridge.ts +99 -0
- package/src/l1-sandbox/manager.ts +14 -2
- package/src/ui/ask-flow.ts +11 -1
- package/src/ui/ask-selector.ts +83 -10
package/extensions/guard.ts
CHANGED
|
@@ -91,7 +91,22 @@ async function detectMcpAsk(
|
|
|
91
91
|
policy: mcpPolicy,
|
|
92
92
|
});
|
|
93
93
|
if (result.decision !== "ask") return null;
|
|
94
|
-
|
|
94
|
+
|
|
95
|
+
// Include detailed call parameters or script preview if present
|
|
96
|
+
let detailedSummary = result.summary;
|
|
97
|
+
if (input && Object.keys(input).length > 0) {
|
|
98
|
+
const rawScript = input.code ?? input.script ?? input.command ?? input.query;
|
|
99
|
+
if (typeof rawScript === "string" && rawScript.includes("\n")) {
|
|
100
|
+
detailedSummary += `\n\n${rawScript}`;
|
|
101
|
+
} else {
|
|
102
|
+
const formattedArgs = JSON.stringify(input, null, 2);
|
|
103
|
+
if (formattedArgs.includes("\n")) {
|
|
104
|
+
detailedSummary += `\n\nArguments:\n${formattedArgs}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return askDetection(ctx, "tool", toolName, detailedSummary);
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
export default function (pi: ExtensionAPI) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-permission-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "Claude auto mode for Pi & security for Pi. A zero-trust permission system for Pi coding agent powered by Advanced Secure mode to scan files, content, and tools to prevent data breaches.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Navin Kumar",
|
package/sandbox.claude.json
CHANGED
package/sandbox.default.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"enabled": true,
|
|
3
3
|
"mode": "advanced-secure",
|
|
4
4
|
"network": {
|
|
5
|
+
"allowLocalhost": true,
|
|
5
6
|
"allowedDomains": [
|
|
6
7
|
"registry.npmjs.org",
|
|
7
8
|
"registry.yarnpkg.com",
|
|
@@ -24,13 +25,22 @@
|
|
|
24
25
|
"rubygems.org",
|
|
25
26
|
"api.nuget.org",
|
|
26
27
|
"deno.land",
|
|
27
|
-
"jsr.io"
|
|
28
|
+
"jsr.io",
|
|
29
|
+
"api.openai.com",
|
|
30
|
+
"huggingface.co",
|
|
31
|
+
"*.huggingface.co",
|
|
32
|
+
"cdn-lfs.huggingface.co",
|
|
33
|
+
"*.diffusion.studio",
|
|
34
|
+
"diffusion.studio"
|
|
28
35
|
]
|
|
29
36
|
},
|
|
30
37
|
"filesystem": {
|
|
31
38
|
"outsideProject": {
|
|
32
39
|
"read": "allow",
|
|
33
|
-
"allowRead": [
|
|
40
|
+
"allowRead": [
|
|
41
|
+
"~/Downloads",
|
|
42
|
+
"~/.cache"
|
|
43
|
+
]
|
|
34
44
|
},
|
|
35
45
|
"denyRead": [],
|
|
36
46
|
"askRead": [
|
|
@@ -39,6 +39,7 @@ A higher layer only states what it changes; absent keys keep the layer below. A
|
|
|
39
39
|
"enabled": true,
|
|
40
40
|
"mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
|
|
41
41
|
"network": {
|
|
42
|
+
"allowLocalhost": true, // true (default): localhost on all ports allowed; false: strict network isolation
|
|
42
43
|
"allowedDomains": ["github.com", "*.github.com"], // empty = allow all
|
|
43
44
|
"deniedDomains": [], // hard deny, never ask-able
|
|
44
45
|
"strictAllowlist": false // true = deny unknown hosts instead of asking
|
|
@@ -31,6 +31,63 @@ export function isWriteBlockError(output: string): boolean {
|
|
|
31
31
|
return WRITE_BLOCK_RE.test(output);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Loopback / localhost connection refusal evidence in a sandboxed command's output.
|
|
36
|
+
* On Linux, bubblewrap runs with --unshare-net which isolates network namespaces.
|
|
37
|
+
* Host services listening on 127.0.0.1/localhost (like Diffusion Studio on port 3274,
|
|
38
|
+
* or local dev servers) are not reachable from inside the container namespace.
|
|
39
|
+
*/
|
|
40
|
+
const LOOPBACK_REFUSAL_RE =
|
|
41
|
+
/(?:Failed to connect to (?:127\.0\.0\.1|localhost|::1)|(?:connect|connection refused).*?(?:127\.0\.0\.1|localhost|::1)|(?:127\.0\.0\.1|localhost|::1).*?(?:connection refused|ECONNREFUSED)|ECONNREFUSED.*?(?:127\.0\.0\.1|localhost|::1)|Diffusion Studio is not running)/i;
|
|
42
|
+
|
|
43
|
+
/** True when the command failed due to connecting to a loopback/localhost address in the sandbox. */
|
|
44
|
+
export function isLoopbackConnectionRefused(output: string): boolean {
|
|
45
|
+
return LOOPBACK_REFUSAL_RE.test(output);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LoopbackEndpointInfo {
|
|
49
|
+
host: string;
|
|
50
|
+
port?: string;
|
|
51
|
+
service?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract endpoint metadata (host, port, service name) from a loopback connection failure.
|
|
56
|
+
*/
|
|
57
|
+
export function extractLoopbackEndpoint(output: string, command?: string): LoopbackEndpointInfo | undefined {
|
|
58
|
+
if (!isLoopbackConnectionRefused(output)) return undefined;
|
|
59
|
+
|
|
60
|
+
// Check for Diffusion Studio specific error or command
|
|
61
|
+
const isDiffusion = /diffusion studio is not running/i.test(output) || (command && /\b(?:dapi|diffusion)\b/i.test(command));
|
|
62
|
+
if (isDiffusion) {
|
|
63
|
+
return { host: "127.0.0.1", port: "3274", service: "Diffusion Studio" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Match "Failed to connect to 127.0.0.1 port 3274" or "localhost port 8080"
|
|
67
|
+
const curlMatch = output.match(/Failed to connect to (127\.0\.0\.1|localhost|::1) port (\d+)/i);
|
|
68
|
+
if (curlMatch) {
|
|
69
|
+
return {
|
|
70
|
+
host: curlMatch[1],
|
|
71
|
+
port: curlMatch[2],
|
|
72
|
+
service: curlMatch[2] === "3274" ? "Diffusion Studio" : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Match "connect ECONNREFUSED 127.0.0.1:3274"
|
|
77
|
+
const nodeMatch = output.match(/(?:ECONNREFUSED|connect to)\s+(127\.0\.0\.1|localhost|::1)(?::| port )(\d+)/i);
|
|
78
|
+
if (nodeMatch) {
|
|
79
|
+
return {
|
|
80
|
+
host: nodeMatch[1],
|
|
81
|
+
port: nodeMatch[2],
|
|
82
|
+
service: nodeMatch[2] === "3274" ? "Diffusion Studio" : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generic host match
|
|
87
|
+
const hostMatch = output.match(/(127\.0\.0\.1|localhost|::1)/i);
|
|
88
|
+
return { host: hostMatch ? hostMatch[1] : "127.0.0.1" };
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
/** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
|
|
35
92
|
function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
|
|
36
93
|
let token = rawToken.trim().replace(/[.,;]+$/, "");
|
|
@@ -181,6 +181,33 @@ export const COMMON_NON_SECRET_VALUES = new Set([
|
|
|
181
181
|
"secretvalue",
|
|
182
182
|
"somevalue",
|
|
183
183
|
"myvalue",
|
|
184
|
+
// Common keyboard keys / UI events (e.g. `Successfully pressed key: Backspace`)
|
|
185
|
+
"backspace",
|
|
186
|
+
"tab",
|
|
187
|
+
"enter",
|
|
188
|
+
"return",
|
|
189
|
+
"escape",
|
|
190
|
+
"esc",
|
|
191
|
+
"space",
|
|
192
|
+
"pageup",
|
|
193
|
+
"pagedown",
|
|
194
|
+
"end",
|
|
195
|
+
"home",
|
|
196
|
+
"arrowleft",
|
|
197
|
+
"arrowup",
|
|
198
|
+
"arrowright",
|
|
199
|
+
"arrowdown",
|
|
200
|
+
"left",
|
|
201
|
+
"up",
|
|
202
|
+
"right",
|
|
203
|
+
"down",
|
|
204
|
+
"delete",
|
|
205
|
+
"insert",
|
|
206
|
+
"capslock",
|
|
207
|
+
"numlock",
|
|
208
|
+
"scrolllock",
|
|
209
|
+
"printscreen",
|
|
210
|
+
"pause",
|
|
184
211
|
]);
|
|
185
212
|
|
|
186
213
|
/**
|
|
@@ -9,11 +9,12 @@ import { homedir } from "node:os";
|
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
11
11
|
import { type BashOperations } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { askReadCandidates, extractBlockedPath, isBlockedAccessError, isSafeFolderGrant, isWriteBlockError, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
|
|
12
|
+
import { askReadCandidates, extractBlockedPath, extractLoopbackEndpoint, isBlockedAccessError, isLoopbackConnectionRefused, isSafeFolderGrant, isWriteBlockError, matchedAskCommands, matchesPolicyPattern, outsideProjectMode, outsideProjectReadCandidates, outsideProjectReadDenied, sandboxFilesystem } from "../core/index";
|
|
13
13
|
import { audit } from "../shared/audit";
|
|
14
14
|
import { canonicalize } from "../shared/canonicalize";
|
|
15
15
|
import { askMain, askMainWithRemember, askRememberFile, MainChoice, type AskCtx } from "../ui/ask-flow";
|
|
16
16
|
import { loadConfig, projectTrusted } from "./config";
|
|
17
|
+
import { injectBridgesIntoBwrap, startHostPortBridges, type HostPortBridge } from "./host-bridge";
|
|
17
18
|
|
|
18
19
|
/** First whitespace-separated token of a bash command — used to name the binary in prompt titles. */
|
|
19
20
|
function firstWord(command: string): string {
|
|
@@ -76,11 +77,15 @@ async function preflightSensitiveCommands(
|
|
|
76
77
|
const matched = matchedAskCommands(command, ask, cwd, homedir());
|
|
77
78
|
if (!matched.length) return undefined;
|
|
78
79
|
const ui = opts.ctx as AskCtx;
|
|
79
|
-
const
|
|
80
|
+
const bodyLines: string[] = [
|
|
80
81
|
`Command ${matched.join(", ")}`,
|
|
81
82
|
`Risk environment variables often hold API keys and tokens`,
|
|
82
83
|
`Note can only be allowed one run at a time`,
|
|
83
|
-
]
|
|
84
|
+
];
|
|
85
|
+
if (command.includes("\n")) {
|
|
86
|
+
bodyLines.push(`\nScript:\n${command}`);
|
|
87
|
+
}
|
|
88
|
+
const body = bodyLines.join("\n");
|
|
84
89
|
const choice = await askMain(ui, `Let ${matched[0]} print your environment variables?`, body, { once: true, remember: false, icon: "cred" });
|
|
85
90
|
const subject = matched.join(", ");
|
|
86
91
|
if (choice === "once") {
|
|
@@ -215,7 +220,26 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
215
220
|
const grantedWriteFolders = new Set<string>();
|
|
216
221
|
const grantedWriteFiles = new Set<string>();
|
|
217
222
|
const attempt = async (writeOnce: string[]): Promise<{ exitCode: number | null }> => {
|
|
218
|
-
const
|
|
223
|
+
const cfg = loadConfig(cwd);
|
|
224
|
+
const allowLocalhost = cfg.allowLocalhost ?? cfg.network?.allowLocalhost ?? true;
|
|
225
|
+
let bridges: HostPortBridge[] = [];
|
|
226
|
+
if (!allowLocalhost && process.platform === "linux") {
|
|
227
|
+
const hostPorts = cfg.bridgeHostPorts ?? [3274];
|
|
228
|
+
bridges = await startHostPortBridges(hostPorts);
|
|
229
|
+
}
|
|
230
|
+
const rawWrapped = await SandboxManager.wrapWithSandbox(command, undefined, buildCustom(writeOnce));
|
|
231
|
+
let wrappedCommand = rawWrapped;
|
|
232
|
+
if (process.platform === "linux") {
|
|
233
|
+
if (allowLocalhost) {
|
|
234
|
+
wrappedCommand = wrappedCommand.replace(" --unshare-net ", " ");
|
|
235
|
+
} else if (bridges.length > 0) {
|
|
236
|
+
wrappedCommand = injectBridgesIntoBwrap(wrappedCommand, bridges);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const cleanupBridges = () => {
|
|
241
|
+
bridges.forEach((b) => b.close());
|
|
242
|
+
};
|
|
219
243
|
|
|
220
244
|
return new Promise((resolve, reject) => {
|
|
221
245
|
const child = spawn("bash", ["-c", wrappedCommand], {
|
|
@@ -251,11 +275,13 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
251
275
|
|
|
252
276
|
child.on("error", (err) => {
|
|
253
277
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
278
|
+
cleanupBridges();
|
|
254
279
|
SandboxManager.cleanupAfterCommand();
|
|
255
280
|
reject(err);
|
|
256
281
|
});
|
|
257
282
|
|
|
258
283
|
const onAbort = () => {
|
|
284
|
+
cleanupBridges();
|
|
259
285
|
if (child.pid) {
|
|
260
286
|
try {
|
|
261
287
|
process.kill(-child.pid, "SIGKILL");
|
|
@@ -270,6 +296,7 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
270
296
|
child.on("close", async (code) => {
|
|
271
297
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
272
298
|
signal?.removeEventListener("abort", onAbort);
|
|
299
|
+
cleanupBridges();
|
|
273
300
|
|
|
274
301
|
SandboxManager.cleanupAfterCommand();
|
|
275
302
|
|
|
@@ -326,6 +353,50 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
326
353
|
` or write inside the project directory (${cwd}).\n`;
|
|
327
354
|
}
|
|
328
355
|
blockedHint += ` Policy: ~/.pi/agent/extensions/sandbox.json (+ <cwd>/.pi/sandbox.json overrides).\n`;
|
|
356
|
+
} else if (code !== 0 && isLoopbackConnectionRefused(outputTail)) {
|
|
357
|
+
const endpointInfo = extractLoopbackEndpoint(outputTail, command);
|
|
358
|
+
const endpointStr = endpointInfo?.port ? `${endpointInfo.host}:${endpointInfo.port}` : (endpointInfo?.host ?? "127.0.0.1");
|
|
359
|
+
const serviceName = endpointInfo?.service;
|
|
360
|
+
|
|
361
|
+
auditL1({
|
|
362
|
+
subject: endpointStr,
|
|
363
|
+
decision: "loopback-refused",
|
|
364
|
+
reason: "Linux sandbox network namespace (--unshare-net) isolates host loopback",
|
|
365
|
+
cwd: opts?.ctx?.cwd,
|
|
366
|
+
note: serviceName ? `service:${serviceName}` : undefined,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const notifyMsg = serviceName === "Diffusion Studio"
|
|
370
|
+
? "pi-sandbox: Diffusion Studio is running on host port 3274, but sandboxed bash cannot reach host localhost. Connect it via MCP in ~/.pi/agent/mcp.json."
|
|
371
|
+
: `pi-sandbox: connection to host ${endpointStr} refused. Sandboxed bash cannot reach host localhost.`;
|
|
372
|
+
opts?.ctx?.ui?.notify?.(notifyMsg, "warning");
|
|
373
|
+
|
|
374
|
+
blockedHint = `\n💡 pi-sandbox: localhost / loopback connection refused.\n` +
|
|
375
|
+
` Target: ${endpointStr}${serviceName ? ` (${serviceName})` : ""}\n` +
|
|
376
|
+
` Why: The Linux sandbox runs inside an isolated network namespace (--unshare-net).\n` +
|
|
377
|
+
` Services running on your HOST's 127.0.0.1/localhost are isolated and cannot\n` +
|
|
378
|
+
` be reached directly from sandboxed bash commands.\n`;
|
|
379
|
+
|
|
380
|
+
if (serviceName === "Diffusion Studio" || /dapi|diffusion|3274/i.test(command)) {
|
|
381
|
+
blockedHint +=
|
|
382
|
+
` Resolution for Diffusion Studio:\n` +
|
|
383
|
+
` • Diffusion Studio exposes an MCP server on http://127.0.0.1:3274/mcp\n` +
|
|
384
|
+
` • Add it to ~/.pi/agent/mcp.json so Pi connects directly on the host:\n` +
|
|
385
|
+
` {\n` +
|
|
386
|
+
` "mcpServers": {\n` +
|
|
387
|
+
` "diffusion-studio": { "url": "http://127.0.0.1:3274/mcp" }\n` +
|
|
388
|
+
` }\n` +
|
|
389
|
+
` }\n` +
|
|
390
|
+
` • Once added, restart or reload Pi: the agent gets native MCP tools\n` +
|
|
391
|
+
` (context, check, capture, probe) with Layer 2 security, no bash needed.\n` +
|
|
392
|
+
` • Or run pi with --yolo for one-off host access.\n`;
|
|
393
|
+
} else {
|
|
394
|
+
blockedHint +=
|
|
395
|
+
` Resolution:\n` +
|
|
396
|
+
` • If this is an MCP service, configure it in ~/.pi/agent/mcp.json.\n` +
|
|
397
|
+
` • Pi runs on the host and can communicate with local MCP servers directly.\n` +
|
|
398
|
+
` • Or run pi with --yolo if this task requires interacting with host network services.\n`;
|
|
399
|
+
}
|
|
329
400
|
}
|
|
330
401
|
/**
|
|
331
402
|
* Emit the block explanation only when the command is not re-run with a
|
package/src/l1-sandbox/config.ts
CHANGED
|
@@ -22,10 +22,18 @@ export interface SandboxFilesystem extends NonNullable<SandboxRuntimeConfig["fil
|
|
|
22
22
|
outsideProject?: { read?: ReadPosture; allowRead?: string[] };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export interface
|
|
25
|
+
export interface SandboxNetwork extends Partial<NonNullable<SandboxRuntimeConfig["network"]>> {
|
|
26
|
+
/** Whether to allow localhost / loopback on all ports (default: true). Set false to strictly isolate network. */
|
|
27
|
+
allowLocalhost?: boolean;
|
|
28
|
+
/** Host TCP ports to bridge into the Linux sandbox namespace when allowLocalhost is false. */
|
|
29
|
+
bridgeHostPorts?: number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SandboxConfig extends Omit<SandboxRuntimeConfig, "filesystem" | "network"> {
|
|
26
33
|
enabled?: boolean;
|
|
27
34
|
/** `default` = rules only; `advanced-secure` adds secret/credential detection (ADR-018). */
|
|
28
35
|
mode?: PermissionMode;
|
|
36
|
+
network?: SandboxNetwork;
|
|
29
37
|
filesystem?: SandboxFilesystem;
|
|
30
38
|
/**
|
|
31
39
|
* Additive project-local overrides written by the ask-tier prompts (Layer 1
|
|
@@ -40,6 +48,14 @@ export interface SandboxConfig extends Omit<SandboxRuntimeConfig, "filesystem">
|
|
|
40
48
|
};
|
|
41
49
|
/** Commands/paths that can print secrets (env, proc environ) and must ask first. */
|
|
42
50
|
commands?: { ask?: string[] };
|
|
51
|
+
/** Allow all Unix domain sockets on Linux (disables seccomp socket blocking). */
|
|
52
|
+
allowAllUnixSockets?: boolean;
|
|
53
|
+
/** Allow local port binding. */
|
|
54
|
+
allowLocalBinding?: boolean;
|
|
55
|
+
/** Whether to allow localhost / loopback on all ports (default: true). Set false to strictly isolate network. */
|
|
56
|
+
allowLocalhost?: boolean;
|
|
57
|
+
/** Host TCP ports to bridge into the Linux sandbox namespace when allowLocalhost is false. */
|
|
58
|
+
bridgeHostPorts?: number[];
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
const BUILTIN_CONFIG: SandboxConfig = {
|
|
@@ -48,6 +64,7 @@ const BUILTIN_CONFIG: SandboxConfig = {
|
|
|
48
64
|
network: {
|
|
49
65
|
allowedDomains: [...BUILTIN_NETWORK_ALLOWED],
|
|
50
66
|
deniedDomains: [],
|
|
67
|
+
allowLocalhost: true,
|
|
51
68
|
},
|
|
52
69
|
filesystem: {
|
|
53
70
|
denyRead: [...DEFAULT_DENY_READ],
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host loopback port bridge for Linux sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Bubblewrap on Linux isolates the network namespace via `--unshare-net`.
|
|
5
|
+
* In that isolated namespace, loopback `127.0.0.1` is disconnected from the host,
|
|
6
|
+
* preventing tools (like Diffusion Studio CLI `dapi` or local dev servers) from
|
|
7
|
+
* reaching services running on the host's localhost (e.g. port 3274).
|
|
8
|
+
*
|
|
9
|
+
* This module creates a lightweight Unix domain socket bridge on the host
|
|
10
|
+
* (using Node's built-in `node:net`), bind-mounts the socket into bwrap,
|
|
11
|
+
* and starts a `socat` listener inside the container forwarding container
|
|
12
|
+
* `127.0.0.1:${port}` to the host service.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export interface HostPortBridge {
|
|
22
|
+
port: number;
|
|
23
|
+
sockPath: string;
|
|
24
|
+
close: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start a Unix domain socket bridge on the host forwarding to host 127.0.0.1:port.
|
|
29
|
+
*/
|
|
30
|
+
export async function startHostPortBridge(port: number): Promise<HostPortBridge> {
|
|
31
|
+
const id = randomBytes(6).toString("hex");
|
|
32
|
+
const sockPath = join(tmpdir(), `pi-host-bridge-${port}-${id}.sock`);
|
|
33
|
+
|
|
34
|
+
const server = net.createServer((client) => {
|
|
35
|
+
const target = net.connect(port, "127.0.0.1");
|
|
36
|
+
client.pipe(target).pipe(client);
|
|
37
|
+
client.on("error", () => target.destroy());
|
|
38
|
+
target.on("error", () => client.destroy());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await new Promise<void>((resolve, reject) => {
|
|
42
|
+
server.listen(sockPath, () => resolve());
|
|
43
|
+
server.on("error", reject);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
port,
|
|
48
|
+
sockPath,
|
|
49
|
+
close: () => {
|
|
50
|
+
try {
|
|
51
|
+
server.close();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
void err;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
if (existsSync(sockPath)) unlinkSync(sockPath);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
void err;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Start bridges for multiple ports concurrently.
|
|
66
|
+
*/
|
|
67
|
+
export async function startHostPortBridges(ports: number[]): Promise<HostPortBridge[]> {
|
|
68
|
+
if (process.platform !== "linux" || !ports.length) return [];
|
|
69
|
+
return Promise.all(ports.map((p) => startHostPortBridge(p)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inject the bridges into the bwrap command invocation on Linux.
|
|
74
|
+
*/
|
|
75
|
+
export function injectBridgesIntoBwrap(wrappedCommand: string, bridges: HostPortBridge[]): string {
|
|
76
|
+
if (process.platform !== "linux" || !bridges.length) return wrappedCommand;
|
|
77
|
+
|
|
78
|
+
const dashIndex = wrappedCommand.indexOf(" -- ");
|
|
79
|
+
if (dashIndex === -1) return wrappedCommand;
|
|
80
|
+
|
|
81
|
+
// 1. Add --bind for each socket before " -- "
|
|
82
|
+
const binds = bridges.map((b) => `--bind ${b.sockPath} ${b.sockPath}`).join(" ");
|
|
83
|
+
const bwrapPart = `${wrappedCommand.slice(0, dashIndex)} ${binds}`;
|
|
84
|
+
let shellPart = wrappedCommand.slice(dashIndex + 4);
|
|
85
|
+
|
|
86
|
+
// 2. Start socat listeners inside the container
|
|
87
|
+
const socatStarters = bridges
|
|
88
|
+
.map((b) => `socat TCP-LISTEN:${b.port},fork,reuseaddr UNIX-CONNECT:${b.sockPath} >/dev/null 2>&1 &\n_pi_bridge_pids="$_pi_bridge_pids $!"`)
|
|
89
|
+
.join("\n");
|
|
90
|
+
|
|
91
|
+
if (shellPart.includes("socat TCP-LISTEN:3128")) {
|
|
92
|
+
shellPart = shellPart.replace("socat TCP-LISTEN:3128", `${socatStarters}\nsocat TCP-LISTEN:3128`);
|
|
93
|
+
// Include our bridge pids in the script EXIT trap cleanup
|
|
94
|
+
shellPart = shellPart.replace("kill %1 %2 2>/dev/null;", "kill %1 %2 $_pi_bridge_pids 2>/dev/null;");
|
|
95
|
+
return `${bwrapPart} -- ${shellPart}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return wrappedCommand;
|
|
99
|
+
}
|
|
@@ -36,11 +36,13 @@ const SUPPRESSED_PROJECT_FILES = new Set([
|
|
|
36
36
|
*/
|
|
37
37
|
export function suppressGhostDotfiles(): void {
|
|
38
38
|
try {
|
|
39
|
+
/* SAFETY: DANGEROUS_FILES is an array of strings in sandbox-runtime. */
|
|
39
40
|
const files = DANGEROUS_FILES as unknown as string[];
|
|
40
41
|
const filteredFiles = files.filter((f) => !SUPPRESSED_PROJECT_FILES.has(f));
|
|
41
42
|
files.length = 0;
|
|
42
43
|
files.push(...filteredFiles);
|
|
43
44
|
|
|
45
|
+
/* SAFETY: DANGEROUS_DIRECTORIES is an array of strings in sandbox-runtime. */
|
|
44
46
|
const dirs = DANGEROUS_DIRECTORIES as unknown as string[];
|
|
45
47
|
const filteredDirs = dirs.filter((d) => d === ".git");
|
|
46
48
|
dirs.length = 0;
|
|
@@ -64,8 +66,17 @@ type Layer1FilesystemKind = typeof OverrideKind.AllowWrite | typeof OverrideKind
|
|
|
64
66
|
* filesystem.outsideProject.read gates reads.
|
|
65
67
|
*/
|
|
66
68
|
function runtimeConfig(cwd: string, config: SandboxConfig): SandboxRuntimeConfig {
|
|
69
|
+
const allowLocalhost = config.allowLocalhost ?? config.network?.allowLocalhost ?? true;
|
|
70
|
+
const network = config.network
|
|
71
|
+
? {
|
|
72
|
+
...config.network,
|
|
73
|
+
allowAllUnixSockets: config.allowAllUnixSockets ?? config.network.allowAllUnixSockets,
|
|
74
|
+
allowLocalBinding: config.allowLocalBinding ?? config.network.allowLocalBinding ?? allowLocalhost,
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
67
78
|
return {
|
|
68
|
-
network:
|
|
79
|
+
network: network as NonNullable<SandboxRuntimeConfig["network"]>,
|
|
69
80
|
filesystem: config.filesystem
|
|
70
81
|
? sandboxFilesystem(config.filesystem, { cwd, home: homedir() })
|
|
71
82
|
: { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
|
|
@@ -87,7 +98,8 @@ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: Net
|
|
|
87
98
|
export async function resetSandbox(): Promise<void> {
|
|
88
99
|
try {
|
|
89
100
|
await SandboxManager.reset();
|
|
90
|
-
} catch {
|
|
101
|
+
} catch (err) {
|
|
102
|
+
void err;
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
|
package/src/ui/ask-flow.ts
CHANGED
|
@@ -214,7 +214,17 @@ async function askWithSelect(
|
|
|
214
214
|
timeoutMs: number,
|
|
215
215
|
footer: string | null,
|
|
216
216
|
): Promise<{ picked: string | undefined; expired: boolean }> {
|
|
217
|
-
|
|
217
|
+
// Guard against terminal overflow in non-custom/RPC mode for long scripts
|
|
218
|
+
let boundedTitle = title;
|
|
219
|
+
const lines = title.split("\n");
|
|
220
|
+
const MAX_FALLBACK_LINES = 16;
|
|
221
|
+
if (lines.length > MAX_FALLBACK_LINES) {
|
|
222
|
+
const header = lines[0];
|
|
223
|
+
const preview = lines.slice(1, MAX_FALLBACK_LINES).join("\n");
|
|
224
|
+
boundedTitle = `${header}\n${preview}\n\n[... ${lines.length - MAX_FALLBACK_LINES} more lines truncated]`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const selectTitle = footer ? `${boundedTitle}\n\n${footer}` : boundedTitle;
|
|
218
228
|
if (timeoutMs === 0) {
|
|
219
229
|
const picked = await ctx.ui.select(selectTitle, options);
|
|
220
230
|
return { picked, expired: false };
|
package/src/ui/ask-selector.ts
CHANGED
|
@@ -54,6 +54,8 @@ export type AskSelectorOptions = {
|
|
|
54
54
|
onExpire: () => void;
|
|
55
55
|
/** Countdown tick, default 1000 ms. Test seam. */
|
|
56
56
|
tickMs?: number;
|
|
57
|
+
/** Maximum body lines shown in viewport before scrolling engages. Default 12. */
|
|
58
|
+
maxBodyLines?: number;
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
/** A one-line horizontal rule, like pi's DynamicBorder but without the
|
|
@@ -70,6 +72,9 @@ class Border implements Component {
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
const SCROLL_UP_KEYS = new Set(["\x1b[5~", "[", "\x1b[1;2A"]);
|
|
76
|
+
const SCROLL_DOWN_KEYS = new Set(["\x1b[6~", "]", "\x1b[1;2B"]);
|
|
77
|
+
|
|
73
78
|
export class AskSelector extends Container {
|
|
74
79
|
private readonly theme: AskTheme;
|
|
75
80
|
private readonly tui: { requestRender(): void };
|
|
@@ -90,6 +95,14 @@ export class AskSelector extends Container {
|
|
|
90
95
|
private finished = false;
|
|
91
96
|
private engaged = false;
|
|
92
97
|
|
|
98
|
+
private readonly isMultiLine: boolean;
|
|
99
|
+
private readonly headerText: string;
|
|
100
|
+
private readonly bodyLines: string[];
|
|
101
|
+
private readonly maxBodyLines: number;
|
|
102
|
+
private readonly bodyComponent: Text | null = null;
|
|
103
|
+
private readonly indicatorComponent: Text | null = null;
|
|
104
|
+
private bodyScrollOffset = 0;
|
|
105
|
+
|
|
93
106
|
constructor(o: AskSelectorOptions) {
|
|
94
107
|
super();
|
|
95
108
|
this.theme = o.theme;
|
|
@@ -103,10 +116,35 @@ export class AskSelector extends Container {
|
|
|
103
116
|
this.labels = o.options;
|
|
104
117
|
this.footerVisible = o.footer !== null && o.footer !== "";
|
|
105
118
|
this.remaining = Math.ceil(o.timeoutMs / 1000);
|
|
119
|
+
this.maxBodyLines = o.maxBodyLines ?? 12;
|
|
120
|
+
|
|
121
|
+
const allLines = o.title.split("\n");
|
|
122
|
+
this.isMultiLine = allLines.length > 1;
|
|
123
|
+
|
|
124
|
+
if (this.isMultiLine) {
|
|
125
|
+
this.headerText = allLines[0] ?? "";
|
|
126
|
+
this.bodyLines = allLines.slice(1);
|
|
127
|
+
} else {
|
|
128
|
+
this.headerText = o.title;
|
|
129
|
+
this.bodyLines = [];
|
|
130
|
+
}
|
|
106
131
|
|
|
107
132
|
this.addChild(new Border((t) => this.theme.fg("border", t)));
|
|
108
133
|
this.addChild(new Spacer(1));
|
|
109
|
-
|
|
134
|
+
|
|
135
|
+
if (!this.isMultiLine) {
|
|
136
|
+
this.addChild(new Text(this.theme.fg("accent", this.theme.bold(o.title)), 0, 0));
|
|
137
|
+
} else if (this.bodyLines.length <= this.maxBodyLines) {
|
|
138
|
+
this.addChild(new Text(this.theme.fg("accent", this.theme.bold(o.title)), 0, 0));
|
|
139
|
+
} else {
|
|
140
|
+
this.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.headerText)), 0, 0));
|
|
141
|
+
this.addChild(new Spacer(1));
|
|
142
|
+
this.bodyComponent = new Text(this.renderBodySlice(), 0, 0);
|
|
143
|
+
this.addChild(this.bodyComponent);
|
|
144
|
+
this.indicatorComponent = new Text(this.renderScrollIndicator(), 0, 0);
|
|
145
|
+
this.addChild(this.indicatorComponent);
|
|
146
|
+
}
|
|
147
|
+
|
|
110
148
|
this.addChild(new Spacer(1));
|
|
111
149
|
if (this.footerVisible) {
|
|
112
150
|
this.footerText = new Text(this.footerValue(), 0, 0);
|
|
@@ -146,6 +184,17 @@ export class AskSelector extends Container {
|
|
|
146
184
|
}
|
|
147
185
|
|
|
148
186
|
handleInput(data: string): void {
|
|
187
|
+
if (this.bodyLines.length > this.maxBodyLines) {
|
|
188
|
+
const pageSize = Math.max(1, this.maxBodyLines - 2);
|
|
189
|
+
if (this.keybindings.matches(data, "tui.select.pageUp") || SCROLL_UP_KEYS.has(data)) {
|
|
190
|
+
this.scrollBody(-pageSize);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (this.keybindings.matches(data, "tui.select.pageDown") || SCROLL_DOWN_KEYS.has(data)) {
|
|
194
|
+
this.scrollBody(pageSize);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
149
198
|
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
150
199
|
this.move(-1);
|
|
151
200
|
return;
|
|
@@ -157,15 +206,39 @@ export class AskSelector extends Container {
|
|
|
157
206
|
this.list.handleInput(data);
|
|
158
207
|
}
|
|
159
208
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
209
|
+
private renderBodySlice(): string {
|
|
210
|
+
const slice = this.bodyLines.slice(this.bodyScrollOffset, this.bodyScrollOffset + this.maxBodyLines);
|
|
211
|
+
return this.theme.fg("accent", this.theme.bold(slice.join("\n")));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private renderScrollIndicator(): string {
|
|
215
|
+
const total = this.bodyLines.length;
|
|
216
|
+
const start = this.bodyScrollOffset + 1;
|
|
217
|
+
const end = Math.min(total, this.bodyScrollOffset + this.maxBodyLines);
|
|
218
|
+
return this.theme.fg("muted", `[${start}–${end} of ${total} lines | [/] or Shift+↑/↓ or Scroll Wheel]`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private scrollBody(delta: number): void {
|
|
222
|
+
if (this.bodyLines.length <= this.maxBodyLines) return;
|
|
223
|
+
const maxOffset = this.bodyLines.length - this.maxBodyLines;
|
|
224
|
+
const next = Math.max(0, Math.min(maxOffset, this.bodyScrollOffset + delta));
|
|
225
|
+
if (next === this.bodyScrollOffset) return;
|
|
226
|
+
this.bodyScrollOffset = next;
|
|
227
|
+
this.bodyComponent?.setText(this.renderBodySlice());
|
|
228
|
+
this.indicatorComponent?.setText(this.renderScrollIndicator());
|
|
229
|
+
this.onActivity();
|
|
230
|
+
this.tui.requestRender();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
handleMouse(event: { type?: string; wheelDelta?: number }): undefined {
|
|
234
|
+
if (event?.type === "wheel" && Number.isFinite(event.wheelDelta)) {
|
|
235
|
+
if (this.bodyLines.length > this.maxBodyLines) {
|
|
236
|
+
const delta = event.wheelDelta;
|
|
237
|
+
if (delta !== undefined) {
|
|
238
|
+
this.scrollBody(delta > 0 ? 2 : -2);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
169
242
|
return undefined;
|
|
170
243
|
}
|
|
171
244
|
|