opencode-landstrip 0.18.35 → 0.18.37

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.
Files changed (5) hide show
  1. package/README.md +4 -2
  2. package/index.ts +56 -467
  3. package/package.json +2 -2
  4. package/shared.ts +87 -33
  5. package/tui.ts +159 -41
package/README.md CHANGED
@@ -20,8 +20,10 @@ OpenCode permissions authorize tool dispatch. Landstrip permissions authorize
20
20
  filesystem and network access. Agent approval never bypasses a sandbox hard
21
21
  denial, and requests without a live presenter are denied.
22
22
 
23
- `/sandbox` displays or toggles the policy. Persistent approvals update
24
- `.opencode/sandbox.json` or `~/.config/opencode/sandbox.json`.
23
+ OpenCode intentionally exposes one plugin slash command: `/landstrip`, which
24
+ opens the bottom management pane. Pi-only agent and task subcommands are not
25
+ available. Persistent approvals update `.opencode/sandbox.json` or
26
+ `~/.config/opencode/sandbox.json`.
25
27
 
26
28
  ## Configuration
27
29
 
package/index.ts CHANGED
@@ -5,40 +5,35 @@ import type { Hooks, Plugin, PluginInput, PluginOptions } from '@opencode-ai/plu
5
5
 
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { spawnSync } from 'node:child_process';
8
- import { lookup } from 'node:dns/promises';
9
- import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
10
- import {
11
- type AddressInfo,
12
- BlockList,
13
- connect as connectNet,
14
- createServer,
15
- isIP,
16
- type Socket,
17
- } from 'node:net';
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { type AddressInfo, connect as connectNet, createServer } from 'node:net';
18
10
  import { tmpdir } from 'node:os';
