opencode-landstrip 0.18.25 → 0.18.27
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/README.md +61 -29
- package/index.ts +45 -81
- package/package.json +2 -2
- package/shared.ts +79 -0
- package/tui.ts +141 -54
package/README.md
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
# opencode-landstrip
|
|
2
2
|
|
|
3
|
-
`opencode-landstrip`
|
|
4
|
-
[`landstrip`](https://github.com/landstrip/landstrip)
|
|
5
|
-
Sandbox Runtime policy fields.
|
|
3
|
+
`opencode-landstrip` runs OpenCode AI `bash` calls through
|
|
4
|
+
[`landstrip`](https://github.com/landstrip/landstrip).
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
options in that order.
|
|
9
|
-
|
|
10
|
-
## Installation
|
|
6
|
+
## Install
|
|
11
7
|
|
|
12
8
|
```sh
|
|
13
9
|
# Current project
|
|
@@ -17,7 +13,8 @@ opencode plugin install opencode-landstrip
|
|
|
17
13
|
opencode plugin install opencode-landstrip --global
|
|
18
14
|
```
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
A manual installation needs both plugins. Add the server plugin to
|
|
17
|
+
`opencode.json`:
|
|
21
18
|
|
|
22
19
|
```json
|
|
23
20
|
{
|
|
@@ -26,7 +23,7 @@ For a manual install, add the server plugin to `opencode.json`:
|
|
|
26
23
|
}
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
Add the
|
|
26
|
+
Add the presenter to `tui.json`:
|
|
30
27
|
|
|
31
28
|
```json
|
|
32
29
|
{
|
|
@@ -35,31 +32,66 @@ Add the TUI plugin to `tui.json`:
|
|
|
35
32
|
}
|
|
36
33
|
```
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
## Permissions
|
|
36
|
+
|
|
37
|
+
OpenCode agent permissions authorize tool dispatch. Landstrip sandbox
|
|
38
|
+
permissions authorize filesystem and network access before a tool or while its
|
|
39
|
+
process runs. Agent approval never bypasses a sandbox hard denial.
|
|
40
|
+
|
|
41
|
+
The TUI presents one request at a time. OpenCode's native agent prompt has
|
|
42
|
+
priority; Landstrip requests retain FIFO order and are routed to the originating
|
|
43
|
+
root session. Without a live presenter, blocked sandbox requests are denied.
|
|
44
|
+
|
|
45
|
+
Filesystem approvals may be stored in `.opencode/sandbox.json` for the project
|
|
46
|
+
or `~/.config/opencode/sandbox.json` globally. `/sandbox` displays or toggles the
|
|
47
|
+
policy. It updates the project file when that file defines `enabled`, otherwise
|
|
48
|
+
the global file.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Policy merges in this order:
|
|
53
|
+
|
|
54
|
+
1. bundled [`sandbox.json`](./sandbox.json);
|
|
55
|
+
2. `~/.config/opencode/sandbox.json`;
|
|
56
|
+
3. `.opencode/sandbox.json`;
|
|
57
|
+
4. plugin options.
|
|
58
|
+
|
|
59
|
+
Objects merge recursively, arrays combine, and later scalar values replace
|
|
60
|
+
earlier values. The global file is initialized from the bundled policy when it
|
|
61
|
+
does not exist. Programmatic plugin options may be the policy object directly or
|
|
62
|
+
`{ "config": { ... } }`.
|
|
63
|
+
|
|
64
|
+
| Field | Bundled default |
|
|
65
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `enabled` | `true` |
|
|
67
|
+
| `filesystem.denyRead` | `["/Users", "/home"]` |
|
|
68
|
+
| `filesystem.allowRead` | `[".", "~/.gitconfig", "~/.config/git/config", "/dev/null"]` |
|
|
69
|
+
| `filesystem.allowWrite` | `[".", "/dev/null"]` |
|
|
70
|
+
| `filesystem.denyWrite` | `["**/.env", "**/.env.*", "**/*.pem", "**/*.key", ".opencode/sandbox.json", "~/.config/opencode/sandbox.json"]` |
|
|
71
|
+
| `network.allowNetwork` | `false` |
|
|
72
|
+
| `network.allowLocalBinding` | `false` |
|
|
73
|
+
| `network.allowAllUnixSockets` | `false` |
|
|
74
|
+
| `network.allowUnixSockets` | `[]` |
|
|
75
|
+
| `network.allowedDomains` | `[]` |
|
|
76
|
+
| `network.deniedDomains` | `[]` |
|
|
41
77
|
|
|
42
|
-
|
|
78
|
+
Filesystem and core network semantics follow the main
|
|
79
|
+
[Landstrip policy](https://github.com/landstrip/landstrip#policy). `allowedDomains`
|
|
80
|
+
and `deniedDomains` are plugin-only fields enforced by its local HTTP/HTTPS proxy.
|
|
43
81
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
The domain lists are plugin policy fields, not core Landstrip fields.
|
|
82
|
+
If `enabled` is true but the Landstrip binary, version, or platform is unusable,
|
|
83
|
+
AI Bash fails closed rather than running without isolation. Set `enabled` to
|
|
84
|
+
false explicitly to allow unsandboxed AI Bash.
|
|
48
85
|
|
|
49
|
-
|
|
50
|
-
can also pause a blocked filesystem or network operation and show an inline
|
|
51
|
-
approval prompt. Filesystem approvals may be saved to
|
|
52
|
-
`.opencode/sandbox.json` or `~/.config/opencode/sandbox.json`.
|
|
86
|
+
## Limits
|
|
53
87
|
|
|
54
|
-
|
|
55
|
-
OpenCode's plugin API
|
|
56
|
-
settings and receive policy checks, but they do not have
|
|
88
|
+
Only AI `bash` calls are process-sandboxed. Shell-mode commands entered directly
|
|
89
|
+
by the user cannot be replaced through OpenCode's plugin API. They may inherit
|
|
90
|
+
proxy settings and receive policy checks, but they do not have OS isolation.
|
|
57
91
|
|
|
58
|
-
See the main [Landstrip documentation](https://github.com/landstrip/landstrip#readme)
|
|
59
|
-
for policy semantics and platform limits.
|
|
92
|
+
See the main [Landstrip documentation](https://github.com/landstrip/landstrip#readme) for platform limits.
|
|
60
93
|
|
|
61
94
|
## License
|
|
62
95
|
|
|
63
|
-
`opencode-landstrip` is licensed under `Apache-2.0`.
|
|
64
|
-
|
|
65
|
-
`Apache-2.0 AND LGPL-2.1-or-later`.
|
|
96
|
+
`opencode-landstrip` is licensed under `Apache-2.0`. The bundled Landstrip
|
|
97
|
+
package is licensed separately as `Apache-2.0 AND LGPL-2.1-or-later`.
|
package/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
permissionType,
|
|
38
38
|
sandboxSummary,
|
|
39
39
|
readDiscoveryPort,
|
|
40
|
-
|
|
40
|
+
trapSessionHelloLine,
|
|
41
41
|
} from './shared.js';
|
|
42
42
|
|
|
43
43
|
type LandstripPolicy = {
|
|
@@ -50,6 +50,7 @@ type LandstripPolicy = {
|
|
|
50
50
|
interface BashSandboxState {
|
|
51
51
|
originalCommand: string;
|
|
52
52
|
wrappedCommand: string;
|
|
53
|
+
sessionID: string | undefined;
|
|
53
54
|
policyDir: string;
|
|
54
55
|
port: number | null;
|
|
55
56
|
proxyToken: string | null;
|
|
@@ -794,9 +795,6 @@ function startTrapServer(
|
|
|
794
795
|
denyRead: string[],
|
|
795
796
|
denyWrite: string[],
|
|
796
797
|
baseDirectory: string,
|
|
797
|
-
sessionAllowedReadPaths: Set<string>,
|
|
798
|
-
sessionAllowedWritePaths: Set<string>,
|
|
799
|
-
sessionAllowedTargets: Set<string>,
|
|
800
798
|
): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
|
|
801
799
|
const trapLines: string[] = [];
|
|
802
800
|
const server = createServer((trapSocket) => {
|
|
@@ -831,11 +829,6 @@ function startTrapServer(
|
|
|
831
829
|
if (allowed) {
|
|
832
830
|
trapSocket.write(controlResponseLine(queryId, 'allow'));
|
|
833
831
|
} else {
|
|
834
|
-
// Remember the scope for later commands, but deny this syscall so
|
|
835
|
-
// a command that may already have side effects is never retried.
|
|
836
|
-
const scope = sessionScopeFor(path, baseDirectory);
|
|
837
|
-
if (operation === 'read') sessionAllowedReadPaths.add(scope);
|
|
838
|
-
else sessionAllowedWritePaths.add(scope);
|
|
839
832
|
trapSocket.write(controlResponseLine(queryId, 'deny'));
|
|
840
833
|
trapLines.push(line);
|
|
841
834
|
}
|
|
@@ -843,17 +836,8 @@ function startTrapServer(
|
|
|
843
836
|
trap.kind === 'network' &&
|
|
844
837
|
(trap.operation === 'connect' || trap.operation === 'bind')
|
|
845
838
|
) {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
// so — matching the filesystem branch above — auto-grant, remember it
|
|
849
|
-
// for the rest of the session, and surface it afterward rather than
|
|
850
|
-
// hanging or hard-denying an already-approved command.
|
|
851
|
-
const target = trap.target;
|
|
852
|
-
if (!sessionAllowedTargets.has(target)) {
|
|
853
|
-
sessionAllowedTargets.add(target);
|
|
854
|
-
trapLines.push(line);
|
|
855
|
-
}
|
|
856
|
-
trapSocket.write(controlResponseLine(queryId, 'allow'));
|
|
839
|
+
trapSocket.write(controlResponseLine(queryId, 'deny'));
|
|
840
|
+
trapLines.push(line);
|
|
857
841
|
} else {
|
|
858
842
|
trapLines.push(line);
|
|
859
843
|
}
|
|
@@ -905,6 +889,7 @@ function buildWrappedCommand(
|
|
|
905
889
|
shell: string,
|
|
906
890
|
command: string,
|
|
907
891
|
trapPort: number | null,
|
|
892
|
+
sessionID?: string,
|
|
908
893
|
): string {
|
|
909
894
|
const baseArgs = ['run', '-p', policyPath, '--', ...shellArgs(shell, command)];
|
|
910
895
|
const plain = [landstripBinaryPath(), ...baseArgs].map(shellQuote).join(' ');
|
|
@@ -926,7 +911,10 @@ function buildWrappedCommand(
|
|
|
926
911
|
]
|
|
927
912
|
.map(shellQuote)
|
|
928
913
|
.join(' ');
|
|
929
|
-
const
|
|
914
|
+
const identifySession = sessionID
|
|
915
|
+
? ` && printf '%s' ${shellQuote(trapSessionHelloLine(sessionID))} >&3`
|
|
916
|
+
: '';
|
|
917
|
+
const openTrap = `exec 3<>/dev/tcp/127.0.0.1/${trapPort}${identifySession} && exec "$@"`;
|
|
930
918
|
return `bash -c ${shellQuote(openTrap)} bash ${trapped}`;
|
|
931
919
|
}
|
|
932
920
|
|
|
@@ -1065,21 +1053,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1065
1053
|
const activeBash = new Map<string, BashSandboxState>();
|
|
1066
1054
|
const notified = new Set<string>();
|
|
1067
1055
|
const callAllowances = new Set<string>();
|
|
1068
|
-
const sessionAllowedReadPaths = new Set<string>();
|
|
1069
|
-
const sessionAllowedWritePaths = new Set<string>();
|
|
1070
|
-
const sessionAllowedTargets = new Set<string>();
|
|
1071
|
-
|
|
1072
|
-
function mergeAllowances(configured: string[], session: Set<string>): string[] {
|
|
1073
|
-
return [...configured, ...session];
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
function getEffectiveAllowRead(config: SandboxConfig): string[] {
|
|
1077
|
-
return mergeAllowances(config.filesystem.allowRead, sessionAllowedReadPaths);
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
function getEffectiveAllowWrite(config: SandboxConfig): string[] {
|
|
1081
|
-
return mergeAllowances(config.filesystem.allowWrite, sessionAllowedWritePaths);
|
|
1082
|
-
}
|
|
1083
1056
|
let enabledNotified = false;
|
|
1084
1057
|
let configuredShell: string | undefined;
|
|
1085
1058
|
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
@@ -1250,12 +1223,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1250
1223
|
|
|
1251
1224
|
const check = checkLandstrip();
|
|
1252
1225
|
if (!check?.ok) {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
'error',
|
|
1257
|
-
);
|
|
1258
|
-
return null;
|
|
1226
|
+
const reason = check?.reason ?? 'Unknown Landstrip installation error';
|
|
1227
|
+
await notifyOnce(`broken-installation:${reason}`, reason, 'error');
|
|
1228
|
+
throw new Error(`Broken @landstrip/landstrip installation: ${reason}`);
|
|
1259
1229
|
}
|
|
1260
1230
|
|
|
1261
1231
|
if (!enabledNotified) {
|
|
@@ -1308,10 +1278,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1308
1278
|
|
|
1309
1279
|
async function prepareBash(
|
|
1310
1280
|
callID: string,
|
|
1281
|
+
sessionID: string | undefined,
|
|
1311
1282
|
args: Record<string, unknown>,
|
|
1312
1283
|
config: SandboxConfig,
|
|
1313
1284
|
): Promise<void> {
|
|
1314
1285
|
if (typeof args.command !== 'string') return;
|
|
1286
|
+
const normalizedSessionID = sessionID?.trim() || undefined;
|
|
1315
1287
|
|
|
1316
1288
|
const rewriteDescription = (): void => {
|
|
1317
1289
|
if (typeof args.description === 'string')
|
|
@@ -1320,7 +1292,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1320
1292
|
|
|
1321
1293
|
const existing = activeBash.get(callID);
|
|
1322
1294
|
if (existing) {
|
|
1323
|
-
if (
|
|
1295
|
+
if (
|
|
1296
|
+
existing.sessionID === normalizedSessionID &&
|
|
1297
|
+
(args.command === existing.originalCommand || args.command === existing.wrappedCommand)
|
|
1298
|
+
) {
|
|
1324
1299
|
args.command = existing.wrappedCommand;
|
|
1325
1300
|
rewriteDescription();
|
|
1326
1301
|
return;
|
|
@@ -1330,16 +1305,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1330
1305
|
}
|
|
1331
1306
|
|
|
1332
1307
|
if (isGeneratedWrappedCommand(args.command as string)) {
|
|
1333
|
-
const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
|
|
1334
|
-
if (policyMatch?.[1] && existsSync(policyMatch[1])) {
|
|
1335
|
-
rewriteDescription();
|
|
1336
|
-
return;
|
|
1337
|
-
}
|
|
1338
1308
|
if (activeBash.has(callID)) await cleanupBash(callID);
|
|
1339
1309
|
const original = extractOriginalCommand(args.command as string);
|
|
1340
|
-
if (original)
|
|
1341
|
-
args.command = original;
|
|
1342
|
-
}
|
|
1310
|
+
if (original) args.command = original;
|
|
1343
1311
|
}
|
|
1344
1312
|
|
|
1345
1313
|
const allowNetwork = config.network.allowNetwork;
|
|
@@ -1347,11 +1315,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1347
1315
|
const effectiveConfig = {
|
|
1348
1316
|
...config,
|
|
1349
1317
|
network: { ...config.network },
|
|
1350
|
-
filesystem:
|
|
1351
|
-
...config.filesystem,
|
|
1352
|
-
allowRead: getEffectiveAllowRead(config),
|
|
1353
|
-
allowWrite: getEffectiveAllowWrite(config),
|
|
1354
|
-
},
|
|
1318
|
+
filesystem: config.filesystem,
|
|
1355
1319
|
};
|
|
1356
1320
|
|
|
1357
1321
|
if (!allowNetwork) {
|
|
@@ -1391,9 +1355,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1391
1355
|
const originalCommand = args.command as string;
|
|
1392
1356
|
|
|
1393
1357
|
// The TUI owns interactive query handling. Fall back to an in-process
|
|
1394
|
-
// broker when no TUI endpoint
|
|
1358
|
+
// broker when no TUI endpoint or session identity is available.
|
|
1359
|
+
const interactiveSessionID = normalizedSessionID ?? '';
|
|
1395
1360
|
const tuiTrapPort =
|
|
1396
|
-
process.platform === 'linux'
|
|
1361
|
+
process.platform === 'linux' && interactiveSessionID
|
|
1362
|
+
? await readLiveDiscoveryPort(directory)
|
|
1363
|
+
: null;
|
|
1397
1364
|
const trapServer =
|
|
1398
1365
|
tuiTrapPort === null
|
|
1399
1366
|
? await startTrapServer(
|
|
@@ -1402,9 +1369,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1402
1369
|
effectiveConfig.filesystem.denyRead,
|
|
1403
1370
|
effectiveConfig.filesystem.denyWrite,
|
|
1404
1371
|
directory,
|
|
1405
|
-
sessionAllowedReadPaths,
|
|
1406
|
-
sessionAllowedWritePaths,
|
|
1407
|
-
sessionAllowedTargets,
|
|
1408
1372
|
)
|
|
1409
1373
|
: null;
|
|
1410
1374
|
const trapPort = tuiTrapPort ?? trapServer?.port ?? null;
|
|
@@ -1414,11 +1378,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1414
1378
|
configuredShell ?? process.env.SHELL ?? '/bin/sh',
|
|
1415
1379
|
originalCommand,
|
|
1416
1380
|
trapPort,
|
|
1381
|
+
tuiTrapPort === null ? undefined : interactiveSessionID,
|
|
1417
1382
|
);
|
|
1418
1383
|
|
|
1419
1384
|
activeBash.set(callID, {
|
|
1420
1385
|
originalCommand,
|
|
1421
1386
|
wrappedCommand,
|
|
1387
|
+
sessionID: normalizedSessionID,
|
|
1422
1388
|
policyDir: policy.dir,
|
|
1423
1389
|
port: proxyPort,
|
|
1424
1390
|
proxyToken,
|
|
@@ -1456,8 +1422,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1456
1422
|
: undefined;
|
|
1457
1423
|
const patterns = permissionPatterns(request);
|
|
1458
1424
|
|
|
1459
|
-
const effectiveAllowRead =
|
|
1460
|
-
const effectiveAllowWrite =
|
|
1425
|
+
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1426
|
+
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1461
1427
|
const args: Record<string, unknown> = { ...metadata };
|
|
1462
1428
|
if (permission === 'read') args.paths = patterns;
|
|
1463
1429
|
if (permission === 'edit') {
|
|
@@ -1489,7 +1455,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1489
1455
|
if (!config) return;
|
|
1490
1456
|
|
|
1491
1457
|
if (input.tool === 'bash') {
|
|
1492
|
-
await prepareBash(input.callID, output.args, config);
|
|
1458
|
+
await prepareBash(input.callID, input.sessionID, output.args, config);
|
|
1493
1459
|
return;
|
|
1494
1460
|
}
|
|
1495
1461
|
|
|
@@ -1498,8 +1464,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1498
1464
|
output.args,
|
|
1499
1465
|
config,
|
|
1500
1466
|
directory,
|
|
1501
|
-
|
|
1502
|
-
|
|
1467
|
+
config.filesystem.allowRead,
|
|
1468
|
+
config.filesystem.allowWrite,
|
|
1503
1469
|
);
|
|
1504
1470
|
for (const decision of decisions) {
|
|
1505
1471
|
enforcePermission(input.callID, decision);
|
|
@@ -1530,7 +1496,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1530
1496
|
// the after-the-fact toast.
|
|
1531
1497
|
const serverTrapOutput = state.trapLines.join('\n');
|
|
1532
1498
|
const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
|
|
1533
|
-
const
|
|
1499
|
+
const traps = parseLandstripTraps(combinedOutput);
|
|
1500
|
+
const errors = traps.filter(
|
|
1534
1501
|
(trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
|
|
1535
1502
|
);
|
|
1536
1503
|
if (errors.length > 0) {
|
|
@@ -1553,21 +1520,18 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1553
1520
|
?.catch?.(() => undefined);
|
|
1554
1521
|
}
|
|
1555
1522
|
|
|
1556
|
-
const
|
|
1523
|
+
const blockedTrap = traps.find(
|
|
1524
|
+
(trap): trap is Extract<LandstripTrap, { kind: 'filesystem' }> =>
|
|
1525
|
+
trap.kind === 'filesystem' && trap.state === 'query',
|
|
1526
|
+
);
|
|
1527
|
+
const blockedPath = blockedTrap
|
|
1528
|
+
? canonicalizePath(blockedTrap.path, directory)
|
|
1529
|
+
: extractBlockedPath(outputText, directory, state.originalCommand);
|
|
1557
1530
|
if (blockedPath) {
|
|
1558
|
-
|
|
1559
|
-
for (const trap of errors) {
|
|
1560
|
-
if (trap.kind === 'filesystem') {
|
|
1561
|
-
blockedOperation = trap.operation;
|
|
1562
|
-
break;
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1565
|
-
const scope = sessionScopeFor(blockedPath, directory);
|
|
1566
|
-
if (blockedOperation === 'read') sessionAllowedReadPaths.add(scope);
|
|
1567
|
-
else sessionAllowedWritePaths.add(scope);
|
|
1531
|
+
const blockedOperation = blockedTrap?.operation ?? 'read';
|
|
1568
1532
|
await notifyOnce(
|
|
1569
1533
|
`blocked:${blockedPath}`,
|
|
1570
|
-
`Sandbox blocked ${blockedOperation} to "${blockedPath}".
|
|
1534
|
+
`Sandbox blocked ${blockedOperation} to "${blockedPath}". No live TUI presenter was available, so access remains denied.`,
|
|
1571
1535
|
'warning',
|
|
1572
1536
|
);
|
|
1573
1537
|
}
|
|
@@ -1597,8 +1561,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1597
1561
|
const config = await activeConfig();
|
|
1598
1562
|
if (!config) return;
|
|
1599
1563
|
|
|
1600
|
-
const effectiveAllowRead =
|
|
1601
|
-
const effectiveAllowWrite =
|
|
1564
|
+
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1565
|
+
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1602
1566
|
|
|
1603
1567
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1604
1568
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.27",
|
|
4
4
|
"description": "OS-level AI command sandboxing for OpenCode with Landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ci:test": "npm test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@landstrip/landstrip": "^0.18.
|
|
51
|
+
"@landstrip/landstrip": "^0.18.27",
|
|
52
52
|
"@opentui/solid": ">=0.3.4",
|
|
53
53
|
"solid-js": "1.9.12"
|
|
54
54
|
},
|
package/shared.ts
CHANGED
|
@@ -517,6 +517,85 @@ export function controlResponseLine(
|
|
|
517
517
|
return JSON.stringify(response) + '\n';
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
export interface TrapSessionHello {
|
|
521
|
+
kind: 'opencode-landstrip-session';
|
|
522
|
+
sessionID: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface SessionAllowances {
|
|
526
|
+
readPaths: Set<string>;
|
|
527
|
+
writePaths: Set<string>;
|
|
528
|
+
targets: Set<string>;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function trapSessionHelloLine(sessionID: string): string {
|
|
532
|
+
const normalized = sessionID.trim();
|
|
533
|
+
if (!normalized) throw new Error('OpenCode session ID must not be empty');
|
|
534
|
+
const hello: TrapSessionHello = { kind: 'opencode-landstrip-session', sessionID: normalized };
|
|
535
|
+
return JSON.stringify(hello) + '\n';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function decodeTrapSessionHello(line: string): TrapSessionHello | null {
|
|
539
|
+
let value: unknown;
|
|
540
|
+
try {
|
|
541
|
+
value = JSON.parse(line);
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
if (!isRecord(value) || value.kind !== 'opencode-landstrip-session') return null;
|
|
546
|
+
if (typeof value.sessionID !== 'string') return null;
|
|
547
|
+
const sessionID = value.sessionID.trim();
|
|
548
|
+
return sessionID ? { kind: 'opencode-landstrip-session', sessionID } : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function sessionAllowancesFor(
|
|
552
|
+
sessions: Map<string, SessionAllowances>,
|
|
553
|
+
sessionID: string,
|
|
554
|
+
): SessionAllowances {
|
|
555
|
+
const existing = sessions.get(sessionID);
|
|
556
|
+
if (existing) return existing;
|
|
557
|
+
const created: SessionAllowances = {
|
|
558
|
+
readPaths: new Set(),
|
|
559
|
+
writePaths: new Set(),
|
|
560
|
+
targets: new Set(),
|
|
561
|
+
};
|
|
562
|
+
sessions.set(sessionID, created);
|
|
563
|
+
return created;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function rootSessionIDFor(
|
|
567
|
+
sourceSessionID: string,
|
|
568
|
+
session: (sessionID: string) => { parentID?: string } | undefined,
|
|
569
|
+
): string | undefined {
|
|
570
|
+
let current = sourceSessionID;
|
|
571
|
+
const seen = new Set<string>();
|
|
572
|
+
while (!seen.has(current)) {
|
|
573
|
+
seen.add(current);
|
|
574
|
+
const value = session(current);
|
|
575
|
+
if (!value) return undefined;
|
|
576
|
+
if (!value.parentID) return current;
|
|
577
|
+
current = value.parentID;
|
|
578
|
+
}
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function nextSandboxPermissionIndex<T extends { sessionID: string }>(
|
|
583
|
+
queue: readonly T[],
|
|
584
|
+
routeSessionID: string,
|
|
585
|
+
blocked: (entry: T) => boolean,
|
|
586
|
+
): number {
|
|
587
|
+
const entry = queue[0];
|
|
588
|
+
return entry && entry.sessionID === routeSessionID && !blocked(entry) ? 0 : -1;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function shouldRenderSandboxPermission(
|
|
592
|
+
requestSessionID: string,
|
|
593
|
+
routeSessionID: string,
|
|
594
|
+
hostPromptActive: boolean,
|
|
595
|
+
): boolean {
|
|
596
|
+
return requestSessionID === routeSessionID && !hostPromptActive;
|
|
597
|
+
}
|
|
598
|
+
|
|
520
599
|
// The TUI plugin runs the query-response socket server and publishes its port
|
|
521
600
|
// to a per-directory discovery file; the server plugin reads it to inject the
|
|
522
601
|
// fd-3 redirect. Namespacing by a hash of the realpath keeps concurrent
|
package/tui.ts
CHANGED
|
@@ -14,19 +14,25 @@ import type {
|
|
|
14
14
|
import { RGBA } from '@opentui/core';
|
|
15
15
|
import { useTerminalDimensions } from '@opentui/solid';
|
|
16
16
|
import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
|
|
17
|
-
import { createSignal, onCleanup } from 'solid-js';
|
|
17
|
+
import { createSignal, onCleanup, onMount } from 'solid-js';
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
controlResponseLine,
|
|
21
|
+
decodeTrapSessionHello,
|
|
21
22
|
getConfigPaths,
|
|
22
23
|
loadConfig,
|
|
23
24
|
normalizeOptions,
|
|
25
|
+
nextSandboxPermissionIndex,
|
|
24
26
|
parseLandstripTraps,
|
|
25
27
|
removeDiscoveryFile,
|
|
26
28
|
sessionAllows,
|
|
29
|
+
sessionAllowancesFor,
|
|
27
30
|
sandboxSummary,
|
|
28
31
|
sessionScopeFor,
|
|
32
|
+
rootSessionIDFor,
|
|
29
33
|
setSandboxConfigEnabled,
|
|
34
|
+
shouldRenderSandboxPermission,
|
|
35
|
+
type SessionAllowances,
|
|
30
36
|
updateForPermission,
|
|
31
37
|
writeConfigFile,
|
|
32
38
|
writeDiscoveryPort,
|
|
@@ -49,6 +55,7 @@ interface PermissionPromptProps<Value extends string> {
|
|
|
49
55
|
options: readonly PromptOption<Value>[];
|
|
50
56
|
onSelect: (value: Value) => void;
|
|
51
57
|
onCancel: () => void;
|
|
58
|
+
onShow: () => void;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
const promptBorderChars = {
|
|
@@ -74,6 +81,9 @@ interface FsQueryEntry {
|
|
|
74
81
|
socket: NetSocket;
|
|
75
82
|
queryId: string;
|
|
76
83
|
operation: 'read' | 'write';
|
|
84
|
+
sessionID: string;
|
|
85
|
+
sourceSessionID: string;
|
|
86
|
+
directory: string;
|
|
77
87
|
path: string;
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -87,6 +97,9 @@ interface NetworkQueryEntry {
|
|
|
87
97
|
socket: NetSocket;
|
|
88
98
|
queryId: string;
|
|
89
99
|
operation: string;
|
|
100
|
+
sessionID: string;
|
|
101
|
+
sourceSessionID: string;
|
|
102
|
+
directory: string;
|
|
90
103
|
target: string;
|
|
91
104
|
}
|
|
92
105
|
|
|
@@ -125,6 +138,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
125
138
|
const theme = api.theme.current;
|
|
126
139
|
const dimensions = useTerminalDimensions();
|
|
127
140
|
const [selected, setSelected] = createSignal(0);
|
|
141
|
+
onMount(props.onShow);
|
|
128
142
|
|
|
129
143
|
function move(direction: number): void {
|
|
130
144
|
setSelected((index) => (index + direction + props.options.length) % props.options.length);
|
|
@@ -273,28 +287,51 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
273
287
|
const resolved = new Set<string>();
|
|
274
288
|
const queue: QueueEntry[] = [];
|
|
275
289
|
const [activeEntry, setActiveEntry] = createSignal<QueueEntry>();
|
|
290
|
+
const [permissionRevision, setPermissionRevision] = createSignal(0);
|
|
276
291
|
let activeId: string | undefined;
|
|
277
292
|
let refreshSandboxStatus: (() => void) | undefined;
|
|
278
293
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// server regenerates the policy from on-disk config each run — so it affects
|
|
282
|
-
// only live socket decisions, not the static policy.
|
|
283
|
-
const sessionAllowedWritePaths = new Set<string>();
|
|
284
|
-
const sessionAllowedReadPaths = new Set<string>();
|
|
285
|
-
|
|
286
|
-
// Targets the user approved "for session": address:port, since the broker
|
|
287
|
-
// knows nothing more specific than that at connect/bind time.
|
|
288
|
-
const sessionAllowedTargets = new Set<string>();
|
|
294
|
+
const sessionAllowances = new Map<string, SessionAllowances>();
|
|
295
|
+
const notified = new Set<string>();
|
|
289
296
|
|
|
290
297
|
// Filesystem and network queries still awaiting a response, so cleanup can
|
|
291
298
|
// release held syscalls instead of letting the child hang.
|
|
292
299
|
const liveQueries = new Set<FsQueryEntry | NetworkQueryEntry>();
|
|
293
300
|
|
|
301
|
+
function nativePermissionPending(entry: QueueEntry): boolean {
|
|
302
|
+
if (api.state.session.permission(entry.sessionID).length > 0) return true;
|
|
303
|
+
return (
|
|
304
|
+
entry.sourceSessionID !== entry.sessionID &&
|
|
305
|
+
api.state.session.permission(entry.sourceSessionID).length > 0
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function currentRouteSessionID(): string | undefined {
|
|
310
|
+
const route = api.route.current;
|
|
311
|
+
if (route.name !== 'session' || !route.params) return undefined;
|
|
312
|
+
return typeof route.params.sessionID === 'string' ? route.params.sessionID : undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
function pump(): void {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
316
|
+
const routeSessionID = currentRouteSessionID();
|
|
317
|
+
if (!routeSessionID) return;
|
|
318
|
+
|
|
319
|
+
const active = activeEntry();
|
|
320
|
+
if (activeId !== undefined) {
|
|
321
|
+
if (active?.sessionID === routeSessionID) return;
|
|
322
|
+
if (active && !resolved.has(active.id)) queue.unshift(active);
|
|
323
|
+
activeId = undefined;
|
|
324
|
+
setActiveEntry(undefined);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
328
|
+
const entry = queue[index];
|
|
329
|
+
if (entry && resolved.has(entry.id)) queue.splice(index, 1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const index = nextSandboxPermissionIndex(queue, routeSessionID, nativePermissionPending);
|
|
333
|
+
if (index === -1) return;
|
|
334
|
+
const [next] = queue.splice(index, 1);
|
|
298
335
|
if (!next) return;
|
|
299
336
|
if (next.kind === 'fs-query') showFsQuery(next);
|
|
300
337
|
else showNetworkQuery(next);
|
|
@@ -310,6 +347,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
310
347
|
|
|
311
348
|
function finishActive(id: string): void {
|
|
312
349
|
resolved.add(id);
|
|
350
|
+
notified.delete(id);
|
|
313
351
|
if (activeId === id) {
|
|
314
352
|
activeId = undefined;
|
|
315
353
|
setActiveEntry(undefined);
|
|
@@ -317,19 +355,39 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
317
355
|
queueMicrotask(pump);
|
|
318
356
|
}
|
|
319
357
|
|
|
358
|
+
function notifyQuery(entry: QueueEntry): void {
|
|
359
|
+
if (notified.has(entry.id)) return;
|
|
360
|
+
notified.add(entry.id);
|
|
361
|
+
void api.attention
|
|
362
|
+
.notify({
|
|
363
|
+
title: `Sandbox ${entry.operation} blocked`,
|
|
364
|
+
message: entry.kind === 'fs-query' ? entry.path : entry.target,
|
|
365
|
+
sound: { name: 'permission' },
|
|
366
|
+
notification: true,
|
|
367
|
+
})
|
|
368
|
+
.catch(() => undefined);
|
|
369
|
+
}
|
|
370
|
+
|
|
320
371
|
function renderSessionPrompt(props: TuiHostSlotMap['session_prompt']) {
|
|
321
372
|
return jsx(Fragment, {
|
|
322
373
|
get children() {
|
|
374
|
+
permissionRevision();
|
|
375
|
+
queueMicrotask(pump);
|
|
323
376
|
const entry = activeEntry();
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
377
|
+
const hostPromptActive =
|
|
378
|
+
props.visible === false ||
|
|
379
|
+
Boolean(props.disabled) ||
|
|
380
|
+
Boolean(entry && nativePermissionPending(entry));
|
|
381
|
+
const showEntry = Boolean(
|
|
382
|
+
entry &&
|
|
383
|
+
shouldRenderSandboxPermission(entry.sessionID, props.session_id, hostPromptActive),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
if (showEntry && entry?.kind === 'fs-query') {
|
|
329
387
|
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
330
388
|
return jsx(LandstripPermissionPrompt, {
|
|
331
389
|
icon: '→',
|
|
332
|
-
title: `${verb} ${formatPath(entry.path, directory)}`,
|
|
390
|
+
title: `${verb} ${formatPath(entry.path, entry.directory)}`,
|
|
333
391
|
options: [
|
|
334
392
|
{ label: 'Allow once', value: 'once' },
|
|
335
393
|
{ label: 'Allow for session', value: 'session' },
|
|
@@ -339,9 +397,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
339
397
|
],
|
|
340
398
|
onSelect: (choice: QueryChoice) => resolveFsQuery(entry, choice),
|
|
341
399
|
onCancel: () => resolveFsQuery(entry, 'deny'),
|
|
400
|
+
onShow: () => notifyQuery(entry),
|
|
342
401
|
});
|
|
343
402
|
}
|
|
344
|
-
if (entry?.kind === 'net-query') {
|
|
403
|
+
if (showEntry && entry?.kind === 'net-query') {
|
|
345
404
|
const verb = entry.operation
|
|
346
405
|
? entry.operation[0]?.toUpperCase() + entry.operation.slice(1)
|
|
347
406
|
: 'Network';
|
|
@@ -355,6 +414,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
355
414
|
],
|
|
356
415
|
onSelect: (choice: NetworkQueryChoice) => resolveNetworkQuery(entry, choice),
|
|
357
416
|
onCancel: () => resolveNetworkQuery(entry, 'deny'),
|
|
417
|
+
onShow: () => notifyQuery(entry),
|
|
358
418
|
});
|
|
359
419
|
}
|
|
360
420
|
return api.ui.Prompt({
|
|
@@ -380,18 +440,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
380
440
|
if (resolved.has(entry.id)) return;
|
|
381
441
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
382
442
|
const verb = entry.operation === 'read' ? 'Read' : 'Write';
|
|
383
|
-
const directory =
|
|
443
|
+
const directory = entry.directory;
|
|
384
444
|
const scope = sessionScopeFor(entry.path, directory);
|
|
385
|
-
const
|
|
386
|
-
|
|
445
|
+
const allowances = sessionAllowancesFor(sessionAllowances, entry.sessionID);
|
|
446
|
+
const sessionPaths = entry.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
387
447
|
|
|
448
|
+
let responseAction: 'allow' | 'deny' = action;
|
|
388
449
|
try {
|
|
389
450
|
if (action === 'allow') {
|
|
390
|
-
// Breadth-first: seed the session set with the broadest reasonable
|
|
391
|
-
// ancestor so the still-running command stops prompting for sibling
|
|
392
|
-
// files under the same tree. 'once' intentionally stays exact-path.
|
|
393
|
-
if (choice !== 'once') sessionPaths.add(scope);
|
|
394
|
-
|
|
395
451
|
if (choice === 'project' || choice === 'global') {
|
|
396
452
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
397
453
|
const update = updateForPermission({
|
|
@@ -400,20 +456,22 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
400
456
|
});
|
|
401
457
|
if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
|
|
402
458
|
}
|
|
459
|
+
if (choice !== 'once') sessionPaths.add(scope);
|
|
403
460
|
}
|
|
461
|
+
} catch {
|
|
462
|
+
responseAction = 'deny';
|
|
463
|
+
}
|
|
404
464
|
|
|
405
|
-
|
|
465
|
+
try {
|
|
466
|
+
respondQuery(entry.socket, entry.queryId, responseAction);
|
|
406
467
|
api.ui.toast({
|
|
407
468
|
title: 'Sandbox',
|
|
408
469
|
message:
|
|
409
|
-
|
|
470
|
+
responseAction === 'deny'
|
|
410
471
|
? `${verb} denied: ${entry.path}`
|
|
411
472
|
: `${verb} allowed (${choice}) under ${scope}`,
|
|
412
|
-
variant:
|
|
473
|
+
variant: responseAction === 'deny' ? 'warning' : 'success',
|
|
413
474
|
});
|
|
414
|
-
} catch {
|
|
415
|
-
// Persisting failed — still release the held syscall by denying it.
|
|
416
|
-
respondQuery(entry.socket, entry.queryId, 'deny');
|
|
417
475
|
} finally {
|
|
418
476
|
liveQueries.delete(entry);
|
|
419
477
|
finishActive(entry.id);
|
|
@@ -423,20 +481,15 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
423
481
|
function showFsQuery(entry: FsQueryEntry): void {
|
|
424
482
|
activeId = entry.id;
|
|
425
483
|
setActiveEntry(entry);
|
|
426
|
-
|
|
427
|
-
void api.attention.notify({
|
|
428
|
-
title: `Sandbox ${entry.operation} blocked`,
|
|
429
|
-
message: entry.path,
|
|
430
|
-
sound: { name: 'permission' },
|
|
431
|
-
notification: true,
|
|
432
|
-
});
|
|
433
484
|
}
|
|
434
485
|
function resolveNetworkQuery(entry: NetworkQueryEntry, choice: NetworkQueryChoice): void {
|
|
435
486
|
if (resolved.has(entry.id)) return;
|
|
436
487
|
const action = choice === 'deny' ? 'deny' : 'allow';
|
|
437
488
|
|
|
438
489
|
try {
|
|
439
|
-
if (action === 'allow' && choice === 'session')
|
|
490
|
+
if (action === 'allow' && choice === 'session') {
|
|
491
|
+
sessionAllowancesFor(sessionAllowances, entry.sessionID).targets.add(entry.target);
|
|
492
|
+
}
|
|
440
493
|
|
|
441
494
|
respondQuery(entry.socket, entry.queryId, action);
|
|
442
495
|
api.ui.toast({
|
|
@@ -456,13 +509,6 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
456
509
|
function showNetworkQuery(entry: NetworkQueryEntry): void {
|
|
457
510
|
activeId = entry.id;
|
|
458
511
|
setActiveEntry(entry);
|
|
459
|
-
|
|
460
|
-
void api.attention.notify({
|
|
461
|
-
title: `Sandbox ${entry.operation} blocked`,
|
|
462
|
-
message: entry.target,
|
|
463
|
-
sound: { name: 'permission' },
|
|
464
|
-
notification: true,
|
|
465
|
-
});
|
|
466
512
|
}
|
|
467
513
|
|
|
468
514
|
// Query-response socket server (Linux-only — landstrip's socket protocol lives
|
|
@@ -481,6 +527,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
481
527
|
const socketId = ++socketSeq;
|
|
482
528
|
const seen = new Set<string>();
|
|
483
529
|
let buffer = '';
|
|
530
|
+
let sourceSessionID: string | undefined;
|
|
531
|
+
let routeSessionID: string | undefined;
|
|
532
|
+
let sessionDirectory: string | undefined;
|
|
484
533
|
|
|
485
534
|
socket.on('data', (chunk: string | Buffer) => {
|
|
486
535
|
buffer += chunk.toString();
|
|
@@ -494,21 +543,37 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
494
543
|
const line = buffer.slice(0, newline);
|
|
495
544
|
buffer = buffer.slice(newline + 1);
|
|
496
545
|
|
|
546
|
+
if (!sourceSessionID || !routeSessionID || !sessionDirectory) {
|
|
547
|
+
const hello = decodeTrapSessionHello(line);
|
|
548
|
+
const sourceSession = hello ? api.state.session.get(hello.sessionID) : undefined;
|
|
549
|
+
const rootSession = hello
|
|
550
|
+
? rootSessionIDFor(hello.sessionID, (sessionID) => api.state.session.get(sessionID))
|
|
551
|
+
: undefined;
|
|
552
|
+
if (!hello || !sourceSession || !rootSession) {
|
|
553
|
+
socket.destroy();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
sourceSessionID = hello.sessionID;
|
|
557
|
+
routeSessionID = rootSession;
|
|
558
|
+
sessionDirectory = sourceSession.directory || baseDirectory;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const allowances = sessionAllowancesFor(sessionAllowances, routeSessionID);
|
|
497
563
|
for (const trap of parseLandstripTraps(line)) {
|
|
498
|
-
const directory = api.state.path.directory || process.cwd();
|
|
499
564
|
if (trap.kind !== 'filesystem' && trap.kind !== 'network') continue;
|
|
500
565
|
if (trap.state !== 'query') continue;
|
|
501
566
|
if (seen.has(trap.query_id)) continue;
|
|
502
567
|
seen.add(trap.query_id);
|
|
503
568
|
|
|
504
|
-
if (!loadConfig(
|
|
569
|
+
if (!loadConfig(sessionDirectory, optionOverrides).enabled) {
|
|
505
570
|
respondQuery(socket, trap.query_id, 'allow');
|
|
506
571
|
continue;
|
|
507
572
|
}
|
|
508
573
|
|
|
509
574
|
if (trap.kind === 'filesystem') {
|
|
510
575
|
const sessionPaths =
|
|
511
|
-
trap.operation === 'read' ?
|
|
576
|
+
trap.operation === 'read' ? allowances.readPaths : allowances.writePaths;
|
|
512
577
|
if (sessionAllows(sessionPaths, trap.path)) {
|
|
513
578
|
respondQuery(socket, trap.query_id, 'allow');
|
|
514
579
|
continue;
|
|
@@ -521,11 +586,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
521
586
|
queryId: trap.query_id,
|
|
522
587
|
operation: trap.operation,
|
|
523
588
|
path: trap.path,
|
|
589
|
+
sessionID: routeSessionID,
|
|
590
|
+
sourceSessionID,
|
|
591
|
+
directory: sessionDirectory,
|
|
524
592
|
};
|
|
525
593
|
liveQueries.add(entry);
|
|
526
594
|
enqueueEntry(entry);
|
|
527
595
|
} else {
|
|
528
|
-
if (
|
|
596
|
+
if (allowances.targets.has(trap.target)) {
|
|
529
597
|
respondQuery(socket, trap.query_id, 'allow');
|
|
530
598
|
continue;
|
|
531
599
|
}
|
|
@@ -537,6 +605,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
537
605
|
queryId: trap.query_id,
|
|
538
606
|
operation: trap.operation,
|
|
539
607
|
target: trap.target,
|
|
608
|
+
sessionID: routeSessionID,
|
|
609
|
+
sourceSessionID,
|
|
610
|
+
directory: sessionDirectory,
|
|
540
611
|
};
|
|
541
612
|
liveQueries.add(entry);
|
|
542
613
|
enqueueEntry(entry);
|
|
@@ -579,6 +650,18 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
579
650
|
});
|
|
580
651
|
}
|
|
581
652
|
|
|
653
|
+
const refreshPermissionPresentation = (): void => {
|
|
654
|
+
queueMicrotask(() => {
|
|
655
|
+
setPermissionRevision((revision) => revision + 1);
|
|
656
|
+
pump();
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
const unregisterPermissionAsked = api.event.on('permission.asked', refreshPermissionPresentation);
|
|
660
|
+
const unregisterPermissionReplied = api.event.on(
|
|
661
|
+
'permission.replied',
|
|
662
|
+
refreshPermissionPresentation,
|
|
663
|
+
);
|
|
664
|
+
|
|
582
665
|
// /sandbox shows the config and toggles the persisted `enabled` flag. The
|
|
583
666
|
// server reads sandbox.json on every tool call, so the toggle takes effect on
|
|
584
667
|
// the next command without any cross-process signalling.
|
|
@@ -683,11 +766,15 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
|
|
683
766
|
}
|
|
684
767
|
|
|
685
768
|
api.lifecycle.onDispose(() => {
|
|
769
|
+
unregisterPermissionAsked();
|
|
770
|
+
unregisterPermissionReplied();
|
|
771
|
+
sessionAllowances.clear();
|
|
686
772
|
// Deny any still-held queries so the sandboxed children don't hang, then
|
|
687
773
|
// tear down the socket server and drop the discovery file.
|
|
688
774
|
for (const entry of liveQueries) {
|
|
689
775
|
respondQuery(entry.socket, entry.queryId, 'deny');
|
|
690
776
|
liveQueries.delete(entry);
|
|
777
|
+
finishActive(entry.id);
|
|
691
778
|
}
|
|
692
779
|
for (const socket of sockets) socket.destroy();
|
|
693
780
|
if (socketServer) {
|