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 +201 -21
- package/README.md +1 -1
- package/index.ts +381 -169
- package/package.json +4 -5
- package/landstrip.d.ts +0 -3
package/LICENSE
CHANGED
|
@@ -1,21 +1,201 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 `
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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) =>
|
|
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
|
-
|
|
473
|
-
|
|
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].
|
|
507
|
+
return normalizeBlockedPath(landstripErrors[0].path, cwd);
|
|
476
508
|
}
|
|
477
509
|
|
|
478
|
-
return
|
|
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(
|
|
526
|
-
for (const error of parseLandstripTraps(
|
|
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.
|
|
560
|
+
return normalizeBlockedPath(error.path, cwd);
|
|
529
561
|
}
|
|
530
562
|
}
|
|
531
563
|
|
|
532
|
-
return
|
|
564
|
+
return null;
|
|
533
565
|
}
|
|
534
566
|
|
|
535
|
-
function extractBlockedReadPath(
|
|
536
|
-
for (const error of parseLandstripTraps(
|
|
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.
|
|
570
|
+
return normalizeBlockedPath(error.path, cwd);
|
|
539
571
|
}
|
|
540
572
|
}
|
|
541
573
|
|
|
542
|
-
return
|
|
574
|
+
return null;
|
|
543
575
|
}
|
|
544
576
|
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
case '
|
|
571
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
630
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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 = (
|
|
1513
|
+
const respondQuery = (
|
|
1514
|
+
queryId: string,
|
|
1515
|
+
action: LandstripControlResponse['action'],
|
|
1516
|
+
): void => {
|
|
1352
1517
|
if (trapSocket.destroyed) return;
|
|
1353
|
-
trapSocket.write(
|
|
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:
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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.
|
|
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": "
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/landstrip/
|
|
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.
|
|
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