19
- import { basename, dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
20
- import { URL } from 'node:url';
11
+ import { basename, join } from 'node:path';
12
+
13
+ import { startFilterProxy } from '@landstrip/landstrip/proxy';
21
14
 
22
15
  import {
23
16
  type LandstripTrap,
24
17
  type SandboxConfig,
25
18
  type SandboxFilesystemConfig,
26
19
  allowsAllDomains,
20
+ canonicalizeGlobPattern,
21
+ canonicalizePath,
27
22
  controlResponseLine,
28
23
  decodeLandstripTrap,
29
24
  domainMatchesAny,
30
- expandHomePath,
31
25
  extractDomainsFromCommand,
32
26
  formatLandstripTraps,
33
27
  getConfigPaths,
28
+ globToRegExp,
34
29
  isRecord,
35
30
  landstripBinaryPath,
36
31
  loadConfig,
37
32
  normalizeOptions,
33
+ normalizePathSeparators,
38
34
  parseLandstripTraps,
39
35
  permissionPatterns,
40
36
  permissionType,
41
- sandboxSummary,
42
37
  readDiscoveryPort,
43
38
  trapSessionHelloLine,
44
39
  } from './shared.js';
@@ -79,121 +74,13 @@ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
79
74
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
80
75
  const DISCOVERY_CONNECT_TIMEOUT_MS = 250;
81
76
 
82
- const prohibitedProxyAddresses = new BlockList();
83
-
84
- for (const [network, prefix] of [
85
- ['0.0.0.0', 8],
86
- ['10.0.0.0', 8],
87
- ['100.64.0.0', 10],
88
- ['127.0.0.0', 8],
89
- ['169.254.0.0', 16],
90
- ['172.16.0.0', 12],
91
- ['192.0.0.0', 24],
92
- ['192.168.0.0', 16],
93
- ['198.18.0.0', 15],
94
- ['224.0.0.0', 4],
95
- ['240.0.0.0', 4],
96
- ] as const) {
97
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv4');
98
- }
99
- for (const [network, prefix] of [
100
- ['::', 128],
101
- ['::1', 128],
102
- ['fc00::', 7],
103
- ['fe80::', 10],
104
- ['ff00::', 8],
105
- ] as const) {
106
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv6');
107
- }
108
-
109
- function expandPath(filePath: string, baseDirectory: string): string {
110
- const expanded = expandHomePath(filePath);
111
- return resolve(isAbsolute(expanded) ? expanded : join(baseDirectory, expanded));
112
- }
113
-
114
77
  function configuredShellPath(config: unknown): string | undefined {
115
78
  if (!isRecord(config)) return undefined;
116
79
  return typeof config.shell === 'string' ? config.shell : undefined;
117
80
  }
118
81
 
119
- function canonicalizePath(filePath: string, baseDirectory: string): string {
120
- const abs = expandPath(filePath, baseDirectory);
121
-
122
- try {
123
- return realpathSync.native(abs);
124
- } catch {
125
- const tail: string[] = [];
126
- let probe = abs;
127
-
128
- while (!existsSync(probe)) {
129
- const parent = dirname(probe);
130
- if (parent === probe) return abs;
131
- tail.unshift(basename(probe));
132
- probe = parent;
133
- }
134
-
135
- try {
136
- return resolve(realpathSync.native(probe), ...tail);
137
- } catch {
138
- return abs;
139
- }
140
- }
141
- }
142
-
143
- function canonicalizeGlobPath(pattern: string, baseDirectory: string): string {
144
- const abs = expandPath(pattern, baseDirectory);
145
- const wildcardIndex = abs.indexOf('*');
146
- if (wildcardIndex === -1) return canonicalizePath(abs, baseDirectory);
147
-
148
- const prefixEnd = abs.lastIndexOf(sep, wildcardIndex);
149
- const root = parse(abs).root;
150
- const wildcardAtRoot = prefixEnd < root.length;
151
- const prefix = wildcardAtRoot ? root : abs.slice(0, prefixEnd);
152
- const suffixStart = wildcardAtRoot ? root.length : prefixEnd;
153
- return canonicalizePath(prefix, baseDirectory) + abs.slice(suffixStart);
154
- }
155
-
156
82
  function normalizePathForMatch(filePath: string): string {
157
- return process.platform === 'win32' ? filePath.replaceAll('\\', '/').toLowerCase() : filePath;
158
- }
159
-
160
- const globRegExpCache = new Map<string, RegExp>();
161
-
162
- /**
163
- * Translates an absolute glob pattern to a regular expression using standard
164
- * path semantics: `**` crosses directory boundaries (and `**​/` may match zero
165
- * segments), while a single `*` is confined to one path segment.
166
- */
167
- function globToRegExp(globPattern: string): RegExp {
168
- const cached = globRegExpCache.get(globPattern);
169
- if (cached) return cached;
170
-
171
- let regex = '';
172
-
173
- for (let i = 0; i < globPattern.length; i++) {
174
- const char = globPattern.charAt(i);
175
- if (char === '*') {
176
- if (globPattern.charAt(i + 1) === '*') {
177
- i++;
178
- if (globPattern.charAt(i + 1) === '/') {
179
- i++;
180
- regex += '(?:.*/)?';
181
- } else {
182
- regex += '.*';
183
- }
184
- } else {
185
- regex += '[^/]*';
186
- }
187
- } else if (/[.+^${}()|[\]\\]/.test(char)) {
188
- regex += `\\${char}`;
189
- } else {
190
- regex += char;
191
- }
192
- }
193
-
194
- const result = new RegExp(`^${regex}$`);
195
- globRegExpCache.set(globPattern, result);
196
- return result;
83
+ return process.platform === 'win32' ? normalizePathSeparators(filePath).toLowerCase() : filePath;
197
84
  }
198
85
 
199
86
  // Component count of an absolute path; "/" is 0. Used to rank how specific a
@@ -211,7 +98,7 @@ function matchDepth(filePath: string, patterns: string[], baseDirectory: string)
211
98
 
212
99
  for (const pattern of patterns) {
213
100
  if (pattern.includes('*')) {
214
- const absPattern = normalizePathForMatch(canonicalizeGlobPath(pattern, baseDirectory));
101
+ const absPattern = normalizePathForMatch(canonicalizeGlobPattern(pattern, baseDirectory));
215
102
  if (globToRegExp(absPattern).test(abs)) depth = Math.max(depth, pathDepth(abs));
216
103
  } else {
217
104
  const absPattern = normalizePathForMatch(canonicalizePath(pattern, baseDirectory));
@@ -228,7 +115,7 @@ function matchDepth(filePath: string, patterns: string[], baseDirectory: string)
228
115
  function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
229
116
  return patterns.map((pattern) =>
230
117
  pattern.includes('*')
231
- ? canonicalizeGlobPath(pattern, baseDirectory)
118
+ ? canonicalizeGlobPattern(pattern, baseDirectory)
232
119
  : canonicalizePath(pattern, baseDirectory),
233
120
  );
234
121
  }
@@ -450,76 +337,6 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
450
337
  return true;
451
338
  }
452
339
 
453
- function parseProxyPort(value: string | undefined, defaultPort: number): number | null {
454
- const rawPort = value ?? String(defaultPort);
455
- if (!/^\d+$/.test(rawPort)) return null;
456
-
457
- const port = Number(rawPort);
458
- return port >= 1 && port <= 65535 ? port : null;
459
- }
460
-
461
- function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
462
- const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
463
- const host = bracketMatch?.[1];
464
- if (host) {
465
- const port = parseProxyPort(bracketMatch?.[2], defaultPort);
466
- return port === null ? null : { host, port };
467
- }
468
-
469
- const lastColon = target.lastIndexOf(':');
470
- if (lastColon > -1 && target.indexOf(':') === lastColon) {
471
- const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
472
- return port === null ? null : { host: target.slice(0, lastColon), port };
473
- }
474
-
475
- return { host: target, port: defaultPort };
476
- }
477
-
478
- function denyProxyRequest(client: Socket, status = '403 Forbidden'): void {
479
- client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
480
- client.end();
481
- }
482
-
483
- function isPublicProxyAddress(address: string, family = isIP(address)): boolean {
484
- if (family === 4) return !prohibitedProxyAddresses.check(address, 'ipv4');
485
- if (family === 6) return !prohibitedProxyAddresses.check(address, 'ipv6');
486
- return false;
487
- }
488
-
489
- async function resolveProxyEndpoint(host: string): Promise<{ address: string; family: 4 | 6 }> {
490
- const literalFamily = isIP(host);
491
- if (literalFamily === 4 || literalFamily === 6) {
492
- if (!isPublicProxyAddress(host, literalFamily)) {
493
- throw new Error(`Proxy destination is not public: ${host}`);
494
- }
495
- return { address: host, family: literalFamily };
496
- }
497
-
498
- const addresses = await lookup(host, { all: true, verbatim: true });
499
- if (
500
- addresses.length === 0 ||
501
- addresses.some(({ address, family }) => !isPublicProxyAddress(address, family))
502
- ) {
503
- throw new Error(`Proxy destination resolves to a non-public address: ${host}`);
504
- }
505
-
506
- const endpoint = addresses[0];
507
- if (!endpoint || (endpoint.family !== 4 && endpoint.family !== 6)) {
508
- throw new Error(`Proxy could not resolve destination: ${host}`);
509
- }
510
- return { address: endpoint.address, family: endpoint.family };
511
- }
512
-
513
- function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): void {
514
- upstream.on('error', () => client.destroy());
515
- client.on('error', () => upstream.destroy());
516
-
517
- if (initialData?.length) upstream.write(initialData);
518
-
519
- client.pipe(upstream);
520
- upstream.pipe(client);
521
- }
522
-
523
340
  function buildLandstripPolicy(
524
341
  config: SandboxConfig,
525
342
  baseDirectory: string,
@@ -552,191 +369,6 @@ function writePolicyFile(
552
369
  return { dir, path };
553
370
  }
554
371
 
555
- function startProxy(
556
- config: SandboxConfig,
557
- authorization?: string,
558
- ): Promise<{ port: number; stop: () => Promise<void> }> {
559
- const sockets = new Set<Socket>();
560
-
561
- // Track the upstream socket so stop() tears it down, and abandon a still-
562
- // connecting upstream when its client goes away — otherwise a connect to a
563
- // black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
564
- function trackUpstream(upstream: Socket, client: Socket, settled: () => boolean): void {
565
- sockets.add(upstream);
566
- upstream.once('close', () => sockets.delete(upstream));
567
- client.once('close', () => {
568
- if (!settled()) upstream.destroy();
569
- });
570
- }
571
-
572
- async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
573
- const endpoint = splitHostPort(target, 443);
574
- if (!endpoint) {
575
- denyProxyRequest(client, '400 Bad Request');
576
- return;
577
- }
578
-
579
- if (!isDomainAllowed(endpoint.host, config)) {
580
- denyProxyRequest(client);
581
- return;
582
- }
583
-
584
- const resolved = await resolveProxyEndpoint(endpoint.host);
585
- let settled = false;
586
- const upstream = connectNet(
587
- { host: resolved.address, port: endpoint.port, family: resolved.family },
588
- () => {
589
- settled = true;
590
- client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
591
- pipeSockets(client, upstream, rest);
592
- },
593
- );
594
- trackUpstream(upstream, client, () => settled);
595
- upstream.once('error', () => {
596
- if (settled) return;
597
- settled = true;
598
- denyProxyRequest(client, '502 Bad Gateway');
599
- });
600
- }
601
-
602
- async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
603
- const lines = headerText.split(/\r?\n/);
604
- const requestLine = lines[0];
605
- if (!requestLine) {
606
- denyProxyRequest(client, '400 Bad Request');
607
- return;
608
- }
609
-
610
- const [method, rawTarget, version] = requestLine.split(' ');
611
-
612
- if (!method || !rawTarget || !version) {
613
- denyProxyRequest(client, '400 Bad Request');
614
- return;
615
- }
616
-
617
- let url: URL;
618
- try {
619
- url = new URL(rawTarget);
620
- } catch {
621
- const host = lines
622
- .find((line) => line.toLowerCase().startsWith('host:'))
623
- ?.slice(5)
624
- .trim();
625
- if (!host) {
626
- denyProxyRequest(client, '400 Bad Request');
627
- return;
628
- }
629
- url = new URL(`http://${host}${rawTarget}`);
630
- }
631
-
632
- if (!isDomainAllowed(url.hostname, config)) {
633
- denyProxyRequest(client);
634
- return;
635
- }
636
-
637
- const port = parseProxyPort(url.port || undefined, url.protocol === 'https:' ? 443 : 80);
638
- if (port === null) {
639
- denyProxyRequest(client, '400 Bad Request');
640
- return;
641
- }
642
-
643
- const path = `${url.pathname}${url.search}` || '/';
644
- lines[0] = `${method} ${path} ${version}`;
645
-
646
- const rewrittenHeader = lines
647
- .filter(
648
- (line) =>
649
- !line.toLowerCase().startsWith('proxy-connection:') &&
650
- !line.toLowerCase().startsWith('proxy-authorization:'),
651
- )
652
- .join('\r\n');
653
- const resolved = await resolveProxyEndpoint(url.hostname);
654
- let settled = false;
655
- const upstream = connectNet({ host: resolved.address, port, family: resolved.family }, () => {
656
- settled = true;
657
- upstream.write(`${rewrittenHeader}\r\n\r\n`);
658
- pipeSockets(client, upstream, rest);
659
- });
660
- trackUpstream(upstream, client, () => settled);
661
- upstream.once('error', () => {
662
- if (settled) return;
663
- settled = true;
664
- denyProxyRequest(client, '502 Bad Gateway');
665
- });
666
- }
667
-
668
- function handleClient(client: Socket): void {
669
- sockets.add(client);
670
- client.on('close', () => sockets.delete(client));
671
- client.on('error', () => sockets.delete(client));
672
-
673
- let buffered = Buffer.alloc(0);
674
-
675
- client.on('data', (chunk: Buffer) => {
676
- buffered = Buffer.concat([buffered, chunk]);
677
- const headerEnd = buffered.indexOf('\r\n\r\n');
678
- if (headerEnd === -1) {
679
- if (buffered.length > 65536) {
680
- client.removeAllListeners('data');
681
- client.pause();
682
- denyProxyRequest(client, '431 Request Header Fields Too Large');
683
- }
684
- return;
685
- }
686
-
687
- client.pause();
688
- client.removeAllListeners('data');
689
-
690
- const header = buffered.subarray(0, headerEnd).toString('utf-8');
691
- const rest = buffered.subarray(headerEnd + 4);
692
- if (authorization) {
693
- const supplied = header
694
- .split(/\r?\n/)
695
- .find((line) => line.toLowerCase().startsWith('proxy-authorization:'))
696
- ?.slice('proxy-authorization:'.length)
697
- .trim();
698
- if (supplied !== authorization) {
699
- denyProxyRequest(client, '407 Proxy Authentication Required');
700
- return;
701
- }
702
- }
703
- const firstLine = header.split(/\r?\n/, 1)[0];
704
- const [method, target] = (firstLine ?? '').split(' ');
705
-
706
- const task =
707
- method?.toUpperCase() === 'CONNECT' && target
708
- ? handleConnect(client, target, rest)
709
- : handleHttp(client, header, rest);
710
- task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
711
- });
712
- }
713
-
714
- const server = createServer(handleClient);
715
- let stopped = false;
716
-
717
- return new Promise((resolvePromise, reject) => {
718
- server.on('error', reject);
719
- server.listen(0, '127.0.0.1', () => {
720
- server.removeListener('error', reject);
721
- const address = server.address() as AddressInfo;
722
-
723
- resolvePromise({
724
- port: address.port,
725
- stop: () =>
726
- new Promise<void>((done) => {
727
- if (stopped) {
728
- done();
729
- return;
730
- }
731
- stopped = true;
732
- for (const socket of sockets) socket.destroy();
733
- server.close(() => done());
734
- }),
735
- });
736
- });
737
- });
738
- }
739
-
740
372
  function proxyEnv(
741
373
  port: number | null,
742
374
  proxyToken?: string | null,
@@ -1008,8 +640,12 @@ function evaluateToolPermissions(
1008
640
  }
1009
641
 
1010
642
  if (tool === 'write' || tool === 'edit') {
1011
- const path = getToolPath(args);
1012
- return path ? [evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite)] : [];
643
+ const paths = Array.isArray(args.paths)
644
+ ? args.paths.filter((path): path is string => typeof path === 'string')
645
+ : [getToolPath(args)].filter((path): path is string => path !== undefined);
646
+ return paths.map((path) =>
647
+ evaluateWritePermission(path, config, baseDirectory, effectiveAllowWrite),
648
+ );
1013
649
  }
