opencode-landstrip 0.18.34 → 0.18.36

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 (6) hide show
  1. package/README.md +23 -70
  2. package/index.ts +56 -467
  3. package/package.json +2 -2
  4. package/shared.ts +100 -37
  5. package/tui.ts +290 -154
  6. package/LICENSE +0 -201
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.34",
3
+ "version": "0.18.36",
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.34",
51
+ "@landstrip/landstrip": "^0.18.36",
52
52
  "@opentui/solid": ">=0.3.4",
53
53
  "solid-js": "1.9.12"
54
54
  },