opencode-landstrip 0.14.0 → 0.14.2

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 (3) hide show
  1. package/index.ts +57 -12
  2. package/package.json +1 -1
  3. package/tui.ts +17 -12
package/index.ts CHANGED
@@ -58,7 +58,7 @@ interface SandboxPermissionDecision {
58
58
 
59
59
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
60
60
 
61
- const LANDSTRIP_VERSION = [0, 14, 0] as const;
61
+ const LANDSTRIP_VERSION = [0, 14, 5] as const;
62
62
  const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
63
63
  const LANDSTRIP_OPERATIONS = new Set<'read' | 'write'>(['read', 'write']);
64
64
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
@@ -101,6 +101,38 @@ function canonicalizePath(filePath: string, baseDirectory: string): string {
101
101
  }
102
102
  }
103
103
 
104
+ /**
105
+ * Translates an absolute glob pattern to a regular expression using standard
106
+ * path semantics: `**` crosses directory boundaries (and `**​/` may match zero
107
+ * segments), while a single `*` is confined to one path segment.
108
+ */
109
+ function globToRegExp(globPattern: string): RegExp {
110
+ let regex = '';
111
+
112
+ for (let i = 0; i < globPattern.length; i++) {
113
+ const char = globPattern[i];
114
+ if (char === '*') {
115
+ if (globPattern[i + 1] === '*') {
116
+ i++;
117
+ if (globPattern[i + 1] === '/') {
118
+ i++;
119
+ regex += '(?:.*/)?';
120
+ } else {
121
+ regex += '.*';
122
+ }
123
+ } else {
124
+ regex += '[^/]*';
125
+ }
126
+ } else if (/[.+^${}()|[\]\\]/.test(char)) {
127
+ regex += `\\${char}`;
128
+ } else {
129
+ regex += char;
130
+ }
131
+ }
132
+
133
+ return new RegExp(`^${regex}$`);
134
+ }
135
+
104
136
  function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
105
137
  const abs = canonicalizePath(filePath, baseDirectory);
106
138
 
@@ -110,8 +142,7 @@ function matchesPattern(filePath: string, patterns: string[], baseDirectory: str
110
142
  : canonicalizePath(pattern, baseDirectory);
111
143
 
112
144
  if (pattern.includes('*')) {
113
- const escaped = absPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
114
- return new RegExp(`^${escaped}$`).test(abs);
145
+ return globToRegExp(absPattern).test(abs);
115
146
  }
116
147
 
117
148
  const sep = absPattern.endsWith('/') ? '' : '/';
@@ -271,10 +302,6 @@ function evaluateReadPermission(
271
302
  ): SandboxPermissionDecision {
272
303
  const filePath = canonicalizePath(path, baseDirectory);
273
304
 
274
- if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
275
- return { status: 'allow', kind: 'read', resource: filePath, message: '' };
276
- }
277
-
278
305
  if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
279
306
  return {
280
307
  status: 'deny',
@@ -284,6 +311,10 @@ function evaluateReadPermission(
284
311
  };
285
312
  }
286
313
 
314
+ if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
315
+ return { status: 'allow', kind: 'read', resource: filePath, message: '' };
316
+ }
317
+
287
318
  return {
288
319
  status: 'ask',
289
320
  kind: 'read',
@@ -300,10 +331,6 @@ function evaluateWritePermission(
300
331
  ): SandboxPermissionDecision {
301
332
  const filePath = canonicalizePath(path, baseDirectory);
302
333
 
303
- if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
304
- return { status: 'allow', kind: 'write', resource: filePath, message: '' };
305
- }
306
-
307
334
  if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
308
335
  return {
309
336
  status: 'deny',
@@ -313,6 +340,10 @@ function evaluateWritePermission(
313
340
  };
314
341
  }
315
342
 
343
+ if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
344
+ return { status: 'allow', kind: 'write', resource: filePath, message: '' };
345
+ }
346
+
316
347
  return {
317
348
  status: 'ask',
318
349
  kind: 'write',
@@ -325,7 +356,7 @@ function evaluateDomainPermission(
325
356
  domain: string,
326
357
  config: SandboxConfig,
327
358
  ): SandboxPermissionDecision {
328
- if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
359
+ if (config.network.allowNetwork) {
329
360
  return { status: 'allow', kind: 'domain', resource: domain, message: '' };
330
361
  }
331
362
 
@@ -338,6 +369,10 @@ function evaluateDomainPermission(
338
369
  };
339
370
  }
340
371
 
372
+ if (domainMatchesAny(domain, config.network.allowedDomains)) {
373
+ return { status: 'allow', kind: 'domain', resource: domain, message: '' };
374
+ }
375
+
341
376
  return {
342
377
  status: 'ask',
343
378
  kind: 'domain',
@@ -547,10 +582,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
547
582
  return;
548
583
  }
549
584
 
585
+ let connected = false;
550
586
  const upstream = connectNet(endpoint.port, endpoint.host, () => {
587
+ connected = true;
551
588
  client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
552
589
  pipeSockets(client, upstream, rest);
553
590
  });
591
+ upstream.on('error', () => {
592
+ if (!connected) denyProxyRequest(client, '502 Bad Gateway');
593
+ });
554
594
  }
