@f5-sales-demo/xcsh 19.98.1 → 19.98.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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.98.
|
|
4
|
+
"version": "19.98.2",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
59
59
|
"@mozilla/readability": "^0.6",
|
|
60
|
-
"@f5-sales-demo/xcsh-stats": "19.98.
|
|
61
|
-
"@f5-sales-demo/pi-agent-core": "19.98.
|
|
62
|
-
"@f5-sales-demo/pi-ai": "19.98.
|
|
63
|
-
"@f5-sales-demo/pi-natives": "19.98.
|
|
64
|
-
"@f5-sales-demo/pi-resource-management": "19.98.
|
|
65
|
-
"@f5-sales-demo/pi-tui": "19.98.
|
|
66
|
-
"@f5-sales-demo/pi-utils": "19.98.
|
|
60
|
+
"@f5-sales-demo/xcsh-stats": "19.98.2",
|
|
61
|
+
"@f5-sales-demo/pi-agent-core": "19.98.2",
|
|
62
|
+
"@f5-sales-demo/pi-ai": "19.98.2",
|
|
63
|
+
"@f5-sales-demo/pi-natives": "19.98.2",
|
|
64
|
+
"@f5-sales-demo/pi-resource-management": "19.98.2",
|
|
65
|
+
"@f5-sales-demo/pi-tui": "19.98.2",
|
|
66
|
+
"@f5-sales-demo/pi-utils": "19.98.2",
|
|
67
67
|
"@sinclair/typebox": "^0.34",
|
|
68
68
|
"@xterm/headless": "^6.0",
|
|
69
69
|
"ajv": "^8.20",
|
|
@@ -104,13 +104,6 @@ export function needsProvision(reg: Registry, sessionId: string): boolean {
|
|
|
104
104
|
return !reg.has(sessionId);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** Lowest free port in the range not already held by a worker. */
|
|
108
|
-
export function pickPort(reg: Registry, range: number[]): number | null {
|
|
109
|
-
const used = new Set([...reg.values()].map(w => w.port));
|
|
110
|
-
for (const p of range) if (!used.has(p)) return p;
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
107
|
/** sessionIds whose worker has been idle longer than idleMs. */
|
|
115
108
|
export function staleKeys(reg: Registry, now: number, idleMs: number): string[] {
|
|
116
109
|
const out: string[] = [];
|
|
@@ -170,3 +163,35 @@ export function sparesToSpawn(
|
|
|
170
163
|
const freeSlots = Math.max(0, totalPorts - activeWorkers - currentSpares);
|
|
171
164
|
return Math.min(want, freeSlots);
|
|
172
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Choose a port to spawn on, probing ONLY ports that are not already spoken for.
|
|
169
|
+
*
|
|
170
|
+
* `isFree` is not a read — every implementation of it binds the port to find out,
|
|
171
|
+
* then releases it. Probing a port that has already been handed to a worker which
|
|
172
|
+
* is still starting up can therefore win that bind, and a worker whose forced
|
|
173
|
+
* `XCSH_BRIDGE_PORT` is occupied throws and exits rather than retrying
|
|
174
|
+
* ("XCSH_BRIDGE_PORT N is already in use", extension-bridge.ts). `proc.exited`
|
|
175
|
+
* then drops its registry entry, so the session ends up with no worker at all.
|
|
176
|
+
*
|
|
177
|
+
* That is the two-tab flake (#2463): provisioning a second tab swept the whole
|
|
178
|
+
* range, including the first tab's port, and could kill the first tab's worker
|
|
179
|
+
* mid-bind — leaving exactly one port advertising the tenant.
|
|
180
|
+
*
|
|
181
|
+
* `reserved` covers ports promised but not yet in the registry (pre-warmed spares
|
|
182
|
+
* live there until adopted). Probing is lazy: it stops at the first free port, so
|
|
183
|
+
* this binds no more ports than it has to.
|
|
184
|
+
*/
|
|
185
|
+
export function selectSpawnPort(
|
|
186
|
+
reg: Registry,
|
|
187
|
+
range: readonly number[],
|
|
188
|
+
reserved: readonly number[],
|
|
189
|
+
isFree: (port: number) => boolean,
|
|
190
|
+
): number | null {
|
|
191
|
+
const taken = new Set<number>([...reg.values()].map(w => w.port).concat(reserved));
|
|
192
|
+
for (const port of range) {
|
|
193
|
+
if (taken.has(port)) continue; // ours already — never probe it
|
|
194
|
+
if (isFree(port)) return port;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -33,8 +33,8 @@ import {
|
|
|
33
33
|
binaryIsStale,
|
|
34
34
|
needsProvision,
|
|
35
35
|
parseControlMsg,
|
|
36
|
-
pickPort,
|
|
37
36
|
type Registry,
|
|
37
|
+
selectSpawnPort,
|
|
38
38
|
sparesToSpawn,
|
|
39
39
|
staleKeys,
|
|
40
40
|
touchLastSeen,
|
|
@@ -46,12 +46,16 @@ const IDLE_MS = 20 * 60_000;
|
|
|
46
46
|
const SWEEP_MS = 60_000;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Whether a loopback TCP port is bindable RIGHT NOW.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* Whether a loopback TCP port is bindable RIGHT NOW. A range port can be held by
|
|
50
|
+
* another app, a stale worker, or a second manager, and a spawned worker binds its
|
|
51
|
+
* forced `XCSH_BRIDGE_PORT` strictly — it throws and exits rather than falling back
|
|
52
|
+
* — so an occupied port must be ruled out before we hand it over.
|
|
53
|
+
*
|
|
54
|
+
* NOTE this is a BINDING probe, not a read: it takes the port and releases it. Only
|
|
55
|
+
* `selectSpawnPort` may call it, and only for ports we have not already handed out;
|
|
56
|
+
* probing one of our own in-flight workers can win the bind and kill it (#2463).
|
|
57
|
+
* Bun.listen binds and throws synchronously, so this stays sync — keeping provision
|
|
58
|
+
* idempotency race-free.
|
|
55
59
|
*/
|
|
56
60
|
function isPortFree(port: number): boolean {
|
|
57
61
|
try {
|
|
@@ -241,10 +245,12 @@ export default class Manager extends Command {
|
|
|
241
245
|
const pool: SpareRec[] = [];
|
|
242
246
|
|
|
243
247
|
const spawnSpare = (): void => {
|
|
244
|
-
|
|
245
|
-
const port =
|
|
248
|
+
// Same rule as spawnWorker: assigned and reserved ports are never probed.
|
|
249
|
+
const port = selectSpawnPort(
|
|
246
250
|
reg,
|
|
247
|
-
range
|
|
251
|
+
range,
|
|
252
|
+
pool.map(s => s.port),
|
|
253
|
+
isPortFree,
|
|
248
254
|
);
|
|
249
255
|
if (port === null) return; // range full — do not pre-warm
|
|
250
256
|
const proc = Bun.spawn([process.execPath, ...workerArgv()], {
|
|
@@ -388,8 +394,15 @@ export default class Manager extends Command {
|
|
|
388
394
|
};
|
|
389
395
|
|
|
390
396
|
const spawnWorker = (msg: { sessionId: string; tenant: string }, managerProvisionMs: number): void => {
|
|
391
|
-
//
|
|
392
|
-
|
|
397
|
+
// Never probe a port we have already handed out: isPortFree BINDS to find
|
|
398
|
+
// out, so sweeping the whole range could win the bind from a worker that is
|
|
399
|
+
// still starting up and kill it (#2463). Spares are reserved the same way.
|
|
400
|
+
const port = selectSpawnPort(
|
|
401
|
+
reg,
|
|
402
|
+
range,
|
|
403
|
+
pool.map(s => s.port),
|
|
404
|
+
isPortFree,
|
|
405
|
+
);
|
|
393
406
|
if (port === null) {
|
|
394
407
|
console.error(`[xcsh manager] port range exhausted; cannot provision ${msg.sessionId}`);
|
|
395
408
|
return;
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.98.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.98.2",
|
|
21
|
+
"commit": "0721dc78b3894f7f79f8121b7ccde4de06e2c711",
|
|
22
|
+
"shortCommit": "0721dc7",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.98.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.98.2",
|
|
25
|
+
"commitDate": "2026-07-28T03:43:05Z",
|
|
26
|
+
"buildDate": "2026-07-28T04:18:37.474Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/0721dc78b3894f7f79f8121b7ccde4de06e2c711",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.2"
|
|
33
33
|
};
|
package/src/sandbox/enforce.ts
CHANGED
|
@@ -116,6 +116,29 @@ function isSystemPath(resolved: string): boolean {
|
|
|
116
116
|
return SYSTEM_READ_ROOTS.some(root => resolved === root || resolved.startsWith(`${root}${path.sep}`));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Character devices that discard output or route it back to the caller's own descriptors. Writing
|
|
121
|
+
* to one stores nothing and reaches no file, so it cannot carry data across the boundary — and
|
|
122
|
+
* `> /dev/null` is too common to refuse.
|
|
123
|
+
*
|
|
124
|
+
* An exact list, not a `/dev` prefix: `/dev` also holds raw block devices like `/dev/disk0`, where
|
|
125
|
+
* a write is both an escape and a catastrophe.
|
|
126
|
+
*/
|
|
127
|
+
const SYSTEM_WRITE_SINKS = new Set([
|
|
128
|
+
"/dev/null",
|
|
129
|
+
"/dev/zero",
|
|
130
|
+
"/dev/full",
|
|
131
|
+
"/dev/tty",
|
|
132
|
+
"/dev/stdin",
|
|
133
|
+
"/dev/stdout",
|
|
134
|
+
"/dev/stderr",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
function isSystemWriteSink(resolved: string): boolean {
|
|
138
|
+
// /dev/fd/N is an already-open descriptor of this process, not a path it can newly reach.
|
|
139
|
+
return SYSTEM_WRITE_SINKS.has(resolved) || resolved.startsWith("/dev/fd/");
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
function firstString(input: Record<string, unknown>, keys: string[]): string | undefined {
|
|
120
143
|
for (const key of keys) {
|
|
121
144
|
const value = input[key];
|
|
@@ -132,52 +155,153 @@ function looksLikePath(token: string): boolean {
|
|
|
132
155
|
return path.isAbsolute(token) || token.startsWith("~") || /(^|[/\\])\.\.([/\\]|$)/.test(token);
|
|
133
156
|
}
|
|
134
157
|
|
|
158
|
+
/** A path-like token found by the floor, with where it was found. */
|
|
159
|
+
interface PathOccurrence {
|
|
160
|
+
token: string;
|
|
161
|
+
/** Offset of the token's first character — past any opening quote — in the scanned string. */
|
|
162
|
+
at: number;
|
|
163
|
+
/** Set when a redirection operator in the raw text says what the shell will do with it. */
|
|
164
|
+
access?: SandboxAccess;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a redirection operator does to the operand that follows it. `skip` is a here-string or a
|
|
169
|
+
* heredoc delimiter: that operand is literal data the shell never opens — verified against bash,
|
|
170
|
+
* where `cat <<</tmp/f` prints the string `/tmp/f` rather than the contents of that file.
|
|
171
|
+
*/
|
|
172
|
+
function operandAccess(operator: string): SandboxAccess | "skip" {
|
|
173
|
+
if (operator.includes("<<")) return "skip";
|
|
174
|
+
// `<>` opens for both; the write side is the one a read-only grant must not license.
|
|
175
|
+
return operator.includes(">") ? "write" : "read";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A path to check, and the boundary to check it against. */
|
|
179
|
+
interface PathCandidate {
|
|
180
|
+
token: string;
|
|
181
|
+
access: SandboxAccess;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
|
|
185
|
+
const WRITE_AND_READ = ["write", "read"] as const satisfies readonly SandboxAccess[];
|
|
186
|
+
|
|
187
|
+
/** A redirection operator, with its optional file-descriptor prefix. Longest forms first. */
|
|
188
|
+
const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
|
|
189
|
+
|
|
190
|
+
/** A whitespace token that is only a redirection operator, so the next token is its operand. */
|
|
191
|
+
const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
|
|
192
|
+
|
|
135
193
|
/**
|
|
136
194
|
* Path-like tokens in a command/code string: bare (whitespace-split) and quoted.
|
|
137
195
|
*
|
|
138
196
|
* Indiscriminate by design, and that is the point: because it never asks what a command *means*, it
|
|
139
197
|
* also catches paths hidden inside quoted scripts, heredoc bodies, `-exec` runs and substitutions.
|
|
140
198
|
* This is the coverage floor for both bash and python. Do not narrow it.
|
|
199
|
+
*
|
|
200
|
+
* Offsets come along so a caller can tell one occurrence of a token from another. Nothing else about
|
|
201
|
+
* what this finds has changed: the two passes and `looksLikePath` are the floor.
|
|
141
202
|
*/
|
|
142
|
-
function
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
203
|
+
function codePathOccurrences(command: string): PathOccurrence[] {
|
|
204
|
+
const found: PathOccurrence[] = [];
|
|
205
|
+
const add = (token: string, at: number, access?: SandboxAccess): void => {
|
|
206
|
+
if (token.length > 0 && looksLikePath(token)) found.push({ token, at, access });
|
|
207
|
+
};
|
|
208
|
+
// Set when the previous token was nothing but a redirection operator, so this one is its operand.
|
|
209
|
+
let carried: SandboxAccess | "skip" | undefined;
|
|
210
|
+
for (const match of command.matchAll(/\S+/g)) {
|
|
211
|
+
const raw = match[0];
|
|
212
|
+
const operand = carried;
|
|
213
|
+
carried = undefined;
|
|
214
|
+
|
|
215
|
+
if (BARE_REDIRECT.test(raw)) {
|
|
216
|
+
carried = operandAccess(raw);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stripped = raw.replace(/^["']|["']$/g, "");
|
|
221
|
+
const openingQuote = raw.length !== stripped.length && (raw[0] === '"' || raw[0] === "'") ? 1 : 0;
|
|
222
|
+
if (operand !== "skip") add(stripped, match.index + openingQuote, operand);
|
|
223
|
+
// An operator glued to its operand is one whitespace token, and `>/work/x` is not absolute.
|
|
224
|
+
// The lexer resolves this for words it can see, but not for text inside a quoted script or a
|
|
225
|
+
// heredoc body — where the floor is the only thing looking — so scan past the operator here
|
|
226
|
+
// too, anywhere it appears in the token: `echo a>/work/x` is one token as well. The operator
|
|
227
|
+
// is also the only thing that says which boundary a nested redirect crosses.
|
|
228
|
+
for (const operator of stripped.matchAll(REDIRECT_OPERATOR)) {
|
|
229
|
+
const access = operandAccess(operator[0]);
|
|
230
|
+
if (access === "skip") continue;
|
|
231
|
+
const from = operator.index + operator[0].length;
|
|
232
|
+
add(stripped.slice(from).replace(/^["']|["']$/g, ""), match.index + openingQuote + from, access);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const match of command.matchAll(/["']([^"']+)["']/g)) add(match[1], match.index + 1);
|
|
236
|
+
return found;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The floor as plain read candidates — what a non-shell language gets. */
|
|
240
|
+
function codePathCandidates(command: string): PathCandidate[] {
|
|
241
|
+
return codePathOccurrences(command).map(({ token }) => ({ token, access: "read" as const }));
|
|
147
242
|
}
|
|
148
243
|
|
|
149
244
|
/**
|
|
150
|
-
* Path
|
|
151
|
-
* script or pattern rather than a filename (issue #2470: `sed -n '/a/p'` was refused because `/a/p`
|
|
152
|
-
* looks absolute, though sed never opens it).
|
|
245
|
+
* Path candidates of a *bash* command. Three things happen here, and only the first narrows:
|
|
153
246
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
247
|
+
* 1. **Exempt words are blanked.** Words the invoked command provably treats as a script or pattern
|
|
248
|
+
* rather than a filename stop being scanned (issue #2470: `sed -n '/a/p'` was refused because
|
|
249
|
+
* `/a/p` looks absolute, though sed never opens it). Implemented by blanking the exempt spans and
|
|
250
|
+
* re-running the floor over what remains, rather than by subtracting a set of token strings. Set
|
|
251
|
+
* subtraction loses which *occurrence* a token came from, so `echo '/elsewhere/x' && cat
|
|
252
|
+
* /elsewhere/x` would exempt the echo operand and thereby clear the identical token belonging to
|
|
253
|
+
* `cat`. Blanking is positional and cannot leak that way.
|
|
158
254
|
*
|
|
159
|
-
*
|
|
255
|
+
* 2. **A word the shell opens for writing is checked against the write boundary** (issue #2516).
|
|
256
|
+
* Every candidate used to be checked as a read, so a path granted read access but not write was
|
|
257
|
+
* writable through `>`. Marking is by span, for the same occurrence-identity reason as blanking:
|
|
258
|
+
* in `cat /shared/x && printf y > /shared/x` only the second occurrence is the write.
|
|
259
|
+
*
|
|
260
|
+
* 3. **Redirect targets are added as candidates** (issue #2520). The floor splits on whitespace, so
|
|
261
|
+
* an operator glued to its path — `cat </work/custB/secret` — was one token that did not look
|
|
262
|
+
* like a path and was never checked at all. The lexer parses those correctly, so its redirect
|
|
263
|
+
* targets go in on top of the floor. This only ever adds candidates.
|
|
264
|
+
*
|
|
265
|
+
* The floor's reach is unchanged: text the lexer never turns into a word — a heredoc body, an
|
|
160
266
|
* `-exec` run — is not blanked, so it is still scanned. When the command cannot be lexed
|
|
161
|
-
* confidently,
|
|
267
|
+
* confidently, the floor stands alone.
|
|
162
268
|
*/
|
|
163
|
-
function
|
|
164
|
-
const floor = codePathTokens(command);
|
|
165
|
-
if (floor.length === 0) return floor;
|
|
166
|
-
|
|
269
|
+
function shellPathCandidates(command: string): PathCandidate[] {
|
|
167
270
|
const lexed = lexShellCommand(command);
|
|
168
|
-
// Unbalanced quotes mean
|
|
169
|
-
|
|
271
|
+
// Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
|
|
272
|
+
// marking below can be trusted, so fall back to the floor, checked as reads.
|
|
273
|
+
if (lexed.unterminated) return codePathCandidates(command);
|
|
170
274
|
|
|
171
275
|
const exemptSpans = lexed.commands.flatMap(simpleCommand => provenExemptWords(simpleCommand));
|
|
172
|
-
|
|
173
|
-
|
|
276
|
+
let scanned = command;
|
|
174
277
|
// Replace each exempt word with equivalent-length whitespace, so surrounding offsets and word
|
|
175
278
|
// boundaries are preserved and only that word's own text stops being scanned.
|
|
176
|
-
let masked = command;
|
|
177
279
|
for (const word of exemptSpans) {
|
|
178
|
-
|
|
280
|
+
scanned = scanned.slice(0, word.start) + " ".repeat(word.end - word.start) + scanned.slice(word.end);
|
|
179
281
|
}
|
|
180
|
-
|
|
282
|
+
|
|
283
|
+
// `<>` opens for both, so it stays a read here and picks up its write below: a floor occurrence
|
|
284
|
+
// may carry only one access, and read is the one the floor would have used anyway.
|
|
285
|
+
const writeTargets = lexed.words.filter(word => word.redirect === "write");
|
|
286
|
+
const inWriteTarget = (at: number): boolean => writeTargets.some(word => at >= word.start && at < word.end);
|
|
287
|
+
|
|
288
|
+
// A floor occurrence takes the access its own operator gave it; failing that, the span of a
|
|
289
|
+
// lexed write target it sits inside; failing that, read.
|
|
290
|
+
const candidates: PathCandidate[] = codePathOccurrences(scanned).map(({ token, at, access }) => ({
|
|
291
|
+
token,
|
|
292
|
+
access: access ?? (inWriteTarget(at) ? "write" : "read"),
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
|
|
296
|
+
// command might be filenames. A redirect target is one the shell will certainly open, so a bare
|
|
297
|
+
// `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
|
|
298
|
+
for (const word of lexed.words) {
|
|
299
|
+
if (word.redirect === undefined || word.redirect === "here-string") continue;
|
|
300
|
+
for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
|
|
301
|
+
candidates.push({ token: word.text, access });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return candidates;
|
|
181
305
|
}
|
|
182
306
|
|
|
183
307
|
/** Base directories a search input would actually search, split like the tools do. */
|
|
@@ -216,13 +340,19 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
|
|
|
216
340
|
}
|
|
217
341
|
|
|
218
342
|
for (const command of commands) {
|
|
219
|
-
// Only bash gets the shell-aware
|
|
220
|
-
// shell yields one non-absolute word and would lose the check entirely
|
|
221
|
-
|
|
343
|
+
// Only bash gets the shell-aware treatment. Python is not shell: lexing `open('/x')` as
|
|
344
|
+
// shell yields one non-absolute word and would lose the check entirely, and it has no
|
|
345
|
+
// redirects, so every candidate it produces is a read.
|
|
346
|
+
const seen = new Set<string>();
|
|
347
|
+
for (const { token, access } of shell ? shellPathCandidates(command) : codePathCandidates(command)) {
|
|
348
|
+
if (!seen.add(`${access}\0${token}`)) continue;
|
|
222
349
|
const resolved = resolveToCwd(token, base);
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
350
|
+
if (policy.isAllowed(resolved, access)) continue;
|
|
351
|
+
// SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately
|
|
352
|
+
// read or traverse". It never licenses writing into /etc, /usr or /opt; only the
|
|
353
|
+
// discard-and-echo devices are writable.
|
|
354
|
+
if (access === "read" ? isSystemPath(resolved) : isSystemWriteSink(resolved)) continue;
|
|
355
|
+
return deny(policy, resolved, access);
|
|
226
356
|
}
|
|
227
357
|
}
|
|
228
358
|
|
package/src/tools/shell-lex.ts
CHANGED
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
/** How a word was quoted. `mixed` when built from differently-quoted segments, as in `a"b"'c'`. */
|
|
24
24
|
export type QuoteKind = "none" | "single" | "double" | "ansi-c" | "mixed";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What the shell will do with a redirect operand. `<>` opens the file for both. `here-string` is
|
|
28
|
+
* `<<<`, whose operand is literal text supplied on stdin — the shell never opens it as a path.
|
|
29
|
+
*/
|
|
30
|
+
export type RedirectDirection = "read" | "write" | "read-write" | "here-string";
|
|
31
|
+
|
|
26
32
|
export interface ShellWord {
|
|
27
33
|
/** Literal text after quote removal and backslash processing. */
|
|
28
34
|
text: string;
|
|
@@ -37,8 +43,11 @@ export interface ShellWord {
|
|
|
37
43
|
* must not treat a non-literal word as a resolvable path or a whole-word URL.
|
|
38
44
|
*/
|
|
39
45
|
literal: boolean;
|
|
40
|
-
/**
|
|
41
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Set when this word is the target of a redirection, with the direction of that redirect.
|
|
48
|
+
* `read-write` is `<>`, which opens the one file for both.
|
|
49
|
+
*/
|
|
50
|
+
redirect: RedirectDirection | undefined;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export type ShellOperator = "|" | "||" | "&&" | ";" | "&" | "\n";
|
|
@@ -245,7 +254,7 @@ function readOperator(lexer: Lexer): ShellOperator | undefined {
|
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
type RedirectToken =
|
|
248
|
-
| { kind: "file"; direction:
|
|
257
|
+
| { kind: "file"; direction: RedirectDirection }
|
|
249
258
|
| { kind: "fd-dup" }
|
|
250
259
|
| { kind: "heredoc"; stripTabs: boolean };
|
|
251
260
|
|
|
@@ -274,10 +283,10 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
|
|
|
274
283
|
return { kind: "file", direction: "write" };
|
|
275
284
|
}
|
|
276
285
|
if (src.startsWith("<<<", cursor)) {
|
|
277
|
-
// A here-string supplies literal text, not a filename
|
|
278
|
-
//
|
|
286
|
+
// A here-string supplies literal text, not a filename. It stays a redirect target so it is
|
|
287
|
+
// still kept out of the exemptible-operand set, but its direction says it is never opened.
|
|
279
288
|
lexer.pos = cursor + 3;
|
|
280
|
-
return { kind: "file", direction: "
|
|
289
|
+
return { kind: "file", direction: "here-string" };
|
|
281
290
|
}
|
|
282
291
|
if (src.startsWith("<<-", cursor)) {
|
|
283
292
|
lexer.pos = cursor + 3;
|
|
@@ -287,15 +296,33 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
|
|
|
287
296
|
lexer.pos = cursor + 2;
|
|
288
297
|
return { kind: "heredoc", stripTabs: false };
|
|
289
298
|
}
|
|
290
|
-
|
|
299
|
+
// `<>` opens one file for reading and writing; reported as `<` alone the write would be invisible.
|
|
300
|
+
if (src.startsWith("<>", cursor)) {
|
|
291
301
|
lexer.pos = cursor + 2;
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
return { kind: "file", direction: "read-write" };
|
|
303
|
+
}
|
|
304
|
+
// `>&` and `<&` duplicate a descriptor only when a descriptor follows: `2>&1`, `3>&-`. With a
|
|
305
|
+
// filename after it — `printf x >&out.txt` — bash opens the file instead, sending both stdout
|
|
306
|
+
// and stderr to it, so the target has to be checked like any other write.
|
|
307
|
+
if (src.startsWith(">&", cursor) || src.startsWith("<&", cursor)) {
|
|
308
|
+
const write = src[cursor] === ">";
|
|
309
|
+
let scan = cursor + 2;
|
|
310
|
+
while (scan < end && isDigit(src[scan])) scan++;
|
|
311
|
+
if (src[scan] === "-") scan++;
|
|
312
|
+
const duplicated = scan > cursor + 2 && (scan >= end || isWordBreak(src[scan]));
|
|
313
|
+
lexer.pos = duplicated ? scan : cursor + 2;
|
|
314
|
+
return duplicated ? { kind: "fd-dup" } : { kind: "file", direction: write ? "write" : "read" };
|
|
294
315
|
}
|
|
295
316
|
if (src.startsWith(">>", cursor)) {
|
|
296
317
|
lexer.pos = cursor + 2;
|
|
297
318
|
return { kind: "file", direction: "write" };
|
|
298
319
|
}
|
|
320
|
+
// `>|` overrides noclobber. Without this the `|` reads as a pipe and the filename after it
|
|
321
|
+
// becomes the next command's name, so the write target is lost.
|
|
322
|
+
if (src.startsWith(">|", cursor)) {
|
|
323
|
+
lexer.pos = cursor + 2;
|
|
324
|
+
return { kind: "file", direction: "write" };
|
|
325
|
+
}
|
|
299
326
|
if (ch === ">") {
|
|
300
327
|
lexer.pos = cursor + 1;
|
|
301
328
|
return { kind: "file", direction: "write" };
|
|
@@ -304,6 +331,11 @@ function readRedirect(lexer: Lexer, end: number): RedirectToken | undefined {
|
|
|
304
331
|
return { kind: "file", direction: "read" };
|
|
305
332
|
}
|
|
306
333
|
|
|
334
|
+
/** True where a word ends: whitespace, or the start of an operator or grouping. */
|
|
335
|
+
function isWordBreak(ch: string | undefined): boolean {
|
|
336
|
+
return ch === undefined || /[\s;&|<>()]/.test(ch);
|
|
337
|
+
}
|
|
338
|
+
|
|
307
339
|
function isDigit(ch: string | undefined): boolean {
|
|
308
340
|
return ch !== undefined && ch >= "0" && ch <= "9";
|
|
309
341
|
}
|