pi-landstrip 0.16.29 → 0.17.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.
package/LICENSE CHANGED
@@ -1,21 +1,201 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jarkko Sakkinen
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -72,7 +72,7 @@ Use `/sandbox` inside pi to show the active config and toggle sandboxing.
72
72
 
73
73
  ## License
74
74
 
75
- `pi-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
75
+ `pi-landstrip` is licensed under `Apache-2.0`. See [LICENSE](LICENSE) for more
76
76
  information.
77
77
 
78
78
  The bundled `@landstrip/landstrip` package is licensed separately as
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: MIT
1
+ // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
4
  import { spawn, spawnSync } from 'node:child_process';
@@ -43,7 +43,13 @@ import {
43
43
  withFileMutationQueue,
44
44
  } from '@earendil-works/pi-coding-agent';
45
45
  import { Key, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
46
- import { binaryPath } from '@landstrip/landstrip';
46
+ import {
47
+ binaryPath,
48
+ type LandstripControlResponse,
49
+ type LandstripFilesystemTrap,
50
+ type LandstripNetworkTrap,
51
+ type LandstripTrap,
52
+ } from '@landstrip/landstrip';
47
53
 
48
54
  interface SandboxFilesystemConfig {
49
55
  denyRead: string[];
@@ -92,12 +98,7 @@ interface LandstripPolicy {
92
98
 
93
99
  type LandstripOperation = 'read' | 'write';
94
100
 
95
- type LandstripTrap =
96
- | { kind: 'filesystem'; operation: LandstripOperation; file: string; mechanism: string }
97
- | { kind: 'network'; operation: string; target: string; mechanism: string }
98
- | { kind: 'launch'; program: string; source: string }
99
- | { kind: 'usage'; message: string }
100
- | { kind: 'internal'; detail: Record<string, string> };
101
+ type LandstripDenialTrap = LandstripFilesystemTrap | LandstripNetworkTrap;
101
102
 
102
103
  interface LandstripBashCallbacks {
103
104
  onStderr?: (data: Buffer) => void;
@@ -107,6 +108,10 @@ interface LandstripBashCallbacks {
107
108
 
108
109
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
109
110
 
111
+ // Grace period after the child exits for its stdio to drain before we stop
112
+ // waiting; matches pi's own bash backend so a backgrounded process cannot hang us.
113
+ const EXIT_STDIO_GRACE_MS = 100;
114
+
110
115
  const packageDir = dirname(fileURLToPath(import.meta.url));
111
116
  type PermissionChoice = 'abort' | 'session' | 'project' | 'global';
112
117
  type NotificationLevel = Parameters<ExtensionContext['ui']['notify']>[1];
@@ -138,6 +143,11 @@ const PERMISSION_OPTIONS: PromptOption[] = [
138
143
  },
139
144
  ];
140
145
 
146
+ const NETWORK_PERMISSION_OPTIONS: PromptOption[] = [
147
+ { label: 'Allow for this session only', key: 's', action: 'session' },
148
+ { label: 'Abort (keep blocked)', key: 'esc', action: 'abort' },
149
+ ];
150
+
141
151
  function loadConfig(cwd: string): SandboxConfig {
142
152
  const projectConfigPath = join(cwd, '.pi', 'sandbox.json');
143
153
  const globalConfigPath = join(getAgentDir(), 'sandbox.json');
@@ -294,8 +304,10 @@ function extractDomainsFromCommand(command: string): string[] {
294
304
  }
295
305
 
296
306
  function domainMatchesPattern(domain: string, pattern: string): boolean {
297
- const normalizedDomain = domain.toLowerCase();
298
- const normalizedPattern = pattern.toLowerCase();
307
+ // A trailing dot ("pastebin.com.") is the same host to DNS but would slip past
308
+ // a literal deny entry; strip it from both sides before matching.
309
+ const normalizedDomain = domain.toLowerCase().replace(/\.+$/, '');
310
+ const normalizedPattern = pattern.toLowerCase().replace(/\.+$/, '');
299
311
 
300
312
  if (normalizedPattern === '*') return true;
301
313
  if (normalizedPattern.startsWith('*.')) {
@@ -306,7 +318,7 @@ function domainMatchesPattern(domain: string, pattern: string): boolean {
306
318
  return normalizedDomain === normalizedPattern;
307
319
  }
308
320
 
309
- function domainMatchesAny(domain: string, patterns: string[]): boolean {
321
+ export function domainMatchesAny(domain: string, patterns: string[]): boolean {
310
322
  return patterns.some((pattern) => domainMatchesPattern(domain, pattern));
311
323
  }
312
324
 
@@ -360,9 +372,14 @@ export function matchesPattern(filePath: string, patterns: string[], cwd: string
360
372
  : canonicalizePath(pattern, cwd);
361
373
 
362
374
  if (pattern.includes('*')) {
375
+ // Mirror landstrip's matcher: `**/` spans directories, `**` spans any run,
376
+ // but a single `*` stops at `/` — so `/srv/*/pub` cannot reach
377
+ // `/srv/a/secret/pub`. Compiling `*` to `.*` would over-match across `/`.
363
378
  const escaped = absPattern
364
379
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
365
- .replace(/\*\*\/|\*\*|\*/g, (token) => (token === '**/' ? '(?:.*/)?' : '.*'));
380
+ .replace(/\*\*\/|\*\*|\*/g, (token) =>
381
+ token === '**/' ? '(?:.*/)?' : token === '**' ? '.*' : '[^/]*',
382
+ );
366
383
  return new RegExp(`^${escaped}$`).test(abs);