555
595
 
556
596
  async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
@@ -589,10 +629,15 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
589
629
  const rewrittenHeader = lines
590
630
  .filter((line) => !line.toLowerCase().startsWith('proxy-connection:'))
591
631
  .join('\r\n');
632
+ let connected = false;
592
633
  const upstream = connectNet(port, url.hostname, () => {
634
+ connected = true;
593
635
  upstream.write(`${rewrittenHeader}\r\n\r\n`);
594
636
  pipeSockets(client, upstream, rest);
595
637
  });
638
+ upstream.on('error', () => {
639
+ if (!connected) denyProxyRequest(client, '502 Bad Gateway');
640
+ });
596
641
  }
597
642
 
598
643
  function handleClient(client: Socket): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
package/tui.ts CHANGED
@@ -64,7 +64,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
64
64
  `allow write: ${list(config.filesystem.allowWrite)}`,
65
65
  `deny write: ${list(config.filesystem.denyWrite)}`,
66
66
  '',
67
- 'esc or any key to close',
67
+ 'Press esc or enter to close',
68
68
  ].join('\n');
69
69
  }
70
70
 
@@ -118,7 +118,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
118
118
  activeId = undefined;
119
119
  api.ui.dialog.clear();
120
120
  }
121
- pump();
121
+ // Defer: `clear()` above tears the dialog down by calling its `onClose`,
122
+ // and the host pops the stack asynchronously. Opening the next dialog
123
+ // synchronously here would race that teardown and get wiped.
124
+ queueMicrotask(pump);
122
125
  }
123
126
 
124
127
  async function replyPermission(
@@ -212,9 +215,11 @@ const tui: TuiPlugin = async (api, options, meta) => {
212
215
  () => {
213
216
  // Dialog dismissed (esc) without a choice: drop our hold so the next
214
217
  // pending permission can surface, but leave it unresolved upstream.
218
+ // The host pops the dialog itself; calling `clear()` here would re-enter
219
+ // this `onClose` (clear() invokes every entry's onClose) and loop until
220
+ // the stack overflows. Defer `pump()` so the pop settles first.
215
221
  if (activeId === permission.id) activeId = undefined;
216
- api.ui.dialog.clear();
217
- pump();
222
+ queueMicrotask(pump);
218
223
  },
219
224
  );
220
225
  }
@@ -233,14 +238,14 @@ const tui: TuiPlugin = async (api, options, meta) => {
233
238
  const directory = api.state.path.directory || process.cwd();
234
239
  const message = sandboxSummary(directory, optionOverrides);
235
240
 
236
- api.ui.dialog.replace(
237
- () =>
238
- api.ui.DialogAlert({
239
- title: 'Sandbox Configuration',
240
- message,
241
- onConfirm: () => api.ui.dialog.clear(),
242
- }),
243
- () => api.ui.dialog.clear(),
241
+ // No `onConfirm`/`onClose` that call `clear()`: the host already pops the
242
+ // dialog on enter/esc/click, and its `clear()` re-invokes every entry's
243
+ // `onClose`, so a `clear()` in there recurses forever and freezes the TUI.
244
+ api.ui.dialog.replace(() =>
245
+ api.ui.DialogAlert({
246
+ title: 'Sandbox Configuration',
247
+ message,
248
+ }),
244
249
  );
245
250
  };
246
251