pi-landstrip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/index.ts +1127 -0
- package/package.json +54 -0
- package/sandbox.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarkko Sakkinen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# pi-landstrip
|
|
2
|
+
|
|
3
|
+
Landlock-based sandboxing for [pi](https://pi.dev/) using
|
|
4
|
+
[`landstrip`](https://github.com/jarkkojs/landstrip).
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
|
|
8
|
+
Install `landstrip` and make sure it is on the `PATH` used to launch pi:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cargo install landstrip
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`landstrip` currently targets Linux. On other platforms this extension loads
|
|
15
|
+
but leaves sandboxing disabled.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:pi-landstrip
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Configure
|
|
24
|
+
|
|
25
|
+
Create `.pi/sandbox.json` in a project or `~/.pi/agent/sandbox.json` globally.
|
|
26
|
+
Project config takes precedence.
|
|
27
|
+
|
|
28
|
+
See [`sandbox.json`](./sandbox.json) for a starter config.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pi --no-sandbox
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use `/sandbox` inside Pi to show the active config.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
`pi-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
|
|
41
|
+
information.
|
package/index.ts
ADDED
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AgentToolResult,
|
|
6
|
+
AgentToolUpdateCallback,
|
|
7
|
+
BashToolDetails,
|
|
8
|
+
BashToolInput,
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionContext,
|
|
11
|
+
} from '@earendil-works/pi-coding-agent';
|
|
12
|
+
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
realpathSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { type AddressInfo, connect as connectNet, createServer, type Socket } from 'node:net';
|
|
24
|
+
import { homedir, tmpdir } from 'node:os';
|
|
25
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
26
|
+
import { URL } from 'node:url';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
type BashOperations,
|
|
30
|
+
createBashToolDefinition,
|
|
31
|
+
getAgentDir,
|
|
32
|
+
getShellConfig,
|
|
33
|
+
isToolCallEventType,
|
|
34
|
+
SettingsManager,
|
|
35
|
+
} from '@earendil-works/pi-coding-agent';
|
|
36
|
+
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
|
|
37
|
+
|
|
38
|
+
interface SandboxFilesystemConfig {
|
|
39
|
+
denyRead: string[];
|
|
40
|
+
allowRead: string[];
|
|
41
|
+
allowWrite: string[];
|
|
42
|
+
denyWrite: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SandboxNetworkConfig {
|
|
46
|
+
allowLocalBinding: boolean;
|
|
47
|
+
allowAllUnixSockets: boolean;
|
|
48
|
+
allowUnixSockets: string[];
|
|
49
|
+
allowedDomains: string[];
|
|
50
|
+
deniedDomains: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface LandstripConfig {
|
|
54
|
+
command: string;
|
|
55
|
+
debug: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SandboxConfig {
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
network: SandboxNetworkConfig;
|
|
61
|
+
filesystem: SandboxFilesystemConfig;
|
|
62
|
+
landstrip: LandstripConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface LandstripPolicy {
|
|
66
|
+
network: {
|
|
67
|
+
allowLocalBinding: boolean;
|
|
68
|
+
allowAllUnixSockets: boolean;
|
|
69
|
+
allowUnixSockets: string[];
|
|
70
|
+
httpProxyPort: number;
|
|
71
|
+
};
|
|
72
|
+
filesystem: SandboxFilesystemConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_CONFIG: SandboxConfig = {
|
|
76
|
+
enabled: true,
|
|
77
|
+
network: {
|
|
78
|
+
allowLocalBinding: false,
|
|
79
|
+
allowAllUnixSockets: false,
|
|
80
|
+
allowUnixSockets: [],
|
|
81
|
+
allowedDomains: [
|
|
82
|
+
'npmjs.org',
|
|
83
|
+
'*.npmjs.org',
|
|
84
|
+
'registry.npmjs.org',
|
|
85
|
+
'registry.yarnpkg.com',
|
|
86
|
+
'pypi.org',
|
|
87
|
+
'*.pypi.org',
|
|
88
|
+
'github.com',
|
|
89
|
+
'*.github.com',
|
|
90
|
+
'api.github.com',
|
|
91
|
+
'raw.githubusercontent.com',
|
|
92
|
+
'crates.io',
|
|
93
|
+
'*.crates.io',
|
|
94
|
+
'static.crates.io',
|
|
95
|
+
],
|
|
96
|
+
deniedDomains: [],
|
|
97
|
+
},
|
|
98
|
+
filesystem: {
|
|
99
|
+
denyRead: ['/Users', '/home'],
|
|
100
|
+
allowRead: ['.', '~/.config', '~/.local', '~/.cargo'],
|
|
101
|
+
allowWrite: ['.', '/tmp'],
|
|
102
|
+
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
103
|
+
},
|
|
104
|
+
landstrip: {
|
|
105
|
+
command: 'landstrip',
|
|
106
|
+
debug: false,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type PermissionChoice = 'abort' | 'session' | 'project' | 'global';
|
|
111
|
+
|
|
112
|
+
interface PromptOption {
|
|
113
|
+
label: string;
|
|
114
|
+
key: string;
|
|
115
|
+
action: PermissionChoice;
|
|
116
|
+
confirm?: boolean;
|
|
117
|
+
hint?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const PERMISSION_OPTIONS: PromptOption[] = [
|
|
121
|
+
{ label: 'Allow for this session only', key: 's', action: 'session' },
|
|
122
|
+
{ label: 'Abort (keep blocked)', key: 'esc', action: 'abort' },
|
|
123
|
+
{
|
|
124
|
+
label: 'Allow for this project',
|
|
125
|
+
key: 'P',
|
|
126
|
+
action: 'project',
|
|
127
|
+
confirm: true,
|
|
128
|
+
hint: '-> .pi/sandbox.json',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
label: 'Allow for all projects',
|
|
132
|
+
key: 'A',
|
|
133
|
+
action: 'global',
|
|
134
|
+
confirm: true,
|
|
135
|
+
hint: '-> ~/.pi/agent/sandbox.json',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
function loadConfig(cwd: string): SandboxConfig {
|
|
140
|
+
const projectConfigPath = join(cwd, '.pi', 'sandbox.json');
|
|
141
|
+
const globalConfigPath = join(getAgentDir(), 'sandbox.json');
|
|
142
|
+
|
|
143
|
+
let globalConfig: Partial<SandboxConfig> = {};
|
|
144
|
+
let projectConfig: Partial<SandboxConfig> = {};
|
|
145
|
+
|
|
146
|
+
if (existsSync(globalConfigPath)) {
|
|
147
|
+
try {
|
|
148
|
+
globalConfig = JSON.parse(readFileSync(globalConfigPath, 'utf-8'));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`Warning: Could not parse ${globalConfigPath}: ${error}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (existsSync(projectConfigPath)) {
|
|
155
|
+
try {
|
|
156
|
+
projectConfig = JSON.parse(readFileSync(projectConfigPath, 'utf-8'));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error(`Warning: Could not parse ${projectConfigPath}: ${error}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
|
166
|
+
return {
|
|
167
|
+
enabled: overrides.enabled ?? base.enabled,
|
|
168
|
+
network: {
|
|
169
|
+
...base.network,
|
|
170
|
+
...overrides.network,
|
|
171
|
+
},
|
|
172
|
+
filesystem: {
|
|
173
|
+
...base.filesystem,
|
|
174
|
+
...overrides.filesystem,
|
|
175
|
+
},
|
|
176
|
+
landstrip: {
|
|
177
|
+
...base.landstrip,
|
|
178
|
+
...overrides.landstrip,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getConfigPaths(cwd: string): { globalPath: string; projectPath: string } {
|
|
184
|
+
return {
|
|
185
|
+
globalPath: join(homedir(), '.pi', 'agent', 'sandbox.json'),
|
|
186
|
+
projectPath: join(cwd, '.pi', 'sandbox.json'),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readOrEmptyConfig(configPath: string): Partial<SandboxConfig> {
|
|
191
|
+
if (!existsSync(configPath)) return {};
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
194
|
+
} catch {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writeConfigFile(configPath: string, config: Partial<SandboxConfig>): void {
|
|
200
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
201
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function addDomainToConfig(configPath: string, domain: string): void {
|
|
205
|
+
const config = readOrEmptyConfig(configPath);
|
|
206
|
+
const existing = config.network?.allowedDomains ?? [];
|
|
207
|
+
if (existing.includes(domain)) return;
|
|
208
|
+
|
|
209
|
+
config.network = {
|
|
210
|
+
...config.network,
|
|
211
|
+
allowedDomains: [...existing, domain],
|
|
212
|
+
deniedDomains: config.network?.deniedDomains ?? [],
|
|
213
|
+
} as SandboxNetworkConfig;
|
|
214
|
+
writeConfigFile(configPath, config);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function addReadPathToConfig(configPath: string, pathToAdd: string): void {
|
|
218
|
+
const config = readOrEmptyConfig(configPath);
|
|
219
|
+
const existing = config.filesystem?.allowRead ?? [];
|
|
220
|
+
if (existing.includes(pathToAdd)) return;
|
|
221
|
+
|
|
222
|
+
config.filesystem = {
|
|
223
|
+
...config.filesystem,
|
|
224
|
+
allowRead: [...existing, pathToAdd],
|
|
225
|
+
denyRead: config.filesystem?.denyRead ?? [],
|
|
226
|
+
allowWrite: config.filesystem?.allowWrite ?? [],
|
|
227
|
+
denyWrite: config.filesystem?.denyWrite ?? [],
|
|
228
|
+
} as SandboxFilesystemConfig;
|
|
229
|
+
writeConfigFile(configPath, config);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function addWritePathToConfig(configPath: string, pathToAdd: string): void {
|
|
233
|
+
const config = readOrEmptyConfig(configPath);
|
|
234
|
+
const existing = config.filesystem?.allowWrite ?? [];
|
|
235
|
+
if (existing.includes(pathToAdd)) return;
|
|
236
|
+
|
|
237
|
+
config.filesystem = {
|
|
238
|
+
...config.filesystem,
|
|
239
|
+
allowWrite: [...existing, pathToAdd],
|
|
240
|
+
denyRead: config.filesystem?.denyRead ?? [],
|
|
241
|
+
allowRead: config.filesystem?.allowRead ?? [],
|
|
242
|
+
denyWrite: config.filesystem?.denyWrite ?? [],
|
|
243
|
+
} as SandboxFilesystemConfig;
|
|
244
|
+
writeConfigFile(configPath, config);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractDomainsFromCommand(command: string): string[] {
|
|
248
|
+
const urlRegex = /https?:\/\/([^\s/:?#]+)(?::\d+)?(?:[/?#]|\s|$)/g;
|
|
249
|
+
const domains = new Set<string>();
|
|
250
|
+
let match: RegExpExecArray | null;
|
|
251
|
+
|
|
252
|
+
while ((match = urlRegex.exec(command)) !== null) {
|
|
253
|
+
domains.add(match[1]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return [...domains];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
260
|
+
const normalizedDomain = domain.toLowerCase();
|
|
261
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
262
|
+
|
|
263
|
+
if (normalizedPattern === '*') return true;
|
|
264
|
+
if (normalizedPattern.startsWith('*.')) {
|
|
265
|
+
const base = normalizedPattern.slice(2);
|
|
266
|
+
return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return normalizedDomain === normalizedPattern;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function domainMatchesAny(domain: string, patterns: string[]): boolean {
|
|
273
|
+
return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function allowsAllDomains(allowedDomains: string[]): boolean {
|
|
277
|
+
return allowedDomains.includes('*');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function shouldPromptForWrite(
|
|
281
|
+
path: string,
|
|
282
|
+
allowWrite: string[],
|
|
283
|
+
patternMatcher: (path: string, patterns: string[]) => boolean,
|
|
284
|
+
): boolean {
|
|
285
|
+
return allowWrite.length === 0 || !patternMatcher(path, allowWrite);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function expandPath(filePath: string): string {
|
|
289
|
+
return resolve(filePath.replace(/^~(?=$|\/)/, homedir()));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function canonicalizePath(filePath: string): string {
|
|
293
|
+
const abs = expandPath(filePath);
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
return realpathSync.native(abs);
|
|
297
|
+
} catch {
|
|
298
|
+
const tail: string[] = [];
|
|
299
|
+
let probe = abs;
|
|
300
|
+
|
|
301
|
+
while (!existsSync(probe)) {
|
|
302
|
+
const parent = dirname(probe);
|
|
303
|
+
if (parent === probe) return abs;
|
|
304
|
+
tail.unshift(basename(probe));
|
|
305
|
+
probe = parent;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
return resolve(realpathSync.native(probe), ...tail);
|
|
310
|
+
} catch {
|
|
311
|
+
return abs;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function matchesPattern(filePath: string, patterns: string[]): boolean {
|
|
317
|
+
const abs = canonicalizePath(filePath);
|
|
318
|
+
|
|
319
|
+
return patterns.some((pattern) => {
|
|
320
|
+
const absPattern = pattern.includes('*') ? expandPath(pattern) : canonicalizePath(pattern);
|
|
321
|
+
|
|
322
|
+
if (pattern.includes('*')) {
|
|
323
|
+
const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
324
|
+
return new RegExp(`^${escaped}$`).test(abs);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const sep = absPattern.endsWith('/') ? '' : '/';
|
|
328
|
+
return abs === absPattern || abs.startsWith(absPattern + sep);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeBlockedPath(path: string, cwd: string): string {
|
|
333
|
+
return canonicalizePath(isAbsolute(path) ? path : join(cwd, path));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function extractBlockedWritePath(output: string, cwd: string): string | null {
|
|
337
|
+
const match = output.match(
|
|
338
|
+
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
return match ? normalizeBlockedPath(match[1], cwd) : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function showPermissionPrompt(
|
|
345
|
+
ctx: ExtensionContext,
|
|
346
|
+
title: string,
|
|
347
|
+
options: PromptOption[],
|
|
348
|
+
): Promise<PermissionChoice> {
|
|
349
|
+
if (!ctx.hasUI) return 'abort';
|
|
350
|
+
|
|
351
|
+
const result = await ctx.ui.custom<PermissionChoice>((tui, theme, _kb, done) => {
|
|
352
|
+
let selectedIndex = 0;
|
|
353
|
+
let pendingAction: PermissionChoice | null = null;
|
|
354
|
+
|
|
355
|
+
function resolveChoice(action: PermissionChoice): void {
|
|
356
|
+
done(action);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
render(width: number): string[] {
|
|
361
|
+
const lines: string[] = [];
|
|
362
|
+
lines.push(truncateToWidth(theme.fg('warning', title), width));
|
|
363
|
+
lines.push('');
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i < options.length; i++) {
|
|
366
|
+
const option = options[i];
|
|
367
|
+
const isSelected = i === selectedIndex;
|
|
368
|
+
const isPending = pendingAction === option.action;
|
|
369
|
+
const prefix = isSelected ? ' -> ' : ' ';
|
|
370
|
+
const keyHint = theme.fg('accent', `[${option.key}]`);
|
|
371
|
+
let label = option.label;
|
|
372
|
+
|
|
373
|
+
if (option.hint) label += ` ${theme.fg('dim', option.hint)}`;
|
|
374
|
+
if (isPending) label += ` ${theme.fg('warning', '-> press Enter to confirm')}`;
|
|
375
|
+
|
|
376
|
+
lines.push(truncateToWidth(`${prefix}${keyHint} ${label}`, width));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
lines.push('');
|
|
380
|
+
lines.push(
|
|
381
|
+
truncateToWidth(
|
|
382
|
+
theme.fg(
|
|
383
|
+
'dim',
|
|
384
|
+
pendingAction
|
|
385
|
+
? 'up/down navigate enter confirm esc cancel'
|
|
386
|
+
: 'up/down navigate enter select esc cancel',
|
|
387
|
+
),
|
|
388
|
+
width,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
return lines;
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
handleInput(data: string): void {
|
|
396
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
|
|
397
|
+
resolveChoice('abort');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (matchesKey(data, Key.enter)) {
|
|
402
|
+
resolveChoice(pendingAction ?? options[selectedIndex]?.action ?? 'abort');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (matchesKey(data, Key.up)) {
|
|
407
|
+
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
408
|
+
pendingAction = null;
|
|
409
|
+
tui.requestRender();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (matchesKey(data, Key.down)) {
|
|
414
|
+
selectedIndex = Math.min(options.length - 1, selectedIndex + 1);
|
|
415
|
+
pendingAction = null;
|
|
416
|
+
tui.requestRender();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < options.length; i++) {
|
|
421
|
+
const option = options[i];
|
|
422
|
+
|
|
423
|
+
if (data === option.key) {
|
|
424
|
+
resolveChoice(option.action);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (data.toLowerCase() === option.key.toLowerCase()) {
|
|
429
|
+
if (option.confirm) {
|
|
430
|
+
pendingAction = option.action;
|
|
431
|
+
selectedIndex = i;
|
|
432
|
+
} else {
|
|
433
|
+
resolveChoice(option.action);
|
|
434
|
+
}
|
|
435
|
+
tui.requestRender();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
invalidate(): void {},
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return result ?? 'abort';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function promptDomainBlock(ctx: ExtensionContext, domain: string): Promise<PermissionChoice> {
|
|
449
|
+
return showPermissionPrompt(
|
|
450
|
+
ctx,
|
|
451
|
+
`Network blocked: "${domain}" is not in allowedDomains`,
|
|
452
|
+
PERMISSION_OPTIONS,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function promptReadBlock(ctx: ExtensionContext, filePath: string): Promise<PermissionChoice> {
|
|
457
|
+
return showPermissionPrompt(
|
|
458
|
+
ctx,
|
|
459
|
+
`Read blocked: "${filePath}" is not in allowRead`,
|
|
460
|
+
PERMISSION_OPTIONS,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function promptWriteBlock(ctx: ExtensionContext, filePath: string): Promise<PermissionChoice> {
|
|
465
|
+
return showPermissionPrompt(
|
|
466
|
+
ctx,
|
|
467
|
+
`Write blocked: "${filePath}" is not in allowWrite`,
|
|
468
|
+
PERMISSION_OPTIONS,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function landstripVersion(command: string): string | null {
|
|
473
|
+
const result = spawnSync(command, ['--version'], { encoding: 'utf-8' });
|
|
474
|
+
if (result.status !== 0) return null;
|
|
475
|
+
return result.stdout.trim();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function proxyEnv(env: NodeJS.ProcessEnv | undefined, port: number): NodeJS.ProcessEnv {
|
|
479
|
+
const url = `http://127.0.0.1:${port}`;
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
...process.env,
|
|
483
|
+
...env,
|
|
484
|
+
HTTP_PROXY: url,
|
|
485
|
+
HTTPS_PROXY: url,
|
|
486
|
+
ALL_PROXY: url,
|
|
487
|
+
http_proxy: url,
|
|
488
|
+
https_proxy: url,
|
|
489
|
+
all_proxy: url,
|
|
490
|
+
NO_PROXY: '',
|
|
491
|
+
no_proxy: '',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
|
|
496
|
+
const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
497
|
+
if (bracketMatch) {
|
|
498
|
+
return {
|
|
499
|
+
host: bracketMatch[1],
|
|
500
|
+
port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const lastColon = target.lastIndexOf(':');
|
|
505
|
+
if (lastColon > -1 && target.indexOf(':') === lastColon) {
|
|
506
|
+
return {
|
|
507
|
+
host: target.slice(0, lastColon),
|
|
508
|
+
port: Number(target.slice(lastColon + 1)),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { host: target, port: defaultPort };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function denyProxyRequest(client: Socket, status = '403 Forbidden'): void {
|
|
516
|
+
client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
|
|
517
|
+
client.end();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): void {
|
|
521
|
+
upstream.on('error', () => client.destroy());
|
|
522
|
+
client.on('error', () => upstream.destroy());
|
|
523
|
+
|
|
524
|
+
if (initialData?.length) upstream.write(initialData);
|
|
525
|
+
|
|
526
|
+
client.pipe(upstream);
|
|
527
|
+
upstream.pipe(client);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export default function (pi: ExtensionAPI) {
|
|
531
|
+
pi.registerFlag('no-sandbox', {
|
|
532
|
+
description: 'Disable landstrip sandboxing for bash commands',
|
|
533
|
+
type: 'boolean',
|
|
534
|
+
default: false,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const localCwd = process.cwd();
|
|
538
|
+
const userShellPath = SettingsManager.create(localCwd).getShellPath();
|
|
539
|
+
const localBash = createBashToolDefinition(localCwd, { shellPath: userShellPath });
|
|
540
|
+
|
|
541
|
+
let sandboxEnabled = false;
|
|
542
|
+
let sandboxReady = false;
|
|
543
|
+
const sessionAllowedDomains: string[] = [];
|
|
544
|
+
const sessionAllowedReadPaths: string[] = [];
|
|
545
|
+
const sessionAllowedWritePaths: string[] = [];
|
|
546
|
+
|
|
547
|
+
function getEffectiveAllowedDomains(cwd: string): string[] {
|
|
548
|
+
const config = loadConfig(cwd);
|
|
549
|
+
return [...config.network.allowedDomains, ...sessionAllowedDomains];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function getEffectiveAllowRead(cwd: string): string[] {
|
|
553
|
+
const config = loadConfig(cwd);
|
|
554
|
+
return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function getEffectiveAllowWrite(cwd: string): string[] {
|
|
558
|
+
const config = loadConfig(cwd);
|
|
559
|
+
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function applyDomainChoice(
|
|
563
|
+
choice: Exclude<PermissionChoice, 'abort'>,
|
|
564
|
+
domain: string,
|
|
565
|
+
cwd: string,
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
const { globalPath, projectPath } = getConfigPaths(cwd);
|
|
568
|
+
if (!sessionAllowedDomains.includes(domain)) sessionAllowedDomains.push(domain);
|
|
569
|
+
if (choice === 'project') addDomainToConfig(projectPath, domain);
|
|
570
|
+
if (choice === 'global') addDomainToConfig(globalPath, domain);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function applyReadChoice(
|
|
574
|
+
choice: Exclude<PermissionChoice, 'abort'>,
|
|
575
|
+
filePath: string,
|
|
576
|
+
cwd: string,
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
const { globalPath, projectPath } = getConfigPaths(cwd);
|
|
579
|
+
if (!sessionAllowedReadPaths.includes(filePath)) sessionAllowedReadPaths.push(filePath);
|
|
580
|
+
if (choice === 'project') addReadPathToConfig(projectPath, filePath);
|
|
581
|
+
if (choice === 'global') addReadPathToConfig(globalPath, filePath);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function applyWriteChoice(
|
|
585
|
+
choice: Exclude<PermissionChoice, 'abort'>,
|
|
586
|
+
filePath: string,
|
|
587
|
+
cwd: string,
|
|
588
|
+
): Promise<void> {
|
|
589
|
+
const { globalPath, projectPath } = getConfigPaths(cwd);
|
|
590
|
+
if (!sessionAllowedWritePaths.includes(filePath)) sessionAllowedWritePaths.push(filePath);
|
|
591
|
+
if (choice === 'project') addWritePathToConfig(projectPath, filePath);
|
|
592
|
+
if (choice === 'global') addWritePathToConfig(globalPath, filePath);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function ensureDomainAllowed(
|
|
596
|
+
ctx: ExtensionContext,
|
|
597
|
+
domain: string,
|
|
598
|
+
cwd: string,
|
|
599
|
+
): Promise<boolean> {
|
|
600
|
+
const config = loadConfig(cwd);
|
|
601
|
+
|
|
602
|
+
if (domainMatchesAny(domain, config.network.deniedDomains)) return false;
|
|
603
|
+
if (domainMatchesAny(domain, getEffectiveAllowedDomains(cwd))) return true;
|
|
604
|
+
|
|
605
|
+
const choice = await promptDomainBlock(ctx, domain);
|
|
606
|
+
if (choice === 'abort') return false;
|
|
607
|
+
|
|
608
|
+
await applyDomainChoice(choice, domain, cwd);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function buildLandstripPolicy(cwd: string, proxyPort: number): LandstripPolicy {
|
|
613
|
+
const config = loadConfig(cwd);
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
network: {
|
|
617
|
+
allowLocalBinding: config.network.allowLocalBinding,
|
|
618
|
+
allowAllUnixSockets: config.network.allowAllUnixSockets,
|
|
619
|
+
allowUnixSockets: config.network.allowUnixSockets,
|
|
620
|
+
httpProxyPort: proxyPort,
|
|
621
|
+
},
|
|
622
|
+
filesystem: {
|
|
623
|
+
denyRead: config.filesystem.denyRead,
|
|
624
|
+
allowRead: [...config.filesystem.allowRead, ...sessionAllowedReadPaths],
|
|
625
|
+
allowWrite: [...config.filesystem.allowWrite, ...sessionAllowedWritePaths],
|
|
626
|
+
denyWrite: config.filesystem.denyWrite,
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function writePolicyFile(cwd: string, proxyPort: number): { dir: string; path: string } {
|
|
632
|
+
const dir = mkdtempSync(join(tmpdir(), 'pi-landstrip-'));
|
|
633
|
+
const path = join(dir, 'policy.json');
|
|
634
|
+
writeFileSync(
|
|
635
|
+
path,
|
|
636
|
+
JSON.stringify(buildLandstripPolicy(cwd, proxyPort), null, 2) + '\n',
|
|
637
|
+
'utf-8',
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
return { dir, path };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function startProxy(
|
|
644
|
+
ctx: ExtensionContext,
|
|
645
|
+
cwd: string,
|
|
646
|
+
): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
647
|
+
const sockets = new Set<Socket>();
|
|
648
|
+
|
|
649
|
+
async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
|
|
650
|
+
const endpoint = splitHostPort(target, 443);
|
|
651
|
+
if (!endpoint || !Number.isFinite(endpoint.port)) {
|
|
652
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!(await ensureDomainAllowed(ctx, endpoint.host, cwd))) {
|
|
657
|
+
denyProxyRequest(client);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const upstream = connectNet(endpoint.port, endpoint.host, () => {
|
|
662
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
663
|
+
pipeSockets(client, upstream, rest);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
|
|
668
|
+
const lines = headerText.split(/\r?\n/);
|
|
669
|
+
const [method, rawTarget, version] = lines[0].split(' ');
|
|
670
|
+
|
|
671
|
+
if (!method || !rawTarget || !version) {
|
|
672
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let url: URL;
|
|
677
|
+
try {
|
|
678
|
+
url = new URL(rawTarget);
|
|
679
|
+
} catch {
|
|
680
|
+
const host = lines
|
|
681
|
+
.find((line) => line.toLowerCase().startsWith('host:'))
|
|
682
|
+
?.slice(5)
|
|
683
|
+
.trim();
|
|
684
|
+
if (!host) {
|
|
685
|
+
denyProxyRequest(client, '400 Bad Request');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
url = new URL(`http://${host}${rawTarget}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!(await ensureDomainAllowed(ctx, url.hostname, cwd))) {
|
|
692
|
+
denyProxyRequest(client);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
697
|
+
const path = `${url.pathname}${url.search}` || '/';
|
|
698
|
+
lines[0] = `${method} ${path} ${version}`;
|
|
699
|
+
|
|
700
|
+
const rewrittenHeader = lines
|
|
701
|
+
.filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
|
|
702
|
+
.join('\r\n');
|
|
703
|
+
const upstream = connectNet(port, url.hostname, () => {
|
|
704
|
+
upstream.write(`${rewrittenHeader}\r\n\r\n`);
|
|
705
|
+
pipeSockets(client, upstream, rest);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function handleClient(client: Socket): void {
|
|
710
|
+
sockets.add(client);
|
|
711
|
+
client.on('close', () => sockets.delete(client));
|
|
712
|
+
client.on('error', () => sockets.delete(client));
|
|
713
|
+
|
|
714
|
+
let buffered = Buffer.alloc(0);
|
|
715
|
+
|
|
716
|
+
client.on('data', (chunk: Buffer) => {
|
|
717
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
718
|
+
const headerEnd = buffered.indexOf('\r\n\r\n');
|
|
719
|
+
if (headerEnd === -1) {
|
|
720
|
+
if (buffered.length > 65536)
|
|
721
|
+
denyProxyRequest(client, '431 Request Header Fields Too Large');
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
client.pause();
|
|
726
|
+
client.removeAllListeners('data');
|
|
727
|
+
|
|
728
|
+
const header = buffered.subarray(0, headerEnd).toString('utf-8');
|
|
729
|
+
const rest = buffered.subarray(headerEnd + 4);
|
|
730
|
+
const firstLine = header.split(/\r?\n/, 1)[0];
|
|
731
|
+
const [method, target] = firstLine.split(' ');
|
|
732
|
+
|
|
733
|
+
const task =
|
|
734
|
+
method?.toUpperCase() === 'CONNECT'
|
|
735
|
+
? handleConnect(client, target, rest)
|
|
736
|
+
: handleHttp(client, header, rest);
|
|
737
|
+
task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const server = createServer(handleClient);
|
|
742
|
+
let stopped = false;
|
|
743
|
+
|
|
744
|
+
return new Promise((resolve, reject) => {
|
|
745
|
+
server.on('error', reject);
|
|
746
|
+
server.listen(0, '127.0.0.1', () => {
|
|
747
|
+
server.removeListener('error', reject);
|
|
748
|
+
const address = server.address() as AddressInfo;
|
|
749
|
+
|
|
750
|
+
resolve({
|
|
751
|
+
port: address.port,
|
|
752
|
+
stop: () =>
|
|
753
|
+
new Promise<void>((done) => {
|
|
754
|
+
if (stopped) {
|
|
755
|
+
done();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
stopped = true;
|
|
759
|
+
for (const socket of sockets) socket.destroy();
|
|
760
|
+
server.close(() => done());
|
|
761
|
+
}),
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function createLandstripBashOps(ctx: ExtensionContext): BashOperations {
|
|
768
|
+
return {
|
|
769
|
+
async exec(command, cwd, { onData, signal, timeout, env }) {
|
|
770
|
+
if (!existsSync(cwd)) throw new Error(`Working directory does not exist: ${cwd}`);
|
|
771
|
+
|
|
772
|
+
const config = loadConfig(cwd);
|
|
773
|
+
const { shell, args } = getShellConfig(userShellPath);
|
|
774
|
+
const proxy = await startProxy(ctx, cwd);
|
|
775
|
+
const policy = writePolicyFile(cwd, proxy.port);
|
|
776
|
+
const landstripArgs = [
|
|
777
|
+
...(config.landstrip.debug ? ['--debug'] : []),
|
|
778
|
+
'-p',
|
|
779
|
+
policy.path,
|
|
780
|
+
shell,
|
|
781
|
+
...args,
|
|
782
|
+
command,
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
return new Promise((resolvePromise, reject) => {
|
|
786
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
787
|
+
let timedOut = false;
|
|
788
|
+
let cleaned = false;
|
|
789
|
+
|
|
790
|
+
const cleanup = () => {
|
|
791
|
+
if (cleaned) return;
|
|
792
|
+
cleaned = true;
|
|
793
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
794
|
+
signal?.removeEventListener('abort', onAbort);
|
|
795
|
+
void proxy.stop();
|
|
796
|
+
rmSync(policy.dir, { recursive: true, force: true });
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const child = spawn(config.landstrip.command, landstripArgs, {
|
|
800
|
+
cwd,
|
|
801
|
+
env: proxyEnv(env, proxy.port),
|
|
802
|
+
detached: true,
|
|
803
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
function killChild(): void {
|
|
807
|
+
if (!child.pid) return;
|
|
808
|
+
try {
|
|
809
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
810
|
+
} catch {
|
|
811
|
+
child.kill('SIGKILL');
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function onAbort(): void {
|
|
816
|
+
killChild();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (timeout !== undefined && timeout > 0) {
|
|
820
|
+
timeoutHandle = setTimeout(() => {
|
|
821
|
+
timedOut = true;
|
|
822
|
+
killChild();
|
|
823
|
+
}, timeout * 1000);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
827
|
+
child.stdout?.on('data', onData);
|
|
828
|
+
child.stderr?.on('data', onData);
|
|
829
|
+
|
|
830
|
+
child.on('error', (error) => {
|
|
831
|
+
cleanup();
|
|
832
|
+
reject(error);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
child.on('close', (code) => {
|
|
836
|
+
cleanup();
|
|
837
|
+
if (signal?.aborted) {
|
|
838
|
+
reject(new Error('aborted'));
|
|
839
|
+
} else if (timedOut) {
|
|
840
|
+
reject(new Error(`timeout:${timeout}`));
|
|
841
|
+
} else {
|
|
842
|
+
resolvePromise({ exitCode: code });
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function runBashWithOptionalRetry(
|
|
851
|
+
id: string,
|
|
852
|
+
params: BashToolInput,
|
|
853
|
+
signal: AbortSignal | undefined,
|
|
854
|
+
onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
|
|
855
|
+
ctx: ExtensionContext,
|
|
856
|
+
): Promise<AgentToolResult<BashToolDetails | undefined>> {
|
|
857
|
+
const sandboxedBash = createBashToolDefinition(localCwd, {
|
|
858
|
+
operations: createLandstripBashOps(ctx),
|
|
859
|
+
shellPath: userShellPath,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
const run = () => sandboxedBash.execute(id, params, signal, onUpdate, ctx);
|
|
863
|
+
const result = await run();
|
|
864
|
+
const outputText = result.content
|
|
865
|
+
.filter((content) => content.type === 'text')
|
|
866
|
+
.map((content) => content.text)
|
|
867
|
+
.join('\n');
|
|
868
|
+
const blockedPath = extractBlockedWritePath(outputText, ctx.cwd);
|
|
869
|
+
|
|
870
|
+
if (!blockedPath || !ctx.hasUI) return result;
|
|
871
|
+
|
|
872
|
+
const choice = await promptWriteBlock(ctx, blockedPath);
|
|
873
|
+
if (choice === 'abort') return result;
|
|
874
|
+
|
|
875
|
+
await applyWriteChoice(choice, blockedPath, ctx.cwd);
|
|
876
|
+
|
|
877
|
+
const config = loadConfig(ctx.cwd);
|
|
878
|
+
const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
|
|
879
|
+
if (matchesPattern(blockedPath, config.filesystem.denyWrite)) {
|
|
880
|
+
ctx.ui.notify(
|
|
881
|
+
`"${blockedPath}" was added to allowWrite, but denyWrite still blocks it. Check:\n ${projectPath}\n ${globalPath}`,
|
|
882
|
+
'warning',
|
|
883
|
+
);
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
onUpdate?.({
|
|
888
|
+
content: [
|
|
889
|
+
{ type: 'text', text: `\n--- Write access granted for "${blockedPath}", retrying ---\n` },
|
|
890
|
+
],
|
|
891
|
+
details: {},
|
|
892
|
+
});
|
|
893
|
+
return run();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function preflightCommandDomains(
|
|
897
|
+
command: string,
|
|
898
|
+
ctx: ExtensionContext,
|
|
899
|
+
): Promise<string | null> {
|
|
900
|
+
for (const domain of extractDomainsFromCommand(command)) {
|
|
901
|
+
if (!(await ensureDomainAllowed(ctx, domain, ctx.cwd))) return domain;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function warnIfAllDomainsAllowed(ctx: ExtensionContext, config: SandboxConfig): void {
|
|
908
|
+
if (!allowsAllDomains(config.network.allowedDomains)) return;
|
|
909
|
+
ctx.ui.notify(
|
|
910
|
+
'Network sandbox allows all domains because network.allowedDomains contains "*".',
|
|
911
|
+
'warning',
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function enableStatus(ctx: ExtensionContext, config: SandboxConfig): void {
|
|
916
|
+
const networkLabel = allowsAllDomains(config.network.allowedDomains)
|
|
917
|
+
? 'all domains'
|
|
918
|
+
: `${config.network.allowedDomains.length} domains`;
|
|
919
|
+
ctx.ui.setStatus(
|
|
920
|
+
'sandbox',
|
|
921
|
+
ctx.ui.theme.fg(
|
|
922
|
+
'accent',
|
|
923
|
+
`Sandbox: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
|
|
924
|
+
),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function enableSandbox(ctx: ExtensionContext): boolean {
|
|
929
|
+
const config = loadConfig(ctx.cwd);
|
|
930
|
+
|
|
931
|
+
if (process.platform !== 'linux') {
|
|
932
|
+
sandboxEnabled = false;
|
|
933
|
+
sandboxReady = false;
|
|
934
|
+
ctx.ui.notify(`landstrip sandboxing is not supported on ${process.platform}`, 'warning');
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const version = landstripVersion(config.landstrip.command);
|
|
939
|
+
if (!version) {
|
|
940
|
+
sandboxEnabled = false;
|
|
941
|
+
sandboxReady = false;
|
|
942
|
+
ctx.ui.notify(`landstrip was not found. Install it with: cargo install landstrip`, 'error');
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
sandboxEnabled = true;
|
|
947
|
+
sandboxReady = true;
|
|
948
|
+
warnIfAllDomainsAllowed(ctx, config);
|
|
949
|
+
enableStatus(ctx, config);
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
pi.registerTool({
|
|
954
|
+
...localBash,
|
|
955
|
+
label: 'bash (landstrip)',
|
|
956
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
957
|
+
if (!sandboxEnabled || !sandboxReady)
|
|
958
|
+
return localBash.execute(id, params, signal, onUpdate, ctx);
|
|
959
|
+
|
|
960
|
+
return runBashWithOptionalRetry(id, params, signal, onUpdate, ctx);
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
pi.on('user_bash', async (event, ctx) => {
|
|
965
|
+
if (!sandboxEnabled || !sandboxReady) return;
|
|
966
|
+
if (!loadConfig(ctx.cwd).enabled) return;
|
|
967
|
+
|
|
968
|
+
const blockedDomain = await preflightCommandDomains(event.command, ctx);
|
|
969
|
+
if (blockedDomain) {
|
|
970
|
+
return {
|
|
971
|
+
result: {
|
|
972
|
+
output: `Blocked: "${blockedDomain}" is not allowed by the sandbox. Use /sandbox to review your config.`,
|
|
973
|
+
exitCode: 1,
|
|
974
|
+
cancelled: false,
|
|
975
|
+
truncated: false,
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return { operations: createLandstripBashOps(ctx) };
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
984
|
+
if (!sandboxEnabled) return;
|
|
985
|
+
|
|
986
|
+
const config = loadConfig(ctx.cwd);
|
|
987
|
+
if (!config.enabled) return;
|
|
988
|
+
|
|
989
|
+
const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
|
|
990
|
+
|
|
991
|
+
if (sandboxReady && isToolCallEventType('bash', event)) {
|
|
992
|
+
const blockedDomain = await preflightCommandDomains(event.input.command, ctx);
|
|
993
|
+
if (blockedDomain) {
|
|
994
|
+
return {
|
|
995
|
+
block: true,
|
|
996
|
+
reason: `Network access to "${blockedDomain}" is blocked by the sandbox.`,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (isToolCallEventType('read', event)) {
|
|
1002
|
+
const filePath = canonicalizePath(event.input.path);
|
|
1003
|
+
if (!matchesPattern(filePath, getEffectiveAllowRead(ctx.cwd))) {
|
|
1004
|
+
const choice = await promptReadBlock(ctx, filePath);
|
|
1005
|
+
if (choice === 'abort') {
|
|
1006
|
+
return {
|
|
1007
|
+
block: true,
|
|
1008
|
+
reason: `Sandbox: read access denied for "${filePath}"`,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
await applyReadChoice(choice, filePath, ctx.cwd);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
|
|
1016
|
+
const filePath = canonicalizePath((event.input as { path: string }).path);
|
|
1017
|
+
|
|
1018
|
+
if (matchesPattern(filePath, config.filesystem.denyWrite)) {
|
|
1019
|
+
return {
|
|
1020
|
+
block: true,
|
|
1021
|
+
reason:
|
|
1022
|
+
`Sandbox: write access denied for "${filePath}" (in denyWrite). ` +
|
|
1023
|
+
`To change this, edit denyWrite in:\n ${projectPath}\n ${globalPath}`,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
|
|
1028
|
+
const choice = await promptWriteBlock(ctx, filePath);
|
|
1029
|
+
if (choice === 'abort') {
|
|
1030
|
+
return {
|
|
1031
|
+
block: true,
|
|
1032
|
+
reason: `Sandbox: write access denied for "${filePath}" (not in allowWrite)`,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
await applyWriteChoice(choice, filePath, ctx.cwd);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1041
|
+
const noSandbox = pi.getFlag('no-sandbox') as boolean;
|
|
1042
|
+
|
|
1043
|
+
if (noSandbox) {
|
|
1044
|
+
sandboxEnabled = false;
|
|
1045
|
+
sandboxReady = false;
|
|
1046
|
+
ctx.ui.notify('Sandbox disabled via --no-sandbox', 'warning');
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const config = loadConfig(ctx.cwd);
|
|
1051
|
+
if (!config.enabled) {
|
|
1052
|
+
sandboxEnabled = false;
|
|
1053
|
+
sandboxReady = false;
|
|
1054
|
+
ctx.ui.notify('Sandbox disabled via config', 'info');
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
enableSandbox(ctx);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
pi.registerCommand('sandbox-enable', {
|
|
1062
|
+
description: 'Enable the landstrip sandbox for this session',
|
|
1063
|
+
handler: async (_args, ctx) => {
|
|
1064
|
+
if (sandboxEnabled) {
|
|
1065
|
+
ctx.ui.notify('Sandbox is already enabled', 'info');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (enableSandbox(ctx)) ctx.ui.notify('Sandbox enabled', 'info');
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
pi.registerCommand('sandbox-disable', {
|
|
1074
|
+
description: 'Disable the landstrip sandbox for this session',
|
|
1075
|
+
handler: async (_args, ctx) => {
|
|
1076
|
+
if (!sandboxEnabled) {
|
|
1077
|
+
ctx.ui.notify('Sandbox is already disabled', 'info');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
sandboxEnabled = false;
|
|
1082
|
+
sandboxReady = false;
|
|
1083
|
+
ctx.ui.setStatus('sandbox', '');
|
|
1084
|
+
ctx.ui.notify('Sandbox disabled', 'info');
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
pi.registerCommand('sandbox', {
|
|
1089
|
+
description: 'Show sandbox configuration',
|
|
1090
|
+
handler: async (_args, ctx) => {
|
|
1091
|
+
if (!sandboxEnabled) {
|
|
1092
|
+
ctx.ui.notify('Sandbox is disabled', 'info');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const config = loadConfig(ctx.cwd);
|
|
1097
|
+
const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
|
|
1098
|
+
const lines = [
|
|
1099
|
+
'Sandbox Configuration',
|
|
1100
|
+
` Project config: ${projectPath}`,
|
|
1101
|
+
` Global config: ${globalPath}`,
|
|
1102
|
+
` landstrip: ${config.landstrip.command}`,
|
|
1103
|
+
'',
|
|
1104
|
+
'Network (bash through HTTP proxy):',
|
|
1105
|
+
` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
|
|
1106
|
+
` Denied domains: ${config.network.deniedDomains.join(', ') || '(none)'}`,
|
|
1107
|
+
...(sessionAllowedDomains.length > 0
|
|
1108
|
+
? [` Session allowed: ${sessionAllowedDomains.join(', ')}`]
|
|
1109
|
+
: []),
|
|
1110
|
+
'',
|
|
1111
|
+
'Filesystem (bash + read/write/edit tools):',
|
|
1112
|
+
` Deny Read: ${config.filesystem.denyRead.join(', ') || '(none)'}`,
|
|
1113
|
+
` Allow Read: ${config.filesystem.allowRead.join(', ') || '(none)'}`,
|
|
1114
|
+
` Allow Write: ${config.filesystem.allowWrite.join(', ') || '(none)'}`,
|
|
1115
|
+
` Deny Write: ${config.filesystem.denyWrite.join(', ') || '(none)'}`,
|
|
1116
|
+
...(sessionAllowedReadPaths.length > 0
|
|
1117
|
+
? [` Session read: ${sessionAllowedReadPaths.join(', ')}`]
|
|
1118
|
+
: []),
|
|
1119
|
+
...(sessionAllowedWritePaths.length > 0
|
|
1120
|
+
? [` Session write: ${sessionAllowedWritePaths.join(', ')}`]
|
|
1121
|
+
: []),
|
|
1122
|
+
];
|
|
1123
|
+
|
|
1124
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-landstrip",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Landlock-based sandboxing for pi with interactive permission prompts",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"landstrip",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-package",
|
|
9
|
+
"sandbox"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/jarkkojs/pi-landstrip.git"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.ts",
|
|
18
|
+
"README.md",
|
|
19
|
+
"sandbox.json"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"fmt": "oxfmt index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
|
|
24
|
+
"lint": "oxlint index.ts",
|
|
25
|
+
"check": "tsc --noEmit",
|
|
26
|
+
"all": "npm run fmt && npm run lint && npm run check",
|
|
27
|
+
"ci:fmt": "oxfmt --check index.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md",
|
|
28
|
+
"ci:lint": "npm run lint",
|
|
29
|
+
"ci:check": "npm run check"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@earendil-works/pi-tui": "^0.78.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
36
|
+
"@types/node": "^24.0.0",
|
|
37
|
+
"oxfmt": "^0.53.0",
|
|
38
|
+
"oxlint": "^1.68.0",
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.78.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"pi": {
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./index.ts"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
package/sandbox.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"network": {
|
|
4
|
+
"allowLocalBinding": true,
|
|
5
|
+
"allowAllUnixSockets": false,
|
|
6
|
+
"allowUnixSockets": [],
|
|
7
|
+
"allowedDomains": [
|
|
8
|
+
"github.com",
|
|
9
|
+
"*.github.com",
|
|
10
|
+
"raw.githubusercontent.com",
|
|
11
|
+
"registry.npmjs.org",
|
|
12
|
+
"*.npmjs.org",
|
|
13
|
+
"crates.io",
|
|
14
|
+
"*.crates.io",
|
|
15
|
+
"static.crates.io"
|
|
16
|
+
],
|
|
17
|
+
"deniedDomains": []
|
|
18
|
+
},
|
|
19
|
+
"filesystem": {
|
|
20
|
+
"denyRead": ["/home"],
|
|
21
|
+
"allowRead": [".", "~/.config", "~/.local", "~/.cargo"],
|
|
22
|
+
"allowWrite": [".", "/tmp", "~/.cargo", "~/.rustup"],
|
|
23
|
+
"denyWrite": [".env", ".env.*", "*.pem", "*.key"]
|
|
24
|
+
}
|
|
25
|
+
}
|