367
384
  }
368
385
 
@@ -463,19 +480,34 @@ function normalizePathMatch(value: string, cwd: string): string | null {
463
480
  return isPathLike(value) ? normalizeBlockedPath(value, cwd) : null;
464
481
  }
465
482
 
466
- type LandstripFilesystemTrap = Extract<LandstripTrap, { kind: 'filesystem' }>;
467
-
468
483
  function isFilesystemTrap(trap: LandstripTrap): trap is LandstripFilesystemTrap {
469
484
  return trap.kind === 'filesystem';
470
485
  }
471
486
 
472
- function extractBlockedPath(output: string, cwd: string): string | null {
473
- const landstripErrors = parseLandstripTraps(output).filter(isFilesystemTrap);
487
+ // filesystem and network traps report an access the policy denied; launch, usage
488
+ // and internal traps report that landstrip itself failed.
489
+ function isDenialTrap(trap: LandstripTrap): trap is LandstripDenialTrap {
490
+ return trap.kind === 'filesystem' || trap.kind === 'network';
491
+ }
492
+
493
+ // A `state: "query"` trap suspends the child's syscall until we answer it on the
494
+ // trap socket. An `info` trap is terminal and carries a `query_id` of "0".
495
+ export function isQueryTrap(trap: LandstripTrap): trap is LandstripDenialTrap {
496
+ return isDenialTrap(trap) && trap.state === 'query';
497
+ }
498
+
499
+ // Structured traps come only from the trap socket (fd 3); the sandboxed command
500
+ // controls its own stderr and could forge a trap line, so these `extractBlocked*`
501
+ // helpers must be fed only that trusted channel. Agent-controlled stderr is read
502
+ // with the `extractNative*` regexes instead, which match a real kernel-denial
503
+ // message rather than a JSON record.
504
+ function extractBlockedPath(trapOutput: string, cwd: string): string | null {
505
+ const landstripErrors = parseLandstripTraps(trapOutput).filter(isFilesystemTrap);
474
506
  if (landstripErrors.length > 0) {
475
- return normalizeBlockedPath(landstripErrors[0].file, cwd);
507
+ return normalizeBlockedPath(landstripErrors[0].path, cwd);
476
508
  }
477
509
 
478
- return extractNativeDeniedPath(output, cwd);
510
+ return null;
479
511
  }
480
512
 
481
513
  function extractNativeDeniedPath(output: string, cwd: string): string | null {
@@ -522,75 +554,66 @@ function extractNativeWriteDeniedPath(output: string, cwd: string): string | nul
522
554
  return null;
523
555
  }
524
556
 
525
- function extractBlockedWritePath(output: string, cwd: string): string | null {
526
- for (const error of parseLandstripTraps(output).filter(isFilesystemTrap)) {
557
+ function extractBlockedWritePath(trapOutput: string, cwd: string): string | null {
558
+ for (const error of parseLandstripTraps(trapOutput).filter(isFilesystemTrap)) {
527
559
  if (error.operation === 'write') {
528
- return normalizeBlockedPath(error.file, cwd);
560
+ return normalizeBlockedPath(error.path, cwd);
529
561
  }
530
562
  }
531
563
 
532
- return extractNativeWriteDeniedPath(output, cwd);
564
+ return null;
533
565
  }
534
566
 
535
- function extractBlockedReadPath(output: string, cwd: string): string | null {
536
- for (const error of parseLandstripTraps(output).filter(isFilesystemTrap)) {
567
+ function extractBlockedReadPath(trapOutput: string, cwd: string): string | null {
568
+ for (const error of parseLandstripTraps(trapOutput).filter(isFilesystemTrap)) {
537
569
  if (error.operation === 'read') {
538
- return normalizeBlockedPath(error.file, cwd);
570
+ return normalizeBlockedPath(error.path, cwd);
539
571
  }
540
572
  }
541
573
 
542
- return extractNativeDeniedPath(output, cwd);
574
+ return null;
543
575
  }
544
576
 
545
- function asString(value: unknown): string | null {
546
- return typeof value === 'string' ? value : null;
547
- }
577
+ // landstrip emits each trap as a flat JSON record tagged by a `kind` discriminant
578
+ // (`filesystem`, `network`, `launch`, `usage`, `internal`) alongside a stable
579
+ // `code` and variant-specific fields. The declarations it ships are erased at
580
+ // compile time, so validate the fields this extension reads before trusting a
581
+ // decoded line.
582
+ function isLandstripTrap(value: unknown): value is LandstripTrap {
583
+ if (typeof value !== 'object' || value === null) return false;
548
584
 
549
- // landstrip emits each trap as a flat JSON record tagged by a `kind`
550
- // discriminant (`filesystem`, `network`, `launch`, `usage`, `internal`)
551
- // alongside a stable `code` and variant-specific fields.
552
- function parseLandstripTrap(obj: Record<string, unknown>): LandstripTrap | null {
585
+ const obj = value as Record<string, unknown>;
553
586
  switch (obj.kind) {
554
- case 'filesystem': {
555
- const operation = obj.operation;
556
- const file = asString(obj.path);
557
- if ((operation === 'read' || operation === 'write') && file !== null) {
558
- return { kind: 'filesystem', operation, file, mechanism: asString(obj.mechanism) ?? '' };
559
- }
560
- return null;
561
- }
562
- case 'network': {
563
- const operation = asString(obj.operation);
564
- const target = asString(obj.target);
565
- if (operation !== null && target !== null) {
566
- return { kind: 'network', operation, target, mechanism: asString(obj.mechanism) ?? '' };
567
- }
568
- return null;
569
- }
570
- case 'launch': {
571
- const program = asString(obj.program);
572
- const source = asString(obj.message);
573
- if (program !== null && source !== null) {
574
- return { kind: 'launch', program, source };
575
- }
576
- return null;
577
- }
578
- case 'usage': {
579
- const message = asString(obj.message);
580
- return message !== null ? { kind: 'usage', message } : null;
581
- }
582
- case 'internal': {
583
- const detail: Record<string, string> = {};
584
- const raw = obj.detail;
585
- if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
586
- for (const [key, value] of Object.entries(raw)) {
587
- if (typeof value === 'string') detail[key] = value;
588
- }
589
- }
590
- return { kind: 'internal', detail };
591
- }
587
+ case 'filesystem':
588
+ return (
589
+ (obj.operation === 'read' || obj.operation === 'write') &&
590
+ typeof obj.path === 'string' &&
591
+ typeof obj.query_id === 'string'
592
+ );
593
+ case 'network':
594
+ return (
595
+ typeof obj.operation === 'string' &&
596
+ typeof obj.target === 'string' &&
597
+ typeof obj.query_id === 'string'
598
+ );
599
+ case 'launch':
600
+ return typeof obj.program === 'string' && typeof obj.message === 'string';
601
+ case 'usage':
602
+ return typeof obj.message === 'string';
603
+ case 'internal':
604
+ return typeof obj.code === 'string' && typeof obj.message === 'string';
592
605
  default:
593
- return null;
606
+ return false;
607
+ }
608
+ }
609
+
610
+ export function parseTrapLine(line: string): LandstripTrap | null {
611
+ try {
612
+ const parsed: unknown = JSON.parse(line);
613
+ return isLandstripTrap(parsed) ? parsed : null;
614
+ } catch {
615
+ // Ignore non-JSON lines (e.g. stderr from child processes)
616
+ return null;
594
617
  }
595
618
  }
596
619
 
@@ -598,44 +621,45 @@ function parseLandstripTraps(output: string): LandstripTrap[] {
598
621
  const traps: LandstripTrap[] = [];
599
622
 
600
623
  for (const line of output.trim().split('\n')) {
601
- if (line.length === 0) continue;
602
-
603
- try {
604
- const parsed: unknown = JSON.parse(line);
605
- if (typeof parsed !== 'object' || parsed === null) continue;
606
- const trap = parseLandstripTrap(parsed as Record<string, unknown>);
607
- if (trap) traps.push(trap);
608
- } catch {
609
- // Ignore non-JSON lines (e.g. stderr from child processes)
610
- }
624
+ const trap = parseTrapLine(line);
625
+ if (trap) traps.push(trap);
611
626
  }
612
627
 
613
628
  return traps;
614
629
  }
615
630
 
616
- function formatLandstripTraps(traps: LandstripTrap[]): string {
631
+ export function formatLandstripTraps(traps: LandstripTrap[]): string {
617
632
  return traps
618
633
  .map((trap) => {
619
634
  switch (trap.kind) {
620
635
  case 'filesystem':
621
- return `landstrip: filesystem ${trap.operation} denied: ${trap.file} (${trap.mechanism})`;
636
+ return `landstrip: filesystem ${trap.operation} denied: ${trap.path} (${trap.mechanism})`;
622
637
  case 'network':
623
638
  return `landstrip: network ${trap.operation} denied: ${trap.target} (${trap.mechanism})`;
624
639
  case 'launch':
625
- return `landstrip: launch failed: ${trap.program}: ${trap.source}`;
640
+ return `landstrip: launch failed: ${trap.program}: ${trap.message}`;
626
641
  case 'usage':
627
642
  return `landstrip: usage error: ${trap.message}`;
628
643
  case 'internal': {
629
- const detail = Object.entries(trap.detail)
630
- .map(([key, value]) => `${key}=${value}`)
631
- .join(', ');
632
- return detail ? `landstrip: internal error: ${detail}` : 'landstrip: internal error';
644
+ const mechanism = trap.mechanism ? ` (${trap.mechanism})` : '';
645
+ return `landstrip: ${trap.code}${mechanism}: ${trap.message}`;
633
646
  }
634
647
  }
635
648
  })
636
649
  .join('\n');
637
650
  }
638
651
 
652
+ // The broker matches an answer to its query by the exact decimal `query_id`
653
+ // string the trap carried. A numeric id fails its deserializer, the line is
654
+ // dropped, and the child's syscall stays suspended.
655
+ export function controlResponseLine(
656
+ queryId: string,
657
+ action: LandstripControlResponse['action'],
658
+ ): string {
659
+ const response: LandstripControlResponse = { query_id: queryId, action };
660
+ return JSON.stringify(response) + '\n';
661
+ }
662
+
639
663
  function notify(ctx: ExtensionContext, message: string, level: NotificationLevel): void {
640
664
  if (!ctx.hasUI) return;
641
665
  ctx.ui.notify(message, level);
@@ -787,11 +811,8 @@ async function showPermissionPrompt(
787
811
  for (let i = 0; i < options.length; i++) {
788
812
  const option = options[i];
789
813
 
790
- if (data === option.key) {
791
- resolveChoice(option.action);
792
- return;
793
- }
794
-
814
+ // Match case-insensitively so a `confirm` option always arms its
815
+ // two-step gate; an exact-key shortcut here would skip it.
795
816
  if (data.toLowerCase() === option.key.toLowerCase()) {
796
817
  if (option.confirm) {
797
818
  pendingAction = option.action;
@@ -848,6 +869,22 @@ function promptWriteBlock(ctx: ExtensionContext, filePath: string): Promise<Perm
848
869
  );
849
870
  }
850
871
 
872
+ // The broker knows only address:port, and no sandbox.json field can express a
873
+ // grant for one: allowedDomains is a hostname list enforced by the proxy, and the
874
+ // landstrip network policy is all-or-nothing (allowNetwork, allowLocalBinding).
875
+ // So a connection is granted for the session or not at all.
876
+ function promptNetworkBlock(
877
+ ctx: ExtensionContext,
878
+ operation: string,
879
+ target: string,
880
+ ): Promise<PermissionChoice> {
881
+ return showPermissionPrompt(
882
+ ctx,
883
+ `Network blocked: ${operation} to "${target}"`,
884
+ NETWORK_PERMISSION_OPTIONS,
885
+ );
886
+ }
887
+
851
888
  // The binary is bundled and version-locked to @landstrip/landstrip via npm, so
852
889
  // compatibility is settled at install time; only confirm it is runnable here.
853
890
  function landstripAvailable(): boolean {
@@ -869,6 +906,11 @@ export function writeEnvFile(
869
906
  const lines: string[] = [];
870
907
  for (const [key, value] of Object.entries(env)) {
871
908
  if (value === undefined) continue;
909
+ // bash exports non-identifier names (e.g. a `BASH_FUNC_foo%%` function
910
+ // export) that `export NAME=...` rejects; emitting one makes `source` exit
911
+ // non-zero and the `&&`-chained command never runs. Skip what the shell
912
+ // itself cannot set.
913
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
872
914
  // Escape single quotes: ' -> '\''
873
915
  const escaped = value.replace(/'/g, "'\\''");
874
916
  lines.push(`export ${key}='${escaped}'`);
@@ -974,11 +1016,13 @@ export function createLandstripIntegration(
974
1016
  const sessionAllowedDomains: string[] = [];
975
1017
  const sessionAllowedReadPaths: string[] = [];
976
1018
  const sessionAllowedWritePaths: string[] = [];
1019
+ const sessionAllowedTargets: string[] = [];
977
1020
 
978
1021
  function resetSessionAllowances(): void {
979
1022
  sessionAllowedDomains.length = 0;
980
1023
  sessionAllowedReadPaths.length = 0;
981
1024
  sessionAllowedWritePaths.length = 0;
1025
+ sessionAllowedTargets.length = 0;
982
1026
  }
983
1027
 
984
1028
  function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
@@ -1032,6 +1076,31 @@ export function createLandstripIntegration(
1032
1076
  noteScope(ctx, 'Read', choice, filePath, scope);
1033
1077
  }
1034
1078
 
1079
+ // Gate a read of `filePath` against allowRead/denyRead, prompting when blocked.
1080
+ // Returns a block result when the user aborts, otherwise undefined once access
1081
+ // is settled. Shared by the read, grep, ls and find tools.
1082
+ async function gateReadAccess(
1083
+ ctx: ExtensionContext,
1084
+ config: SandboxConfig,
1085
+ filePath: string,
1086
+ ): Promise<{ block: true; reason: string } | undefined> {
1087
+ if (readAllowed(filePath, getEffectiveAllowRead(config), config.filesystem.denyRead, ctx.cwd)) {
1088
+ return undefined;
1089
+ }
1090
+ const choice = await promptReadBlock(
1091
+ ctx,
1092
+ filePath,
1093
+ matchesPattern(filePath, config.filesystem.denyRead, ctx.cwd)
1094
+ ? 'granting allowRead will override it'
1095
+ : undefined,
1096
+ );
1097
+ if (choice === 'abort') {
1098
+ return { block: true, reason: `Sandbox: read access denied for "${filePath}"` };
1099
+ }
1100
+ await applyReadChoice(ctx, choice, filePath, ctx.cwd);
1101
+ return undefined;
1102
+ }
1103
+
1035
1104
  async function applyWriteChoice(
1036
1105
  ctx: ExtensionContext,
1037
1106
  choice: Exclude<PermissionChoice, 'abort'>,
@@ -1104,6 +1173,17 @@ export function createLandstripIntegration(
1104
1173
  return domainMatchesAny(domain, getEffectiveAllowedDomains(config));
1105
1174
  }
1106
1175
 
1176
+ // Track the upstream socket so stop() tears it down, and abandon a still-
1177
+ // connecting upstream when its client goes away — otherwise a connect to a
1178
+ // black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
1179
+ function trackUpstream(upstream: Socket, client: Socket, settled: () => boolean): void {
1180
+ sockets.add(upstream);
1181
+ upstream.once('close', () => sockets.delete(upstream));
1182
+ client.once('close', () => {
1183
+ if (!settled()) upstream.destroy();
1184
+ });
1185
+ }
1186
+
1107
1187
  async function handleConnect(client: Socket, target: string, rest: Buffer): Promise<void> {
1108
1188
  const endpoint = splitHostPort(target, 443);
1109
1189
  if (!endpoint || !Number.isFinite(endpoint.port)) {
@@ -1122,6 +1202,7 @@ export function createLandstripIntegration(
1122
1202
  client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
1123
1203
  pipeSockets(client, upstream, rest);
1124
1204
  });
1205
+ trackUpstream(upstream, client, () => settled);
1125
1206
  upstream.once('error', () => {
1126
1207
  if (settled) return;
1127
1208
  settled = true;
@@ -1182,6 +1263,7 @@ export function createLandstripIntegration(
1182
1263
  upstream.write(`${rewrittenHeader}\r\n\r\n`);
1183
1264
  pipeSockets(client, upstream, rest);
1184
1265
  });
1266
+ trackUpstream(upstream, client, () => settled);
1185
1267
  upstream.once('error', () => {
1186
1268
  if (settled) return;
1187
1269
  settled = true;
@@ -1281,10 +1363,28 @@ export function createLandstripIntegration(
1281
1363
  const config = loadConfig(cwd);
1282
1364
  const allowNetwork = config.network.allowNetwork;
1283
1365
  const proxy = allowNetwork ? null : await startProxy(cwd);
1284
- const policy = writePolicyFile(cwd, proxy?.port ?? null);
1285
- const envFile = writeEnvFile({ ...process.env, ...env }, proxy?.port ?? null);
1286
- const wrappedCommand = `source '${envFile.path}' && ${command}`;
1287
- const landstripArgs = ['--trap-fd', '3', '-p', policy.path, shell, ...args, wrappedCommand];
1366
+
1367
+ // Started/created before the child exists, so tear them down on any early
1368
+ // failure too the env file holds a copy of the environment (secrets),
1369
+ // and the proxy keeps a listening socket. Idempotent: safe to call twice.
1370
+ let policy: ReturnType<typeof writePolicyFile> | undefined;
1371
+ let envFile: ReturnType<typeof writeEnvFile> | undefined;
1372
+ const teardownResources = () => {
1373
+ void proxy?.stop();
1374
+ if (policy) rmSync(policy.dir, { recursive: true, force: true });
1375
+ if (envFile) rmSync(envFile.dir, { recursive: true, force: true });
1376
+ };
1377
+
1378
+ let landstripArgs: string[];
1379
+ try {
1380
+ policy = writePolicyFile(cwd, proxy?.port ?? null);
1381
+ envFile = writeEnvFile({ ...process.env, ...env }, proxy?.port ?? null);
1382
+ const wrappedCommand = `source '${envFile.path}' && ${command}`;
1383
+ landstripArgs = ['--trap-fd', '3', '-p', policy.path, shell, ...args, wrappedCommand];
1384
+ } catch (error) {
1385
+ teardownResources();
1386
+ throw error;
1387
+ }
1288
1388
 
1289
1389
  return new Promise((resolvePromise, reject) => {
1290
1390
  (async () => {
@@ -1299,10 +1399,8 @@ export function createLandstripIntegration(
1299
1399
  cleaned = true;
1300
1400
  if (timeoutHandle) clearTimeout(timeoutHandle);
1301
1401
  signal?.removeEventListener('abort', onAbort);
1302
- void proxy?.stop();
1402
+ teardownResources();
1303
1403
  trapSocket.destroy();
1304
- rmSync(policy.dir, { recursive: true, force: true });
1305
- rmSync(envFile.dir, { recursive: true, force: true });
1306
1404
  };
1307
1405
 
1308
1406
  const child = spawn(binaryPath(), landstripArgs, {
@@ -1339,18 +1437,85 @@ export function createLandstripIntegration(
1339
1437
  let stderrAcc = '';
1340
1438
  let errorFdAcc = '';
1341
1439
 
1440
+ let execSettled = false;
1441
+ let childExited = false;
1442
+ let childExitCode: number | null = null;
1443
+ let postExitTimer: NodeJS.Timeout | undefined;
1444
+ let stdoutEnded = child.stdout === null;
1445
+ let stderrEnded = child.stderr === null;
1446
+
1447
+ const finalizeExec = (code: number | null): void => {
1448
+ if (execSettled) return;
1449
+ execSettled = true;
1450
+ if (postExitTimer) clearTimeout(postExitTimer);
1451
+ // Stop tracking the inherited pipes: a backgrounded grandchild can
1452
+ // hold them open after the command itself exits, so 'close' would
1453
+ // otherwise never arrive.
1454
+ child.stdout?.destroy();
1455
+ child.stderr?.destroy();
1456
+ void (async () => {
1457
+ cleanup();
1458
+ if (signal?.aborted) {
1459
+ reject(new Error('aborted'));
1460
+ return;
1461
+ }
1462
+ if (timedOut) {
1463
+ reject(new Error(`timeout:${timeout}`));
1464
+ return;
1465
+ }
1466
+
1467
+ // Structured traps are trusted only from the trap socket (fd 3);
1468
+ // the command's own stderr is read with the native regexes, which
1469
+ // reflect a real kernel denial rather than a forgeable JSON line.
1470
+ const blockedPath =
1471
+ extractBlockedPath(errorFdAcc, cwd) ?? extractNativeDeniedPath(stderrAcc, cwd);
1472
+ if (!blockedPath && ctx.hasUI) {
1473
+ const traps = parseLandstripTraps(errorFdAcc);
1474
+ const denials = traps.filter(isDenialTrap);
1475
+ if (denials.length > 0) {
1476
+ const formatted = formatLandstripTraps(denials);
1477
+ notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1478
+ }
1479
+
1480
+ const failures = traps.filter((trap) => !isDenialTrap(trap));
1481
+ if (failures.length > 0) {
1482
+ notify(ctx, `Sandbox failed: ${formatLandstripTraps(failures)}`, 'error');
1483
+ }
1484
+ }
1485
+
1486
+ resolvePromise({ exitCode: code });
1487
+ })().catch(reject);
1488
+ };
1489
+
1490
+ const maybeFinalizeAfterExit = (): void => {
1491
+ if (childExited && !execSettled && stdoutEnded && stderrEnded) {
1492
+ finalizeExec(childExitCode);
1493
+ }
1494
+ };
1495
+
1342
1496
  child.stdout?.on('data', onData);
1343
1497
  child.stderr?.on('data', (data: Buffer) => {
1344
1498
  stderrAcc += data.toString('utf8');
1345
1499
  callbacks.onStderr?.(data);
1346
1500
  onData(data);
1347
1501
  });
1502
+ child.stdout?.once('end', () => {
1503
+ stdoutEnded = true;
1504
+ maybeFinalizeAfterExit();
1505
+ });
1506
+ child.stderr?.once('end', () => {
1507
+ stderrEnded = true;
1508
+ maybeFinalizeAfterExit();
1509
+ });
1348
1510
  let trapBuffer = '';
1349
1511
  let queryChain: Promise<void> = Promise.resolve();
1350
1512
 
1351
- const respondQuery = (queryId: number, action: 'allow' | 'deny'): void => {
1513
+ const respondQuery = (
1514
+ queryId: string,
1515
+ action: LandstripControlResponse['action'],
1516
+ ): void => {
1352
1517
  if (trapSocket.destroyed) return;
1353
- trapSocket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
1518
+ trapSocket.write(controlResponseLine(queryId, action));
1354
1519
  };
1355
1520
 
1356
1521
  // Surface a denial through the error-fd accumulator so the post-close
@@ -1364,7 +1529,7 @@ export function createLandstripIntegration(
1364
1529
  // Answer a landstrip query (state:"query"). The broker suspends the
1365
1530
  // child's syscall until we respond allow/deny on the trap socket.
1366
1531
  const handleQuery = (
1367
- queryId: number,
1532
+ queryId: string,
1368
1533
  operation: LandstripOperation,
1369
1534
  rawPath: string,
1370
1535
  rawLine: string,
@@ -1422,6 +1587,42 @@ export function createLandstripIntegration(
1422
1587
  .catch(() => respondQuery(queryId, 'deny'));
1423
1588
  };
1424
1589
 
1590
+ // A denied connect or bind is a query too, and the broker re-issues it
1591
+ // itself once we allow. The target is address:port, so a grant only
1592
+ // lasts for the session (see promptNetworkBlock).
1593
+ const handleNetworkQuery = (
1594
+ queryId: string,
1595
+ operation: string,
1596
+ target: string,
1597
+ rawLine: string,
1598
+ ): void => {
1599
+ if (sessionAllowedTargets.includes(target)) {
1600
+ respondQuery(queryId, 'allow');
1601
+ return;
1602
+ }
1603
+ if (!ctx.hasUI || !callbacks.promptOnBlock) {
1604
+ appendErrorLine(rawLine);
1605
+ respondQuery(queryId, 'deny');
1606
+ return;
1607
+ }
1608
+ queryChain = queryChain
1609
+ .then(async () => {
1610
+ if (sessionAllowedTargets.includes(target)) {
1611
+ respondQuery(queryId, 'allow');
1612
+ return;
1613
+ }
1614
+ const choice = await promptNetworkBlock(ctx, operation, target);
1615
+ if (choice === 'abort') {
1616
+ appendErrorLine(rawLine);
1617
+ respondQuery(queryId, 'deny');
1618
+ return;
1619
+ }
1620
+ sessionAllowedTargets.push(target);
1621
+ respondQuery(queryId, 'allow');
1622
+ })
1623
+ .catch(() => respondQuery(queryId, 'deny'));
1624
+ };
1625
+
1425
1626
  trapSocket.on('data', (data: Buffer) => {
1426
1627
  trapBuffer += data.toString('utf8');
1427
1628
  let nl = trapBuffer.indexOf('\n');
@@ -1430,66 +1631,49 @@ export function createLandstripIntegration(
1430
1631
  trapBuffer = trapBuffer.slice(nl + 1);
1431
1632
  nl = trapBuffer.indexOf('\n');
1432
1633
  if (line.length === 0) continue;
1433
- let obj: Record<string, unknown> | null = null;
1434
- try {
1435
- const parsed: unknown = JSON.parse(line);
1436
- if (typeof parsed === 'object' && parsed !== null) {
1437
- obj = parsed as Record<string, unknown>;
1634
+ const trap = parseTrapLine(line);
1635
+ // An unanswered query holds the child's syscall, so every query
1636
+ // gets an answer; anything else is informational and kept for
1637
+ // post-close handling.
1638
+ if (trap && isQueryTrap(trap)) {
1639
+ if (isFilesystemTrap(trap)) {
1640
+ handleQuery(trap.query_id, trap.operation, trap.path, line);
1641
+ } else {
1642
+ handleNetworkQuery(trap.query_id, trap.operation, trap.target, line);
1438
1643
  }
1439
- } catch {
1440
- obj = null;
1441
- }
1442
- if (
1443
- obj &&
1444
- obj.state === 'query' &&
1445
- typeof obj.query_id === 'number' &&
1446
- (obj.operation === 'read' || obj.operation === 'write') &&
1447
- typeof obj.path === 'string'
1448
- ) {
1449
- handleQuery(obj.query_id, obj.operation, obj.path, line);
1450
1644
  } else {
1451
- // Informational trap (network denials, etc.): keep for post-close handling.
1452
1645
  appendErrorLine(line);
1453
1646
  }
1454
1647
  }
1455
1648
  });
1456
1649
 
1457
1650
  child.on('error', (error) => {
1651
+ if (execSettled) return;
1652
+ execSettled = true;
1653
+ if (postExitTimer) clearTimeout(postExitTimer);
1458
1654
  cleanup();
1459
1655
  reject(error);
1460
1656
  });
1461
1657
 
1462
- child.on('close', (code) => {
1463
- void (async () => {
1464
- cleanup();
1465
- if (signal?.aborted) {
1466
- reject(new Error('aborted'));
1467
- return;
1468
- }
1469
- if (timedOut) {
1470
- reject(new Error(`timeout:${timeout}`));
1471
- return;
1472
- }
1473
-
1474
- const errorOutput = errorFdAcc || stderrAcc;
1475
-
1476
- // Filesystem denials are now answered live during execution; only
1477
- // informational traps (network, etc.) remain to surface here.
1478
- const blockedPath =
1479
- extractBlockedPath(errorOutput, cwd) ??
1480
- (errorFdAcc ? extractBlockedPath(stderrAcc, cwd) : null);
1481
- if (!blockedPath && ctx.hasUI) {
1482
- const landstripErrors = parseLandstripTraps(errorOutput);
1483
- if (landstripErrors.length > 0) {
1484
- const formatted = formatLandstripTraps(landstripErrors);
1485
- notify(ctx, `Sandbox blocked an operation: ${formatted}`, 'warning');
1486
- }
1487
- }
1488
-
1489
- resolvePromise({ exitCode: code });
1490
- })().catch(reject);
1658
+ // Settle on 'exit' the command itself has ended — rather than
1659
+ // waiting for 'close', which only fires once every inherited stdio
1660
+ // pipe is closed. A backgrounded process holding one open would
1661
+ // otherwise block us indefinitely.
1662
+ child.once('exit', (code) => {
1663
+ childExited = true;
1664
+ childExitCode = code;
1665
+ maybeFinalizeAfterExit();
1666
+ if (!execSettled) {
1667
+ postExitTimer = setTimeout(() => finalizeExec(code), EXIT_STDIO_GRACE_MS);
1668
+ }
1491
1669
  });
1492
- })().catch(reject);
1670
+ child.once('close', (code) => finalizeExec(code));
1671
+ })().catch((error: unknown) => {
1672
+ // A failure before the child is wired (e.g. createSocketPair) never
1673
+ // reaches cleanup(); free the proxy and temp dirs here too.
1674
+ teardownResources();
1675
+ reject(error);
1676
+ });
1493
1677
  });
1494
1678
  },
1495
1679
  };
@@ -1598,7 +1782,7 @@ export function createLandstripIntegration(
1598
1782
  const fallbackOutput = `${stderrOutput}\n${errorText}`;
1599
1783
  const blockedWritePath =
1600
1784
  extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1601
- extractBlockedWritePath(fallbackOutput, ctx.cwd);
1785
+ extractNativeWriteDeniedPath(fallbackOutput, ctx.cwd);
1602
1786
  if (blockedWritePath) {
1603
1787
  const retryResult = await retryWithWriteAccess(blockedWritePath);
1604
1788
  if (retryResult) return retryResult;
@@ -1606,13 +1790,13 @@ export function createLandstripIntegration(
1606
1790
 
1607
1791
  const blockedReadPath =
1608
1792
  extractBlockedReadPath(landstripErrorOutput, ctx.cwd) ??
1609
- extractBlockedReadPath(fallbackOutput, ctx.cwd);
1793
+ extractNativeDeniedPath(fallbackOutput, ctx.cwd);
1610
1794
  if (blockedReadPath) {
1611
1795
  const retryResult = await retryWithReadAccess(blockedReadPath);
1612
1796
  if (retryResult) return retryResult;
1613
1797
  }
1614
1798
 
1615
- const landstripErrors = parseLandstripTraps(landstripErrorOutput || errorText);
1799
+ const landstripErrors = parseLandstripTraps(landstripErrorOutput);
1616
1800
  if (landstripErrors.length > 0) {
1617
1801
  throw new Error(formatLandstripTraps(landstripErrors));
1618
1802
  }
@@ -1625,7 +1809,7 @@ export function createLandstripIntegration(
1625
1809
  }
1626
1810
  const blockedWritePath =
1627
1811
  extractBlockedWritePath(landstripErrorOutput, ctx.cwd) ??
1628
- extractBlockedWritePath(stderrOutput, ctx.cwd);
1812
+ extractNativeWriteDeniedPath(stderrOutput, ctx.cwd);
1629
1813
  if (blockedWritePath) {
1630
1814
  const retryResult = await retryWithWriteAccess(blockedWritePath);
1631
1815
  if (retryResult) return retryResult;
@@ -1633,7 +1817,7 @@ export function createLandstripIntegration(
1633
1817
 
1634
1818
  const blockedReadPath =
1635
1819
  extractBlockedReadPath(landstripErrorOutput, ctx.cwd) ??
1636
- extractBlockedReadPath(stderrOutput, ctx.cwd);
1820
+ extractNativeDeniedPath(stderrOutput, ctx.cwd);
1637
1821
  if (!blockedReadPath) return result;
1638
1822
 
1639
1823
  const retryResult = await retryWithReadAccess(blockedReadPath);
@@ -1690,20 +1874,37 @@ export function createLandstripIntegration(
1690
1874
  setTuiStatus(ctx, 'sandbox', `${dot} ${label} ${sep} ${net} ${sep} ${write}`);
1691
1875
  }
1692
1876
 
1877
+ const headlessWarnings = new Set<string>();
1878
+
1879
+ // When sandboxing cannot be applied, bash falls back to running unsandboxed.
1880
+ // notify() is a no-op without a UI, so in headless/RPC mode also print to
1881
+ // stderr (once per message) rather than failing open silently.
1882
+ function warnUnsandboxed(ctx: ExtensionContext, message: string, level: NotificationLevel): void {
1883
+ notify(ctx, message, level);
1884
+ if (!ctx.hasUI && !headlessWarnings.has(message)) {
1885
+ headlessWarnings.add(message);
1886
+ console.error(`pi-landstrip: ${message}`);
1887
+ }
1888
+ }
1889
+
1693
1890
  function enableSandbox(ctx: ExtensionContext): boolean {
1694
1891
  const config = loadConfig(ctx.cwd);
1695
1892
 
1696
1893
  if (!SUPPORTED_PLATFORMS.has(process.platform)) {
1697
1894
  sandboxEnabled = false;
1698
1895
  sandboxReady = false;
1699
- notify(ctx, `landstrip sandboxing is not supported on ${process.platform}`, 'warning');
1896
+ warnUnsandboxed(
1897
+ ctx,
1898
+ `landstrip sandboxing is not supported on ${process.platform}`,
1899
+ 'warning',
1900
+ );
1700
1901
  return false;
1701
1902
  }
1702
1903
 
1703
1904
  if (!landstripAvailable()) {
1704
1905
  sandboxEnabled = false;
1705
1906
  sandboxReady = false;
1706
- notify(
1907
+ warnUnsandboxed(
1707
1908
  ctx,
1708
1909
  `landstrip was not found. Reinstall with: npm install @landstrip/landstrip`,
1709
1910
  'error',
@@ -1813,17 +2014,28 @@ export function createLandstripIntegration(
1813
2014
  }
1814
2015
 
1815
2016
  if (isToolCallEventType('read', event)) {
1816
- const filePath = canonicalizePath(event.input.path, ctx.cwd);
1817
- if (!matchesPattern(filePath, getEffectiveAllowRead(config), ctx.cwd)) {
1818
- const choice = await promptReadBlock(ctx, filePath);
1819
- if (choice === 'abort') {
1820
- return {
1821
- block: true,
1822
- reason: `Sandbox: read access denied for "${filePath}"`,
1823
- };
1824
- }
1825
- await applyReadChoice(ctx, choice, filePath, ctx.cwd);
1826
- }
2017
+ const result = await gateReadAccess(
2018
+ ctx,
2019
+ config,
2020
+ canonicalizePath(event.input.path, ctx.cwd),
2021
+ );
2022
+ if (result) return result;
2023
+ }
2024
+
2025
+ // grep, ls and find read their target path (default: the cwd) and recurse
2026
+ // into it, so gate them against allowRead/denyRead like the read tool —
2027
+ // otherwise they run in-process, unsandboxed, and defeat denyRead.
2028
+ if (
2029
+ isToolCallEventType('grep', event) ||
2030
+ isToolCallEventType('ls', event) ||
2031
+ isToolCallEventType('find', event)
2032
+ ) {
2033
+ const result = await gateReadAccess(
2034
+ ctx,
2035
+ config,
2036
+ canonicalizePath(event.input.path ?? '.', ctx.cwd),
2037
+ );
2038
+ if (result) return result;
1827
2039
  }
1828
2040
 
1829
2041
  if (isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.16.29",
3
+ "version": "0.17.2",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -8,14 +8,13 @@
8
8
  "pi-package",
9
9
  "sandbox"
10
10
  ],
11
- "license": "MIT",
11
+ "license": "Apache-2.0",
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/landstrip/pi-landstrip.git"
14
+ "url": "git+https://github.com/landstrip/landstrip.git"
15
15
  },
16
16
  "files": [
17
17
  "index.ts",
18
- "landstrip.d.ts",
19
18
  "README.md",
20
19
  "sandbox.json"
21
20
  ],
@@ -33,7 +32,7 @@
33
32
  },
34
33
  "dependencies": {
35
34
  "@earendil-works/pi-tui": "^0.78.0",
36
- "@landstrip/landstrip": "^0.16.23"
35
+ "@landstrip/landstrip": "^0.17.2"
37
36
  },
38
37
  "devDependencies": {
39
38
  "@earendil-works/pi-coding-agent": "^0.74.2",
package/landstrip.d.ts DELETED
@@ -1,3 +0,0 @@
1
- declare module '@landstrip/landstrip' {
2
- function binaryPath(): string;
3
- }