1014
650
 
1015
651
  if (tool === 'apply_patch' && typeof args.patchText === 'string') {
@@ -1043,12 +679,20 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1043
679
  return `${callID}:${kind}:${resource}`;
1044
680
  }
1045
681
 
1046
- function rememberCallAllowance(
682
+ function rememberCallAllowances(
1047
683
  callID: string | undefined,
1048
- decision: SandboxPermissionDecision,
684
+ approvals: readonly SandboxPermissionDecision[],
1049
685
  ): void {
1050
- if (!callID || decision.status === 'deny') return;
1051
- callAllowances.add(allowanceKey(callID, decision.kind, decision.resource));
686
+ if (!callID) return;
687
+ for (const approval of approvals) {
688
+ callAllowances.add(allowanceKey(callID, approval.kind, approval.resource));
689
+ }
690
+ }
691
+
692
+ function clearCallAllowances(callID: string): void {
693
+ for (const key of callAllowances) {
694
+ if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
695
+ }
1052
696
  }
1053
697
 
1054
698
  function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
@@ -1073,26 +717,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1073
717
  reportBlocked(decision);
1074
718
  }
1075
719
 
1076
- function pushCommandText(
1077
- input: { sessionID: string },
1078
- output: { parts: unknown[] },
1079
- text: string,
1080
- ): void {
1081
- output.parts.push({
1082
- type: 'text',
1083
- text,
1084
- id: '',
1085
- sessionID: input.sessionID,
1086
- messageID: '',
1087
- });
1088
- }
1089
-
1090
- function buildSandboxSummary(config: SandboxConfig): string {
1091
- const { globalPath, projectPath } = getConfigPaths(directory);
1092
- const report = sandboxSummary(config, globalPath, projectPath);
1093
- return ['# Sandbox Configuration', '', report].join('\n');
1094
- }
1095
-
1096
720
  client.app
