auto-permission-system 3.9.1 → 3.15.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 +18 -3
- package/extensions/permission-mode.ts +28 -23
- package/package.json +1 -1
- package/sandbox.claude.json +1 -0
- package/sandbox.default.json +13 -2
- package/skills/auto-permission-system/SKILL.md +11 -1
- package/src/core/policy/patterns.ts +53 -0
- package/src/detect/sensitive-file-scanner/patterns.ts +27 -0
- package/src/l1-sandbox/bash-ops.ts +68 -27
- package/src/l1-sandbox/config.ts +19 -1
- package/src/l1-sandbox/host-bridge.ts +99 -0
- package/src/l1-sandbox/manager.ts +15 -2
- package/src/ui/ask-flow.ts +11 -1
- package/src/ui/ask-selector.ts +83 -10
- package/src/ui/ask.ts +55 -5
package/extensions/guard.ts
CHANGED
|
@@ -34,8 +34,8 @@ import { onYolo } from "../src/shared/yolo";
|
|
|
34
34
|
const detectionGrants = new Set<string>();
|
|
35
35
|
|
|
36
36
|
function detectionBlock(action: "read" | "tool", why: string): string {
|
|
37
|
-
if (action === "read") return `Read blocked by policy: ${why}. Nothing was read — ask the user.`;
|
|
38
|
-
return `Tool call blocked by policy: ${why}. Nothing was run — ask the user.`;
|
|
37
|
+
if (action === "read") return `Read blocked by policy: ${why}. Nothing was read — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
|
|
38
|
+
return `Tool call blocked by policy: ${why}. Nothing was run — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Maps each in-flight tool call to the program id we'll show in the
|
|
@@ -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) {
|
|
@@ -19,37 +19,32 @@ import { emitYolo, onYolo, YOLO_STATUS } from "../src/shared/yolo";
|
|
|
19
19
|
|
|
20
20
|
const GLOBAL_POLICY = `${getAgentDir()}/extensions/sandbox.json`;
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const MODE_DEFS: Array<{
|
|
23
|
+
value: PermissionMode;
|
|
24
|
+
icon: string;
|
|
25
|
+
name: string;
|
|
26
|
+
desc: string;
|
|
27
|
+
}> = [
|
|
23
28
|
{
|
|
24
|
-
label: "Advanced Secure (recommended) — detect secrets in reads, MCP calls, and output",
|
|
25
29
|
value: "advanced-secure",
|
|
30
|
+
icon: "🧠",
|
|
31
|
+
name: "Advanced Secure",
|
|
32
|
+
desc: "Blocks sensitive data leaks from file reads, MCP tools & command outputs",
|
|
26
33
|
},
|
|
27
34
|
{
|
|
28
|
-
label: "Default — rules only",
|
|
29
35
|
value: "default",
|
|
36
|
+
icon: "🔒",
|
|
37
|
+
name: "Default",
|
|
38
|
+
desc: "Restricts file paths & network access at the sandbox level",
|
|
30
39
|
},
|
|
31
40
|
{
|
|
32
|
-
label: "YOLO — disable ALL security layers",
|
|
33
41
|
value: "yolo",
|
|
42
|
+
icon: "⚠️",
|
|
43
|
+
name: "YOLO",
|
|
44
|
+
desc: "Turns off all file sandboxing and leak protections",
|
|
34
45
|
},
|
|
35
46
|
];
|
|
36
47
|
|
|
37
|
-
const EXPLANATION = [
|
|
38
|
-
"Advanced Secure (recommended)",
|
|
39
|
-
" Adds secret/credential detection on top of the rules:",
|
|
40
|
-
" • File reads — scans contents for keys, tokens, and passwords",
|
|
41
|
-
" • MCP calls — flags risky tool calls before they run",
|
|
42
|
-
" • Tool/command/file output — redacts secrets before they reach the model",
|
|
43
|
-
"",
|
|
44
|
-
"Default",
|
|
45
|
-
" Rule-based only: sandbox.json paths, domains, and commands.",
|
|
46
|
-
" No file contents are inspected.",
|
|
47
|
-
"",
|
|
48
|
-
"YOLO",
|
|
49
|
-
" Turns every layer off. Saved like the other modes, so new conversations",
|
|
50
|
-
" start with it too until you pick Default or Advanced Secure again.",
|
|
51
|
-
].join("\n");
|
|
52
|
-
|
|
53
48
|
/**
|
|
54
49
|
* The single sandbox footer chip: `Sandbox: <icon> N domains, M paths`.
|
|
55
50
|
* `🧠` marks advanced-secure, `🔒` default; YOLO replaces it with the warning.
|
|
@@ -79,13 +74,23 @@ function modeNotice(mode: PermissionMode): { text: string; level: "info" | "warn
|
|
|
79
74
|
|
|
80
75
|
async function pickMode(pi: ExtensionAPI, ctx: ExtensionContext, yolo: { active: boolean }): Promise<void> {
|
|
81
76
|
const current: PermissionMode = yolo.active ? "yolo" : normalizeMode(loadPolicy(ctx.cwd).mode);
|
|
77
|
+
|
|
78
|
+
const options = MODE_DEFS.map((m) => {
|
|
79
|
+
const isActive = m.value === current;
|
|
80
|
+
const nameWithStatus = isActive ? `${m.name} (active)` : m.name;
|
|
81
|
+
const labelCol = nameWithStatus.padEnd(26);
|
|
82
|
+
return `${m.icon} ${labelCol} — ${m.desc}`;
|
|
83
|
+
});
|
|
84
|
+
|
|
82
85
|
const picked = await ctx.ui.select(
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
"Select permission mode:",
|
|
87
|
+
options,
|
|
85
88
|
{ timeout: 120_000 },
|
|
86
89
|
);
|
|
87
90
|
if (picked === undefined) return;
|
|
88
|
-
|
|
91
|
+
|
|
92
|
+
const pickedIndex = options.indexOf(picked);
|
|
93
|
+
const next = pickedIndex >= 0 ? MODE_DEFS[pickedIndex].value : current;
|
|
89
94
|
if (next === current) {
|
|
90
95
|
if (next === "yolo" && !yolo.active) {
|
|
91
96
|
yolo.active = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auto-permission-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.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,8 @@
|
|
|
2
2
|
"enabled": true,
|
|
3
3
|
"mode": "advanced-secure",
|
|
4
4
|
"network": {
|
|
5
|
+
"allowLocalhost": true,
|
|
6
|
+
"allowAllUnixSockets": true,
|
|
5
7
|
"allowedDomains": [
|
|
6
8
|
"registry.npmjs.org",
|
|
7
9
|
"registry.yarnpkg.com",
|
|
@@ -24,13 +26,22 @@
|
|
|
24
26
|
"rubygems.org",
|
|
25
27
|
"api.nuget.org",
|
|
26
28
|
"deno.land",
|
|
27
|
-
"jsr.io"
|
|
29
|
+
"jsr.io",
|
|
30
|
+
"api.openai.com",
|
|
31
|
+
"huggingface.co",
|
|
32
|
+
"*.huggingface.co",
|
|
33
|
+
"cdn-lfs.huggingface.co",
|
|
34
|
+
"*.diffusion.studio",
|
|
35
|
+
"diffusion.studio"
|
|
28
36
|
]
|
|
29
37
|
},
|
|
30
38
|
"filesystem": {
|
|
31
39
|
"outsideProject": {
|
|
32
40
|
"read": "allow",
|
|
33
|
-
"allowRead": [
|
|
41
|
+
"allowRead": [
|
|
42
|
+
"~/Downloads",
|
|
43
|
+
"~/.cache"
|
|
44
|
+
]
|
|
34
45
|
},
|
|
35
46
|
"denyRead": [],
|
|
36
47
|
"askRead": [
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: auto-permission-system
|
|
3
|
-
description: Use this skill when the user asks about auto-permission-system, security for Pi,
|
|
3
|
+
description: Use this skill ONLY when the user explicitly asks about auto-permission-system, security for Pi, configuring security policies, or permission modes. Do NOT invoke this skill automatically when an action or tool is blocked during regular coding tasks.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Security for Pi (Pi Permission System) — Skill
|
|
7
7
|
|
|
8
8
|
Use this skill when the user asks about the auto-permission-system extension, security for Pi, permission system for Pi, how to secure Pi, or configuring Claude auto mode for Pi.
|
|
9
9
|
|
|
10
|
+
## Handling Blocked Operations (Agent Behavior Guideline)
|
|
11
|
+
|
|
12
|
+
When a command, file operation, or tool call is blocked by the security policy:
|
|
13
|
+
- **Do not inspect plugin internals**: Do NOT read files inside `auto-permission-system`, do NOT inspect sandbox runtime code, and do NOT attempt to find bypasses or workarounds.
|
|
14
|
+
- **Explain clearly**: Calmly and concisely inform the user what specific action was restricted and why.
|
|
15
|
+
- **Ask to change permission mode**: Ask the user if they wish to adjust the permission mode using `/permission-mode` to allow the action.
|
|
16
|
+
- **Wait for user input**: Do not proceed with speculative workarounds or retry loops until the user responds or updates the mode.
|
|
17
|
+
|
|
18
|
+
|
|
10
19
|
## What this extension does
|
|
11
20
|
|
|
12
21
|
**Layer 1 — Bash sandbox (OS-level)**
|
|
@@ -39,6 +48,7 @@ A higher layer only states what it changes; absent keys keep the layer below. A
|
|
|
39
48
|
"enabled": true,
|
|
40
49
|
"mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
|
|
41
50
|
"network": {
|
|
51
|
+
"allowLocalhost": true, // true (default): localhost on all ports allowed; false: strict network isolation
|
|
42
52
|
"allowedDomains": ["github.com", "*.github.com"], // empty = allow all
|
|
43
53
|
"deniedDomains": [], // hard deny, never ask-able
|
|
44
54
|
"strictAllowlist": false // true = deny unknown hosts instead of asking
|
|
@@ -31,6 +31,59 @@ 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
|
+
const isDiffusion = /diffusion studio is not running/i.test(output) || (command && /\b(?:dapi|diffusion)\b/i.test(command));
|
|
61
|
+
if (isDiffusion) {
|
|
62
|
+
return { host: "127.0.0.1", port: "3274", service: "Diffusion Studio" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const curlMatch = output.match(/Failed to connect to (127\.0\.0\.1|localhost|::1) port (\d+)/i);
|
|
66
|
+
if (curlMatch) {
|
|
67
|
+
return {
|
|
68
|
+
host: curlMatch[1],
|
|
69
|
+
port: curlMatch[2],
|
|
70
|
+
service: curlMatch[2] === "3274" ? "Diffusion Studio" : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const nodeMatch = output.match(/(?:ECONNREFUSED|connect to)\s+(127\.0\.0\.1|localhost|::1)(?::| port )(\d+)/i);
|
|
75
|
+
if (nodeMatch) {
|
|
76
|
+
return {
|
|
77
|
+
host: nodeMatch[1],
|
|
78
|
+
port: nodeMatch[2],
|
|
79
|
+
service: nodeMatch[2] === "3274" ? "Diffusion Studio" : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const hostMatch = output.match(/(127\.0\.0\.1|localhost|::1)/i);
|
|
84
|
+
return { host: hostMatch ? hostMatch[1] : "127.0.0.1" };
|
|
85
|
+
}
|
|
86
|
+
|
|
34
87
|
/** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
|
|
35
88
|
function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
|
|
36
89
|
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 {
|
|
@@ -29,7 +30,7 @@ export interface SandboxedBashOpts {
|
|
|
29
30
|
|
|
30
31
|
/** One denial shape for Layer 1, mirroring denyMessage() in Layer 2. */
|
|
31
32
|
function blockedLine(action: "Read" | "Write", why: string, outcome: "read" | "written" | "run"): string {
|
|
32
|
-
return
|
|
33
|
+
return `🛡️ pi-sandbox: ${action} blocked by policy: ${why}. Nothing was ${outcome}. Assistant guideline: Inform the user and ask if they wish to adjust the permission mode (/permission-mode); do not attempt workarounds or inspect plugin internals.`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** `Link` body row for a symlinked block path; empty when the spellings agree (ADR-035). */
|
|
@@ -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
|
|
|
@@ -300,32 +327,46 @@ export function createSandboxedBashOps(opts?: SandboxedBashOpts): BashOperations
|
|
|
300
327
|
}
|
|
301
328
|
readDenied = hardDenied || outsideDenied;
|
|
302
329
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
blockedHint = `\n💡 pi-sandbox: filesystem access blocked.\n`;
|
|
330
|
+
blockedHint = `\n🛡️ Security Policy Notice: filesystem access blocked by sandbox policy.\n`;
|
|
306
331
|
if (offending) {
|
|
307
332
|
let why = "";
|
|
308
|
-
if (writeDenied && !hardDenied) why = " (
|
|
333
|
+
if (writeDenied && !hardDenied) why = " (writes outside allowWrite are read-only)";
|
|
309
334
|
else if (hardDenied) why = ` (${hardWhy})`;
|
|
310
|
-
else if (outsideDenied) why = " (outside the project
|
|
311
|
-
blockedHint += `
|
|
312
|
-
if (spelledOffending && spelledOffending !== offending) blockedHint += ` Link:
|
|
313
|
-
}
|
|
314
|
-
blockedHint += ` This is the pi sandbox (Layer 1), NOT macOS Full Disk Access / TCC.\n`;
|
|
315
|
-
if (configDirHint) {
|
|
316
|
-
blockedHint +=
|
|
317
|
-
` Looks like a tool's own config dir. To allow this tool in the\n` +
|
|
318
|
-
` current project, add a project-local policy:\n` +
|
|
319
|
-
` mkdir -p ${cwd}/.pi && cat > ${cwd}/.pi/sandbox.json <<'JSON'\n` +
|
|
320
|
-
` { "filesystem": { "allowWrite": [".", "${offending?.replace(/^~/, "$HOME") ?? "~/.config/<tool>"}"] } }\n` +
|
|
321
|
-
` JSON\n` +
|
|
322
|
-
` Or run pi with --yolo for one-off elevated access (disables ALL layers).\n`;
|
|
323
|
-
} else {
|
|
324
|
-
blockedHint +=
|
|
325
|
-
` Use $TMPDIR (= ${piTmp}/) for scratch files,\n` +
|
|
326
|
-
` or write inside the project directory (${cwd}).\n`;
|
|
335
|
+
else if (outsideDenied) why = " (outside the project directory)";
|
|
336
|
+
blockedHint += ` Target: ${offending}${why}\n`;
|
|
337
|
+
if (spelledOffending && spelledOffending !== offending) blockedHint += ` Link: ${spelledOffending}\n`;
|
|
327
338
|
}
|
|
328
|
-
blockedHint +=
|
|
339
|
+
blockedHint +=
|
|
340
|
+
` Explanation: The current sandbox policy restricts this operation to protect your system.\n` +
|
|
341
|
+
` Guideline for Assistant:\n` +
|
|
342
|
+
` • Stop and inform the user clearly about what was blocked.\n` +
|
|
343
|
+
` • Ask the user if they wish to adjust the permission mode (/permission-mode) to allow this action.\n` +
|
|
344
|
+
` • Do not inspect security plugin internals or attempt unauthorized bypasses.\n`;
|
|
345
|
+
} else if (code !== 0 && isLoopbackConnectionRefused(outputTail)) {
|
|
346
|
+
const endpointInfo = extractLoopbackEndpoint(outputTail, command);
|
|
347
|
+
const endpointStr = endpointInfo?.port ? `${endpointInfo.host}:${endpointInfo.port}` : (endpointInfo?.host ?? "127.0.0.1");
|
|
348
|
+
const serviceName = endpointInfo?.service;
|
|
349
|
+
|
|
350
|
+
auditL1({
|
|
351
|
+
subject: endpointStr,
|
|
352
|
+
decision: "loopback-refused",
|
|
353
|
+
reason: "Network namespace isolates host loopback under current policy",
|
|
354
|
+
cwd: opts?.ctx?.cwd,
|
|
355
|
+
note: serviceName ? `service:${serviceName}` : undefined,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const notifyMsg = serviceName
|
|
359
|
+
? `pi-sandbox: connection to ${serviceName} on ${endpointStr} was restricted by the sandbox.`
|
|
360
|
+
: `pi-sandbox: connection to host ${endpointStr} was restricted by the sandbox.`;
|
|
361
|
+
opts?.ctx?.ui?.notify?.(notifyMsg, "warning");
|
|
362
|
+
|
|
363
|
+
blockedHint = `\n🛡️ Security Policy Notice: Local network connection restricted.\n` +
|
|
364
|
+
` Target: ${endpointStr}${serviceName ? ` (${serviceName})` : ""}\n` +
|
|
365
|
+
` Explanation: The sandbox policy restricted access to this local host address.\n` +
|
|
366
|
+
` Guideline for Assistant:\n` +
|
|
367
|
+
` • Stop and inform the user clearly about what connection was restricted.\n` +
|
|
368
|
+
` • Ask the user if they wish to adjust the permission mode (/permission-mode) to allow this action.\n` +
|
|
369
|
+
` • Do not inspect security plugin internals or attempt unauthorized bypasses.\n`;
|
|
329
370
|
}
|
|
330
371
|
/**
|
|
331
372
|
* 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,8 @@ const BUILTIN_CONFIG: SandboxConfig = {
|
|
|
48
64
|
network: {
|
|
49
65
|
allowedDomains: [...BUILTIN_NETWORK_ALLOWED],
|
|
50
66
|
deniedDomains: [],
|
|
67
|
+
allowLocalhost: true,
|
|
68
|
+
allowAllUnixSockets: true,
|
|
51
69
|
},
|
|
52
70
|
filesystem: {
|
|
53
71
|
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,18 @@ 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 allowAllUnixSockets = config.allowAllUnixSockets ?? config.network?.allowAllUnixSockets ?? true;
|
|
71
|
+
const network = config.network
|
|
72
|
+
? {
|
|
73
|
+
...config.network,
|
|
74
|
+
allowAllUnixSockets,
|
|
75
|
+
allowLocalBinding: config.allowLocalBinding ?? config.network.allowLocalBinding ?? allowLocalhost,
|
|
76
|
+
}
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
67
79
|
return {
|
|
68
|
-
network:
|
|
80
|
+
network: network as NonNullable<SandboxRuntimeConfig["network"]>,
|
|
69
81
|
filesystem: config.filesystem
|
|
70
82
|
? sandboxFilesystem(config.filesystem, { cwd, home: homedir() })
|
|
71
83
|
: { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
|
|
@@ -87,7 +99,8 @@ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: Net
|
|
|
87
99
|
export async function resetSandbox(): Promise<void> {
|
|
88
100
|
try {
|
|
89
101
|
await SandboxManager.reset();
|
|
90
|
-
} catch {
|
|
102
|
+
} catch (err) {
|
|
103
|
+
void err;
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
|
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
|
|
package/src/ui/ask.ts
CHANGED
|
@@ -67,9 +67,59 @@ function actionOf(ask: AskKind): AskAction {
|
|
|
67
67
|
return AskAction.Read;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Format the reason into plain, user-friendly language for non-technical users. */
|
|
71
71
|
export function displayWhy(reason: string): string {
|
|
72
|
-
|
|
72
|
+
const raw = reason.replace(/\s*→\s*.*$/, "").trim();
|
|
73
|
+
|
|
74
|
+
if (raw.startsWith("absolute-deny matched")) {
|
|
75
|
+
const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
|
|
76
|
+
if (pat.includes("ssh")) return `contains private SSH keys (${pat})`;
|
|
77
|
+
if (pat.includes("aws")) return `contains AWS credentials (${pat})`;
|
|
78
|
+
if (pat.includes("gnupg")) return `contains private encryption keys (${pat})`;
|
|
79
|
+
if (pat.includes("pem") || pat.includes("key")) return `contains private security keys (${pat})`;
|
|
80
|
+
if (pat.includes("auth.json")) return "holds login credentials";
|
|
81
|
+
return `contains sensitive credentials (${pat || "credentials"})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (raw.startsWith("askRead matched")) {
|
|
85
|
+
const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
|
|
86
|
+
if (pat.includes(".env")) return ".env files usually hold passwords and API keys";
|
|
87
|
+
return `this file usually holds private passwords or API keys (${pat})`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (raw.startsWith("denyRead matched")) {
|
|
91
|
+
const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
|
|
92
|
+
if (pat.includes(".env")) return ".env files usually hold passwords and API keys";
|
|
93
|
+
return `protected by security rules from being read (${pat})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (raw.startsWith("modelDenyRead matched")) {
|
|
97
|
+
const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
|
|
98
|
+
return `contains private data hidden from the AI (${pat})`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (raw.startsWith("denyWrite matched")) {
|
|
102
|
+
const pat = raw.match(/"([^"]+)"/)?.[1] ?? "";
|
|
103
|
+
return `protected file that cannot be modified (${pat})`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (raw.includes("not under any allowWrite root") || raw.includes("not under an allowWrite root")) {
|
|
107
|
+
return "saving files outside your project workspace requires permission";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (raw.startsWith("outside the project")) {
|
|
111
|
+
return "reading files outside your project folder requires permission";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (raw.startsWith("domain not in allowlist")) {
|
|
115
|
+
return "this site isn't on your allowed list yet";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (raw === "blocked by the sandbox filesystem policy") {
|
|
119
|
+
return "restricted by your project's security policy";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return raw;
|
|
73
123
|
}
|
|
74
124
|
|
|
75
125
|
/** A domain reason without its `: host` suffix, so it can sit next to `Site:` without repeating it. */
|
|
@@ -79,9 +129,9 @@ export function networkWhy(reason: string): string {
|
|
|
79
129
|
|
|
80
130
|
/** The model-facing denial text: one pattern for every block. */
|
|
81
131
|
export function denyMessage(overrideKind: OverrideKind, reason: string): string {
|
|
82
|
-
if (overrideKind === "allowDomains") return `Network blocked by policy: ${networkWhy(reason)}. Nothing was fetched \u2014 ask the user.`;
|
|
83
|
-
if (overrideKind === "allowWrite") return `Write blocked by policy: ${displayWhy(reason)}. Nothing was written \u2014 ask the user.`;
|
|
84
|
-
return `Read blocked by policy: ${displayWhy(reason)}. Nothing was read \u2014 ask the user.`;
|
|
132
|
+
if (overrideKind === "allowDomains") return `Network blocked by policy: ${networkWhy(reason)}. Nothing was fetched \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
|
|
133
|
+
if (overrideKind === "allowWrite") return `Write blocked by policy: ${displayWhy(reason)}. Nothing was written \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
|
|
134
|
+
return `Read blocked by policy: ${displayWhy(reason)}. Nothing was read \u2014 ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
|
|
85
135
|
}
|
|
86
136
|
|
|
87
137
|
/**
|