1097
721
  ?.log?.({
1098
722
  body: {
@@ -1241,13 +865,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1241
865
  }
1242
866
 
1243
867
  async function cleanupBash(callID: string): Promise<void> {
868
+ clearCallAllowances(callID);
1244
869
  const state = activeBash.get(callID);
1245
870
  if (!state) return;
1246
871
 
1247
- for (const key of callAllowances) {
1248
- if (key.startsWith(`${callID}:`)) callAllowances.delete(key);
1249
- }
1250
-
1251
872
  activeBash.delete(callID);
1252
873
  if (state.stop) await state.stop().catch(() => undefined);
1253
874
  if (state.trapServer) {
@@ -1323,7 +944,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1323
944
  proxyToken === null
1324
945
  ? undefined
1325
946
  : `Basic ${Buffer.from(`landstrip:${proxyToken}`).toString('base64')}`;
1326
- const proxy = allowNetwork ? null : await startProxy(effectiveConfig, proxyAuthorization);
947
+ const proxy = allowNetwork
948
+ ? null
949
+ : await startFilterProxy({
950
+ isDomainAllowed: (domain) => isDomainAllowed(domain, effectiveConfig),
951
+ ...(proxyAuthorization === undefined ? {} : { authorization: proxyAuthorization }),
952
+ });
1327
953
  const proxyPort = proxy ? proxy.port : null;
1328
954
  let policy: { dir: string; path: string };
1329
955
 
@@ -1387,10 +1013,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1387
1013
 
1388
1014
  'permission.ask': async (input, output) => {
1389
1015
  const config = await activeConfig();
1390
- if (!config) {
1391
- output.status = 'allow';
1392
- return;
1393
- }
1016
+ if (!config) return;
1394
1017
 
1395
1018
  const request = input as Record<string, unknown>;
1396
1019
  const permission = permissionType(request);
@@ -1409,7 +1032,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1409
1032
  const args: Record<string, unknown> = { ...metadata };
1410
1033
  if (permission === 'read') args.paths = patterns;
1411
1034
  if (permission === 'edit') {
1412
- args.path = typeof metadata.filepath === 'string' ? metadata.filepath : patterns[0];
1035
+ args.paths =
1036
+ patterns.length > 0
1037
+ ? patterns
1038
+ : [metadata.filepath].filter((path): path is string => typeof path === 'string');
1413
1039
  }
1414
1040
  if (permission === 'bash' && typeof args.command !== 'string') args.command = patterns[0];
1415
1041
  const decisions = evaluateToolPermissions(
@@ -1421,13 +1047,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1421
1047
  effectiveAllowWrite,
1422
1048
  );
1423
1049
 
1424
- const decision =
1425
- decisions.find((item) => item.status === 'deny') ??
1426
- decisions.find((item) => item.status === 'ask');
1427
- if (!decision) return;
1050
+ const denied = decisions.find((item) => item.status === 'deny');
1051
+ if (denied) {
1052
+ output.status = 'deny';
1053
+ return;
1054
+ }
1055
+
1056
+ const approvals = decisions.filter((item) => item.status === 'ask');
1057
+ if (approvals.length === 0) return;
1428
1058
 
1429
- output.status = decision.status;
1430
- rememberCallAllowance(callID, decision);
1059
+ output.status = 'ask';
1060
+ rememberCallAllowances(callID, approvals);
1431
1061
  },
1432
1062
 
1433
1063
  'tool.execute.before': async (input, output) => {
@@ -1464,7 +1094,10 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1464
1094
  },
1465
1095
 
1466
1096
  'tool.execute.after': async (input, output) => {
1467
- if (input.tool !== 'bash') return;
1097
+ if (input.tool !== 'bash') {
1098
+ clearCallAllowances(input.callID);
1099
+ return;
1100
+ }
1468
1101
 
1469
1102
  const state = activeBash.get(input.callID);
1470
1103
  if (!state) {
@@ -1521,50 +1154,6 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1521
1154
  await cleanupBash(input.callID);
1522
1155
  },
1523
1156
 
1524
- 'command.execute.before': async (input, output) => {
1525
- // OpenCode strips the leading slash before dispatching commands, so the
1526
- // hook receives the bare name ("sandbox"); accept both forms so the
1527
- // handler matches whether invoked by name or via tui.executeCommand.
1528
- const command = input.command.trim().replace(/^\//, '');
1529
- if (command === 'sandbox') {
1530
- const config = loadConfig(directory, optionOverrides);
1531
- pushCommandText(input, output, buildSandboxSummary(config));
1532
- await client.tui
1533
- ?.showToast?.({
1534
- body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
1535
- })
1536
- ?.catch?.(() => undefined);
1537
- return;
1538
- }
1539
-
1540
- // Check domain and filesystem in user shell commands (commands starting with !)
1541
- if (input.command.startsWith('!')) {
1542
- const shellCommand = input.command.slice(1).trim();
1543
- const config = await activeConfig();
1544
- if (!config) return;
1545
-
1546
- const effectiveAllowRead = config.filesystem.allowRead;
1547
- const effectiveAllowWrite = config.filesystem.allowWrite;
1548
-
1549
- for (const path of extractCandidatePaths(shellCommand)) {
1550
- const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1551
- if (readDecision.status === 'deny') reportBlocked(readDecision);
1552
-
1553
- const writeDecision = evaluateWritePermission(
1554
- path,
1555
- config,
1556
- directory,
1557
- effectiveAllowWrite,
1558
- );
1559
- if (writeDecision.status === 'deny') reportBlocked(writeDecision);
1560
- }
1561
-
1562
- for (const decision of evaluateCommandDomains(shellCommand, config)) {
1563
- if (decision.status !== 'allow') reportBlocked(decision);
1564
- }
1565
- }
1566
- },
1567
-
1568
1157
  dispose: async () => {
1569
1158
  await Promise.all([...activeBash.keys()].map((callID) => cleanupBash(callID)));
1570
1159
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.18.35",
3
+ "version": "0.18.37",
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.35",
51
+ "@landstrip/landstrip": "^0.18.37",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },
package/shared.ts CHANGED
@@ -9,14 +9,19 @@ import {
9
9
 
10
10
  import {
11
11
  allowsAllDomains,
12
+ canonicalizeGlobPattern,
13
+ canonicalizePath,
12
14
  controlResponseLine,
13
15
  decodeLandstripTrap,
14
16
  domainMatchesAny,
15
17
  domainMatchesPattern,
16
18
  expandHomePath,
19
+ expandPath,
20
+ globToRegExp,
17
21
  formatLandstripTrap,
18
22
  formatLandstripTraps,
19
23
  isRecord,
24
+ normalizePathSeparators,
20
25
  parseLandstripTraps,
21
26
  pathUnderDirectory,
22
27
  sessionAllows,
@@ -25,14 +30,19 @@ import {
25
30
 
26
31
  export {
27
32
  allowsAllDomains,
33
+ canonicalizeGlobPattern,
34
+ canonicalizePath,
28
35
  controlResponseLine,
29
36
  decodeLandstripTrap,
30
37
  domainMatchesAny,
31
38
  domainMatchesPattern,
32
39
  expandHomePath,
40
+ expandPath,
41
+ globToRegExp,
33
42
  formatLandstripTrap,
34
43
  formatLandstripTraps,
35
44
  isRecord,
45
+ normalizePathSeparators,
36
46
  parseLandstripTraps,
37
47
  pathUnderDirectory,
38
48
  sessionAllows,
@@ -93,57 +103,97 @@ export function list(values: string[]): string {
93
103
  return values.join(', ') || '(none)';
94
104
  }
95
105
 
96
- function stringArray(value: unknown): string[] | undefined {
97
- if (!Array.isArray(value)) return undefined;
98
- return value.every((item) => typeof item === 'string') ? [...value] : undefined;
106
+ function rejectUnknownFields(
107
+ value: Record<string, unknown>,
108
+ fields: readonly string[],
109
+ prefix = '',
110
+ ): void {
111
+ for (const field of Object.keys(value)) {
112
+ if (!fields.includes(field)) throw new Error(`unknown sandbox field ${prefix}${field}`);
113
+ }
114
+ }
115
+
116
+ function booleanValue(value: unknown, field: string): boolean | undefined {
117
+ if (value === undefined) return undefined;
118
+ if (typeof value !== 'boolean') throw new Error(`${field} must be a boolean`);
119
+ return value;
120
+ }
121
+
122
+ function stringArray(value: unknown, field: string): string[] | undefined {
123
+ if (value === undefined) return undefined;
124
+ if (!Array.isArray(value) || [...value].some((item) => typeof item !== 'string')) {
125
+ throw new Error(`${field} must be an array of strings`);
126
+ }
127
+ return [...value];
99
128
  }
100
129
 
101
130
  function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> | undefined {
102
- if (!isRecord(value)) return undefined;
131
+ if (value === undefined) return undefined;
132
+ if (!isRecord(value)) throw new Error('network must be an object');
133
+ rejectUnknownFields(
134
+ value,
135
+ [
136
+ 'allowNetwork',
137
+ 'allowLocalBinding',
138
+ 'allowAllUnixSockets',
139
+ 'allowUnixSockets',
140
+ 'allowedDomains',
141
+ 'deniedDomains',
142
+ ],
143
+ 'network.',
144
+ );
103
145
 
104
146
  const config: Partial<SandboxNetworkConfig> = {};
105
- if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
106
- if (typeof value.allowLocalBinding === 'boolean')
107
- config.allowLocalBinding = value.allowLocalBinding;
108
- if (typeof value.allowAllUnixSockets === 'boolean')
109
- config.allowAllUnixSockets = value.allowAllUnixSockets;
147
+ const allowNetwork = booleanValue(value.allowNetwork, 'network.allowNetwork');
148
+ if (allowNetwork !== undefined) config.allowNetwork = allowNetwork;
149
+ const allowLocalBinding = booleanValue(value.allowLocalBinding, 'network.allowLocalBinding');
150
+ if (allowLocalBinding !== undefined) config.allowLocalBinding = allowLocalBinding;
151
+ const allowAllUnixSockets = booleanValue(
152
+ value.allowAllUnixSockets,
153
+ 'network.allowAllUnixSockets',
154
+ );
155
+ if (allowAllUnixSockets !== undefined) config.allowAllUnixSockets = allowAllUnixSockets;
110
156
 
111
- const allowUnixSockets = stringArray(value.allowUnixSockets);
157
+ const allowUnixSockets = stringArray(value.allowUnixSockets, 'network.allowUnixSockets');
112
158
  if (allowUnixSockets) config.allowUnixSockets = allowUnixSockets;
113
159
 
114
- const allowedDomains = stringArray(value.allowedDomains);
160
+ const allowedDomains = stringArray(value.allowedDomains, 'network.allowedDomains');
115
161
  if (allowedDomains) config.allowedDomains = allowedDomains;
116
162
 
117
- const deniedDomains = stringArray(value.deniedDomains);
163
+ const deniedDomains = stringArray(value.deniedDomains, 'network.deniedDomains');
118
164
  if (deniedDomains) config.deniedDomains = deniedDomains;
119
165
 
120
166
  return config;
121
167
  }
122
168
 
123
169
  function normalizeFilesystemConfig(value: unknown): Partial<SandboxFilesystemConfig> | undefined {
124
- if (!isRecord(value)) return undefined;
170
+ if (value === undefined) return undefined;
171
+ if (!isRecord(value)) throw new Error('filesystem must be an object');
172
+ rejectUnknownFields(value, ['denyRead', 'allowRead', 'allowWrite', 'denyWrite'], 'filesystem.');
125
173
 
126
174
  const config: Partial<SandboxFilesystemConfig> = {};
127
- const denyRead = stringArray(value.denyRead);
175
+ const denyRead = stringArray(value.denyRead, 'filesystem.denyRead');
128
176
  if (denyRead) config.denyRead = denyRead;
129
177
 
130
- const allowRead = stringArray(value.allowRead);
178
+ const allowRead = stringArray(value.allowRead, 'filesystem.allowRead');
131
179
  if (allowRead) config.allowRead = allowRead;
132
180
 
133
- const allowWrite = stringArray(value.allowWrite);
181
+ const allowWrite = stringArray(value.allowWrite, 'filesystem.allowWrite');
134
182
  if (allowWrite) config.allowWrite = allowWrite;
135
183
 
136
- const denyWrite = stringArray(value.denyWrite);
184
+ const denyWrite = stringArray(value.denyWrite, 'filesystem.denyWrite');
137
185
  if (denyWrite) config.denyWrite = denyWrite;
138
186
 
139
187
  return config;
140
188
  }
141
189
 
142
190
  export function normalizeConfig(value: unknown): SandboxConfigOverrides {
143
- if (!isRecord(value)) return {};
191
+ if (!isRecord(value)) throw new Error('sandbox config must be an object');
192
+ rejectUnknownFields(value, ['enabled', 'network', 'filesystem']);
144
193
 
145
194
  const config: SandboxConfigOverrides = {};
146
- if (typeof value.enabled === 'boolean') config.enabled = value.enabled;
195
+ const enabled = booleanValue(value.enabled, 'enabled');
196
+ if (enabled !== undefined) config.enabled = enabled;
147
197
 
148
198
  const network = normalizeNetworkConfig(value.network);
149
199
  if (network) config.network = network;
@@ -156,7 +206,9 @@ export function normalizeConfig(value: unknown): SandboxConfigOverrides {
156
206
 
157
207
  export function normalizeOptions(options: unknown): SandboxConfigOverrides {
158
208
  if (!isRecord(options)) return {};
159
- return normalizeConfig(isRecord(options.config) ? options.config : options);
209
+ if (options.config === undefined) return normalizeConfig(options);
210
+ if (!isRecord(options.config)) throw new Error('config must be an object');
211
+ return normalizeConfig(options.config);
160
212
  }
161
213
 
162
214
  function mergeArray(base: string[], override?: string[]): string[] {
@@ -194,15 +246,16 @@ export function getConfigPaths(baseDirectory: string): { globalPath: string; pro
194
246
  };
195
247
  }
196
248
 
197
- // Returns `{}` when the file is absent and `null` when it exists but cannot be
198
- // parsed, so callers can refuse to overwrite a corrupted config.
199
- export function readConfigFile(configPath: string): SandboxConfigOverrides | null {
249
+ export function readConfigFile(configPath: string): SandboxConfigOverrides {
200
250
  if (!existsSync(configPath)) return {};
201
251
 
202
252
  try {
203
253
  return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
204
- } catch {
205
- return null;
254
+ } catch (error) {
255
+ throw new Error(
256
+ `Could not load ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
257
+ { cause: error },
258
+ );
206
259
  }
207
260
  }
208
261
 
@@ -219,17 +272,14 @@ export function loadConfig(
219
272
  }
220
273
 
221
274
  const templateConfig: SandboxConfig = JSON.parse(readFileSync(templatePath, 'utf-8'));
222
- const globalOverrides = readConfigFile(globalPath) ?? {};
275
+ const globalOverrides = readConfigFile(globalPath);
223
276
  const baseConfig = deepMerge(templateConfig, globalOverrides);
224
277
 
225
- return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath) ?? {}), optionOverrides);
278
+ return deepMerge(deepMerge(baseConfig, readConfigFile(projectPath)), optionOverrides);
226
279
  }
227
280
 
228
281
  export function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
229
282
  const current = readConfigFile(configPath);
230
- if (current === null) {
231
- throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
232
- }
233
283
 
234
284
  const templateConfig: SandboxConfig = JSON.parse(
235
285
  readFileSync(join(packageDir, 'sandbox.json'), 'utf-8'),
@@ -482,7 +532,7 @@ export function discoveryFilePath(baseDirectory: string): string {
482
532
  return join(discoveryDir(), `port-${directoryHash(baseDirectory)}.json`);
483
533
  }
484
534
 
485
- // /sandbox toggles the persisted `enabled` flag. Write it where the setting
535
+ // /landstrip toggles the persisted `enabled` flag. Write it where the setting
486
536
  // already lives — the project config if it sets `enabled`, otherwise the global
487
537
  // config — and return the scope written so the UI can report it.
488
538
  export function sandboxConfigTarget(baseDirectory: string): {
@@ -490,8 +540,7 @@ export function sandboxConfigTarget(baseDirectory: string): {
490
540
  path: string;
491
541
  } {
492
542
  const { globalPath, projectPath } = getConfigPaths(baseDirectory);
493
- const projectConfig = readConfigFile(projectPath);
494
- const useProject = projectConfig !== null && projectConfig.enabled !== undefined;
543
+ const useProject = readConfigFile(projectPath).enabled !== undefined;
495
544
  return useProject
496
545
  ? { scope: 'project', path: projectPath }
497
546
  : { scope: 'global', path: globalPath };
@@ -500,7 +549,12 @@ export function sandboxConfigTarget(baseDirectory: string): {
500
549
  export function setSandboxConfigEnabled(
501
550
  baseDirectory: string,
502
551
  enabled: boolean,
552
+ optionOverrides: SandboxConfigOverrides = {},
503
553
  ): 'project' | 'global' {
554
+ if (optionOverrides.enabled !== undefined) {
555
+ throw new Error('Sandbox state is managed by plugin options');
556
+ }
557
+
504
558
  const target = sandboxConfigTarget(baseDirectory);
505
559
  writeConfigFile(target.path, { enabled });
506
560
  return target.scope;
package/tui.ts CHANGED
@@ -125,6 +125,7 @@ function selectedForeground(background: RGBA): RGBA {
125
125
 
126
126
  const tui: TuiPlugin = async (api, options, meta) => {
127
127
  const optionOverrides = normalizeOptions(options);
128
+ const enabledManagedByOptions = optionOverrides.enabled !== undefined;
128
129
 
129
130
  function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
130
131
  const props = rawProps as unknown as PermissionPromptProps<string>;
@@ -147,9 +148,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
147
148
  priority: 1000,
148
149
  bindings: [
149
150
  { key: 'left', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
150
- { key: 'h', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
151
151
  { key: 'right', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
152
- { key: 'l', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
153
152
  { key: 'return', desc: 'Select permission option', group: 'Sandbox', cmd: submit },
154
153
  { key: 'escape', desc: 'Reject permission', group: 'Sandbox', cmd: props.onCancel },
155
154
  {
@@ -690,58 +689,176 @@ const tui: TuiPlugin = async (api, options, meta) => {
690
689
  refreshPermissionPresentation,
691
690
  );
692
691
 
693
- // /sandbox toggles the persisted `enabled` flag. The server reads
694
- // sandbox.json on every tool call, so the toggle takes effect on the next
695
- // command without any cross-process signalling.
696
- const showSandbox = () => {
692
+ const [landstripOpen, setLandstripOpen] = createSignal(false);
693
+ const [confirmingDisable, setConfirmingDisable] = createSignal(false);
694
+ const [sandboxRevision, setSandboxRevision] = createSignal(0);
695
+ let unregisterLandstripKeys: (() => void) | undefined;
696
+ let popLandstripMode: (() => void) | undefined;
697
+
698
+ const closeLandstrip = () => {
699
+ unregisterLandstripKeys?.();
700
+ popLandstripMode?.();
701
+ unregisterLandstripKeys = undefined;
702
+ popLandstripMode = undefined;
703
+ setConfirmingDisable(false);
704
+ setLandstripOpen(false);
705
+ };
706
+ const toggleLandstrip = () => {
707
+ if (enabledManagedByOptions) {
708
+ api.ui.toast({
709
+ title: 'Landstrip',
710
+ message: 'Sandbox state is managed by plugin options',
711
+ variant: 'warning',
712
+ });
713
+ return;
714
+ }
715
+
697
716
  const directory = api.state.path.directory || process.cwd();
698
717
  const config = loadConfig(directory, optionOverrides);
699
- const next = !config.enabled;
700
- const target = sandboxConfigTarget(directory);
701
- const message = [
702
- `Sandbox is ${config.enabled ? 'enabled' : 'disabled'}.`,
703
- `Network: ${config.network.allowNetwork ? 'open' : 'proxied'}`,
704
- `Change: ${target.scope} config at ${formatPath(target.path, directory)}`,
705
- ].join('\n');
706
-
707
- // No `clear()` in onConfirm: the host pops the dialog itself, and its
708
- // `clear()` re-invokes onClose, which would recurse and freeze the TUI.
709
- api.ui.dialog.replace(() =>
710
- api.ui.DialogConfirm({
711
- title: next ? 'Enable sandbox?' : 'Disable sandbox?',
712
- message,
713
- onConfirm: () => {
714
- const scope = setSandboxConfigEnabled(directory, next);
715
- refreshSandboxStatus?.();
716
- api.ui.toast({
717
- title: 'Sandbox',
718
- message: `Sandbox ${next ? 'enabled' : 'disabled'} (${scope} config)`,
719
- variant: next ? 'success' : 'warning',
720
- });
721
- },
722
- }),
723
- );
718
+ if (config.enabled && !confirmingDisable()) {
719
+ setConfirmingDisable(true);
720
+ return;
721
+ }
722
+ setConfirmingDisable(false);
723
+ const enabled = !config.enabled;
724
+ const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
725
+ refreshSandboxStatus?.();
726
+ api.ui.toast({
727
+ title: 'Landstrip',
728
+ message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
729
+ variant: enabled ? 'success' : 'warning',
730
+ });
731
+ };
732
+ const cancelLandstrip = () => {
733
+ if (confirmingDisable()) setConfirmingDisable(false);
734
+ else closeLandstrip();
735
+ };
736
+
737
+ function LandstripPane() {
738
+ const theme = api.theme.current;
739
+ const dimensions = useTerminalDimensions();
740
+
741
+ const row = (label: string, value: string) =>
742
+ jsxs('box', {
743
+ flexDirection: 'row',
744
+ paddingLeft: 2,
745
+ paddingRight: 2,
746
+ children: [
747
+ jsx('text', { fg: theme.textMuted, width: 22, children: label }),
748
+ jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
749
+ ],
750
+ });
751
+
752
+ return Portal({
753
+ get children() {
754
+ if (!landstripOpen()) return null;
755
+ sandboxRevision();
756
+ const directory = api.state.path.directory || process.cwd();
757
+ const config = loadConfig(directory, optionOverrides);
758
+ const target = sandboxConfigTarget(directory);
759
+ const values = (items: readonly string[]) => items.join(', ') || 'none';
760
+ const status = enabledManagedByOptions
761
+ ? config.enabled
762
+ ? 'Active (plugin options)'
763
+ : 'Disabled by plugin options'
764
+ : config.enabled
765
+ ? 'Active'
766
+ : 'Disabled by configuration';
767
+ return jsxs('box', {
768
+ position: 'absolute',
769
+ zIndex: 4000,
770
+ bottom: 0,
771
+ left: 0,
772
+ right: 0,
773
+ get height() {
774
+ return Math.min(22, Math.max(12, dimensions().height - 2));
775
+ },
776
+ flexDirection: 'column',
777
+ border: ['top'],
778
+ borderColor: theme.primary,
779
+ backgroundColor: theme.backgroundPanel,
780
+ children: [
781
+ jsxs('box', {
782
+ flexDirection: 'row',
783
+ paddingLeft: 2,
784
+ paddingRight: 2,
785
+ paddingTop: 1,
786
+ paddingBottom: 1,
787
+ justifyContent: 'space-between',
788
+ children: [
789
+ jsx('text', { fg: theme.primary, children: 'Landstrip' }),
790
+ jsx('text', {
791
+ fg: config.enabled ? theme.success : theme.warning,
792
+ children: status,
793
+ }),
794
+ ],
795
+ }),
796
+ row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
797
+ row('Allowed Domains', values(config.network.allowedDomains)),
798
+ row('Denied Domains', values(config.network.deniedDomains)),
799
+ row('Allowed Reads', values(config.filesystem.allowRead)),
800
+ row('Denied Reads', values(config.filesystem.denyRead)),
801
+ row('Allowed Writes', values(config.filesystem.allowWrite)),
802
+ row('Denied Writes', values(config.filesystem.denyWrite)),
803
+ row(
804
+ 'Configuration Scope',
805
+ enabledManagedByOptions
806
+ ? 'Plugin options'
807
+ : target.scope === 'project'
808
+ ? 'Project'
809
+ : 'Global',
810
+ ),
811
+ row('Configuration File', formatPath(target.path, directory)),
812
+ jsx('box', { flexGrow: 1 }),
813
+ jsx('text', {
814
+ fg: confirmingDisable() ? theme.error : theme.textMuted,
815
+ marginLeft: 2,
816
+ marginRight: 2,
817
+ marginBottom: 1,
818
+ children: confirmingDisable()
819
+ ? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
820
+ : enabledManagedByOptions
821
+ ? 'Sandbox state is managed by plugin options · Esc close'
822
+ : `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
823
+ }),
824
+ ],
825
+ });
826
+ },
827
+ });
828
+ }
829
+
830
+ const showLandstrip = () => {
831
+ if (landstripOpen()) return;
832
+ setLandstripOpen(true);
833
+ popLandstripMode = api.mode.push('modal');
834
+ unregisterLandstripKeys = api.keymap.registerLayer({
835
+ priority: 1000,
836
+ bindings: [
837
+ { key: 'return', desc: 'Toggle sandbox', group: 'Landstrip', cmd: toggleLandstrip },
838
+ { key: 'escape', desc: 'Close Landstrip', group: 'Landstrip', cmd: cancelLandstrip },
839
+ ],
840
+ });
724
841
  };
842
+ api.lifecycle.onDispose(closeLandstrip);
725
843
 
726
844
  api.keymap.registerLayer({
727
845
  commands: [
728
846
  {
729
847
  namespace: 'palette',
730
- name: 'sandbox',
731
- title: 'Sandbox',
732
- desc: 'Inspect and toggle the sandbox',
733
- category: 'Sandbox',
848
+ name: 'landstrip',
849
+ title: 'Landstrip',
850
+ desc: 'Manage the Landstrip sandbox',
851
+ category: 'Landstrip',
734
852
  suggested: true,
735
- slash: { name: 'sandbox' },
736
- slashName: 'sandbox',
737
- run: showSandbox,
853
+ slash: { name: 'landstrip' },
854
+ slashName: 'landstrip',
855
+ run: showLandstrip,
738
856
  },
739
857
  ],
740
858
  });
741
859
 
742
860
  // Persistent status badge in the prompt area.
743
861
  try {
744
- const [sandboxRevision, setSandboxRevision] = createSignal(0);
745
862
  const statusBadge = (ctx: TuiSlotContext) => {
746
863
  sandboxRevision();
747
864
  const directory = api.state.path.directory || process.cwd();
@@ -759,6 +876,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
759
876
 
760
877
  const statusSlot: TuiSlotPlugin = {
761
878
  slots: {
879
+ app: () => jsx(LandstripPane, {}),
762
880
  home_prompt_right: (ctx) => statusBadge(ctx),
763
881
  session_prompt: (_ctx, props) => renderSessionPrompt(props),
764
882
  session_prompt_right: (ctx) => statusBadge(ctx),
@@ -767,7 +885,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
767
885
  api.slots.register(statusSlot);
768
886
  refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
769
887
  } catch {
770
- // Solid runtime unavailable on this host — skip the status badge.
888
+ // Solid runtime unavailable on this host — skip the pane and status badge.
771
889
  }
772
890
 
773
891
  // First-run onboarding: a single quiet pointer to the default-strict policy
@@ -777,7 +895,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
777
895
  api.kv.set('onboarded', true);
778
896
  api.ui.toast({
779
897
  title: 'Sandbox active',
780
- message: 'Landstrip sandbox is on. Run /sandbox to inspect it.',
898
+ message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
781
899
  variant: 'info',
782
900
  duration: 8000,
783
901
  });