@f5-sales-demo/xcsh 20.4.0 → 20.4.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 +8 -8
- package/src/browser/chat-conformance.json +4 -0
- package/src/browser/chat-handler.ts +129 -22
- package/src/browser/chat-protocol.ts +38 -13
- package/src/browser/headless-bridge.ts +5 -0
- package/src/browser/office-pane-server.ts +12 -2
- package/src/cli/sandbox-check.ts +167 -65
- package/src/config/settings-schema.ts +11 -14
- package/src/extensibility/extensions/bundled/sandbox-guard.ts +3 -5
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/plugin-resolve.ts +22 -5
- package/src/modes/controllers/login-model.ts +7 -7
- package/src/prompts/internal-urls/containment.md +15 -7
- package/src/sandbox/containment.ts +46 -18
- package/src/sandbox/session-fence.ts +7 -7
- package/src/sdk.ts +4 -1
- package/src/tools/bash-skill-urls.ts +4 -4
- package/src/tools/bash.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.4.
|
|
4
|
+
"version": "20.4.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",
|
|
@@ -60,13 +60,13 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
62
62
|
"@mozilla/readability": "^0.6",
|
|
63
|
-
"@f5-sales-demo/xcsh-stats": "20.4.
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "20.4.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "20.4.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "20.4.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "20.4.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "20.4.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "20.4.
|
|
63
|
+
"@f5-sales-demo/xcsh-stats": "20.4.2",
|
|
64
|
+
"@f5-sales-demo/pi-agent-core": "20.4.2",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "20.4.2",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "20.4.2",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "20.4.2",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "20.4.2",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "20.4.2",
|
|
70
70
|
"@sinclair/typebox": "^0.34",
|
|
71
71
|
"@xterm/headless": "^6.0",
|
|
72
72
|
"ajv": "^8.20",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeHostToolDefinitions,
|
|
13
13
|
RpcHostToolBridge,
|
|
14
14
|
} from "../host-tools";
|
|
15
|
+
import { LITELLM_LOGIN_MODEL_CHOICES } from "../modes/controllers/login-model";
|
|
15
16
|
import { extractReferences } from "../references";
|
|
16
17
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
17
18
|
import {
|
|
@@ -31,9 +32,11 @@ import {
|
|
|
31
32
|
isChatStop,
|
|
32
33
|
isConfigure,
|
|
33
34
|
isListCommands,
|
|
35
|
+
isListModels,
|
|
34
36
|
isListSkills,
|
|
35
37
|
isSetHostTools,
|
|
36
38
|
isTransportChatRequest,
|
|
39
|
+
type ModelsList,
|
|
37
40
|
type PageContextSnapshot,
|
|
38
41
|
type SetHostTools,
|
|
39
42
|
type SetHostToolsAck,
|
|
@@ -84,6 +87,11 @@ export class ChatHandler {
|
|
|
84
87
|
// Only one pending request at a time (newest wins — a third prompt while one is
|
|
85
88
|
// queued replaces it, so the user's latest intent always runs next).
|
|
86
89
|
#pendingRequest: ChatRequest | null = null;
|
|
90
|
+
// A request can arrive after chat_done but before AgentSession clears its short
|
|
91
|
+
// prompt-in-flight tail. There is then no active chat whose finally block can
|
|
92
|
+
// replay the queue, so that orphaned queue needs its own idle drain.
|
|
93
|
+
#pendingReplayScheduled = false;
|
|
94
|
+
#disposed = false;
|
|
87
95
|
// Transport-neutral host-tool bridge (A1): maps `set_host_tools` definitions to
|
|
88
96
|
// AgentTools whose execute() round-trips a `host_tool_call` back to the WS client
|
|
89
97
|
// and awaits the correlated `host_tool_result`. Reused verbatim from the stdio RPC
|
|
@@ -119,6 +127,9 @@ export class ChatHandler {
|
|
|
119
127
|
// Skills enumeration (#2311): the pane asks for the loaded skills to populate
|
|
120
128
|
// the composer's Skills submenu.
|
|
121
129
|
else if (isListSkills(msg)) this.#handleListSkills();
|
|
130
|
+
// Model enumeration is separate from credential configuration: the pane can
|
|
131
|
+
// select among models xcsh already knows without asking for another token.
|
|
132
|
+
else if (isListModels(msg)) this.#handleListModels();
|
|
122
133
|
// Slash-command enumeration: the pane asks for the session's file-based
|
|
123
134
|
// commands to populate the composer's `/` menu.
|
|
124
135
|
else if (isListCommands(msg)) this.#handleListCommands();
|
|
@@ -127,6 +138,7 @@ export class ChatHandler {
|
|
|
127
138
|
});
|
|
128
139
|
|
|
129
140
|
this.#server.onDisconnected(() => {
|
|
141
|
+
this.#disposed = true;
|
|
130
142
|
this.#pendingRequest = null; // abandon any queued prompt — the bridge is gone
|
|
131
143
|
// Fail any in-flight host-tool call — the client that would answer it is gone.
|
|
132
144
|
this.#hostToolBridge.rejectAllPending("bridge disconnected before host tool completed");
|
|
@@ -158,6 +170,7 @@ export class ChatHandler {
|
|
|
158
170
|
ok: true,
|
|
159
171
|
detail: "xcsh is finishing the current request — yours is queued and will run next.",
|
|
160
172
|
});
|
|
173
|
+
this.#scheduleOrphanedPendingReplay();
|
|
161
174
|
return;
|
|
162
175
|
}
|
|
163
176
|
|
|
@@ -205,14 +218,14 @@ export class ChatHandler {
|
|
|
205
218
|
data: img.data,
|
|
206
219
|
mimeType: img.mimeType,
|
|
207
220
|
}));
|
|
208
|
-
// "Search the web" toggle → add Anthropic's server-side web-search tool for this
|
|
209
|
-
// turn only. The gateway executes it and returns cited results; source URLs the
|
|
210
|
-
// model writes inline flow to the pane's Sources chips via extractReferences.
|
|
211
|
-
const serverTools = req.web_search
|
|
212
|
-
? [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }]
|
|
213
|
-
: undefined;
|
|
214
|
-
|
|
215
221
|
try {
|
|
222
|
+
// Select the native provider-side descriptor from the ACTIVE model on every
|
|
223
|
+
// turn. This must happen after a model switch and before prompt() transmits a
|
|
224
|
+
// payload; an unsupported API is rejected locally instead of receiving another
|
|
225
|
+
// provider's raw tool shape.
|
|
226
|
+
const serverTools = req.web_search
|
|
227
|
+
? officeWebSearchServerTools(this.#session.model?.api ?? "unconfigured")
|
|
228
|
+
: undefined;
|
|
216
229
|
chat.promptAt = Date.now();
|
|
217
230
|
await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images, serverTools });
|
|
218
231
|
} catch (err: unknown) {
|
|
@@ -234,6 +247,43 @@ export class ChatHandler {
|
|
|
234
247
|
}
|
|
235
248
|
}
|
|
236
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Drain a queue created during AgentSession's terminal streaming tail. Normal
|
|
252
|
+
* mid-turn queues are replayed by the active chat's finally block; this path is
|
|
253
|
+
* only scheduled when no active chat exists to own that replay.
|
|
254
|
+
*/
|
|
255
|
+
#scheduleOrphanedPendingReplay(): void {
|
|
256
|
+
if (this.#pendingReplayScheduled || this.#disposed || !this.#pendingRequest || this.#activeChats.size > 0) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.#pendingReplayScheduled = true;
|
|
260
|
+
void this.#replayPendingAfterIdle();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #replayPendingAfterIdle(): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
await this.#session.waitForIdle();
|
|
266
|
+
// waitForIdle can resolve in AgentSession's prompt finally just before its
|
|
267
|
+
// prompt-in-flight counter is decremented. Yield one task so isStreaming is
|
|
268
|
+
// the settled state rather than that terminal edge.
|
|
269
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
270
|
+
} catch {
|
|
271
|
+
// A disposing session can reject its idle wait. Teardown owns any terminal
|
|
272
|
+
// frame and clears the pending request; never replay into that session.
|
|
273
|
+
this.#pendingReplayScheduled = false;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.#pendingReplayScheduled = false;
|
|
277
|
+
if (this.#disposed || !this.#pendingRequest || this.#activeChats.size > 0) return;
|
|
278
|
+
if (this.#session.isStreaming) {
|
|
279
|
+
this.#scheduleOrphanedPendingReplay();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const pending = this.#pendingRequest;
|
|
283
|
+
this.#pendingRequest = null;
|
|
284
|
+
void this.#handleChatRequest(pending);
|
|
285
|
+
}
|
|
286
|
+
|
|
237
287
|
#handleSessionEvent(chat: ActiveChat, event: AgentSessionEvent): void {
|
|
238
288
|
if (chat.terminalSent) return;
|
|
239
289
|
|
|
@@ -377,8 +427,8 @@ export class ChatHandler {
|
|
|
377
427
|
* SQLite credential store. Mirrors #handleSetHostTools's try/ack-or-nack shape;
|
|
378
428
|
* never throws out of the handler (a nack keeps a waiting client from hanging).
|
|
379
429
|
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
430
|
+
* A curated model route identifies the concrete provider, model, effort, and
|
|
431
|
+
* provider-specific gateway path. The pane supplies only a gateway root. */
|
|
382
432
|
async #handleConfigure(msg: Configure): Promise<void> {
|
|
383
433
|
try {
|
|
384
434
|
const registry = this.#session.modelRegistry;
|
|
@@ -386,9 +436,21 @@ export class ChatHandler {
|
|
|
386
436
|
if (!defaultModel) {
|
|
387
437
|
throw new Error("Invalid baked default model selector");
|
|
388
438
|
}
|
|
389
|
-
const
|
|
439
|
+
const currentChoice = LITELLM_LOGIN_MODEL_CHOICES.find(
|
|
440
|
+
choice => choice.provider === this.#session.model?.provider && choice.modelId === this.#session.model.id,
|
|
441
|
+
);
|
|
442
|
+
const modelId = msg.model ?? currentChoice?.modelId ?? defaultModel.id;
|
|
443
|
+
const choice = LITELLM_LOGIN_MODEL_CHOICES.find(candidate => candidate.modelId === modelId);
|
|
444
|
+
const matchingProviders = (["anthropic", "litellm"] as const).filter(candidate =>
|
|
445
|
+
registry.find(candidate, modelId),
|
|
446
|
+
);
|
|
447
|
+
const provider = choice?.provider ?? (matchingProviders.length === 1 ? matchingProviders[0] : undefined);
|
|
448
|
+
if (!provider) {
|
|
449
|
+
throw new Error(`No unambiguous Office model route for ${modelId}`);
|
|
450
|
+
}
|
|
451
|
+
const thinkingLevel = choice?.thinkingLevel ?? defaultModel.thinkingLevel;
|
|
390
452
|
|
|
391
|
-
if (msg.baseUrl) {
|
|
453
|
+
if (msg.baseUrl && msg.token) {
|
|
392
454
|
// SSRF guard: only an `https:` gateway URL may be dialed. Validate BEFORE
|
|
393
455
|
// registerProvider so a bad URL becomes a configure_error nack (never a
|
|
394
456
|
// silently-ignored frame that hangs the client). We deliberately do NOT
|
|
@@ -400,7 +462,8 @@ export class ChatHandler {
|
|
|
400
462
|
// THEIR OWN gateway with THEIR OWN token over a loopback-only, TLS,
|
|
401
463
|
// Origin-checked bridge (extension-bridge `isAllowedBridgeOrigin`), https is
|
|
402
464
|
// enforced here, and the token is session-only (never persisted to disk).
|
|
403
|
-
const
|
|
465
|
+
const gatewayRoot = requireHttpsUrl(msg.baseUrl);
|
|
466
|
+
const baseUrl = providerGatewayBaseUrl(gatewayRoot, provider);
|
|
404
467
|
|
|
405
468
|
// baseUrl + apiKey, no models[] → sets the in-memory runtime API key AND
|
|
406
469
|
// overrides the existing provider models' baseUrl/headers (reusing their
|
|
@@ -414,14 +477,11 @@ export class ChatHandler {
|
|
|
414
477
|
},
|
|
415
478
|
"office-configure",
|
|
416
479
|
);
|
|
417
|
-
} else {
|
|
480
|
+
} else if (msg.token) {
|
|
418
481
|
// Key-only: reuse the baked F5 gateway; set just the non-persistent runtime key.
|
|
419
482
|
registry.authStorage.setRuntimeApiKey(provider, msg.token);
|
|
420
483
|
}
|
|
421
484
|
|
|
422
|
-
const currentDefaultModelId =
|
|
423
|
-
this.#session.model?.provider === provider ? this.#session.model.id : defaultModelId;
|
|
424
|
-
const modelId = msg.model ?? currentDefaultModelId;
|
|
425
485
|
const model = registry.find(provider, modelId);
|
|
426
486
|
if (!model) {
|
|
427
487
|
throw new Error(`No model ${provider}/${modelId} available`);
|
|
@@ -464,6 +524,23 @@ export class ChatHandler {
|
|
|
464
524
|
this.#server.send({ type: "skills", skills: toSkillSummaries(this.#session.skills) } satisfies SkillsList);
|
|
465
525
|
}
|
|
466
526
|
|
|
527
|
+
/** Reply with the curated Office models that resolve in the live registry. */
|
|
528
|
+
#handleListModels(): void {
|
|
529
|
+
const models = LITELLM_LOGIN_MODEL_CHOICES.filter(choice =>
|
|
530
|
+
this.#session.modelRegistry.find(choice.provider, choice.modelId),
|
|
531
|
+
).map(choice => ({ id: choice.modelId, label: choice.label }));
|
|
532
|
+
const current = this.#session.model?.id;
|
|
533
|
+
// A ready Office session normally always has an active model. During an
|
|
534
|
+
// unconfigured startup there is no truthful `current` value to advertise, so
|
|
535
|
+
// leave the selector empty until the next connection instead of inventing one.
|
|
536
|
+
if (!current) return;
|
|
537
|
+
this.#server.send({
|
|
538
|
+
type: "models",
|
|
539
|
+
current,
|
|
540
|
+
models,
|
|
541
|
+
} satisfies ModelsList);
|
|
542
|
+
}
|
|
543
|
+
|
|
467
544
|
/** Reply to `list_commands` with the session's file-based slash commands (name +
|
|
468
545
|
* description) so the pane can populate the composer's `/` menu. Pure read — the
|
|
469
546
|
* commands are already discovered; the template bodies never cross the wire. */
|
|
@@ -487,6 +564,7 @@ export class ChatHandler {
|
|
|
487
564
|
}
|
|
488
565
|
|
|
489
566
|
dispose(): void {
|
|
567
|
+
this.#disposed = true;
|
|
490
568
|
this.#pendingRequest = null; // abandon any queued prompt — don't replay into a dead session
|
|
491
569
|
// Fail any in-flight host-tool call — the session is going away.
|
|
492
570
|
this.#hostToolBridge.rejectAllPending("bridge disconnected before host tool completed");
|
|
@@ -502,11 +580,37 @@ export class ChatHandler {
|
|
|
502
580
|
}
|
|
503
581
|
}
|
|
504
582
|
|
|
505
|
-
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
|
|
509
|
-
|
|
583
|
+
/** Native provider-side web-search descriptors supported by the Office surface.
|
|
584
|
+
* Ordinary xcsh and Office host tools do not use this seam; they remain function
|
|
585
|
+
* tools and are preserved by the agent's payload composer. */
|
|
586
|
+
export function officeWebSearchServerTools(modelApi: string): Record<string, unknown>[] {
|
|
587
|
+
switch (modelApi) {
|
|
588
|
+
case "anthropic-messages":
|
|
589
|
+
return [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }];
|
|
590
|
+
case "openai-completions":
|
|
591
|
+
return [{ type: "web_search_preview" }];
|
|
592
|
+
default:
|
|
593
|
+
throw new Error(`Office web search is unsupported for model API ${modelApi}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const OFFICE_GATEWAY_PROVIDER_PATHS = {
|
|
598
|
+
anthropic: "/anthropic",
|
|
599
|
+
litellm: "/api/v1",
|
|
600
|
+
} as const;
|
|
601
|
+
|
|
602
|
+
/** Derive the selected provider's API base from a normalized gateway root. */
|
|
603
|
+
export function providerGatewayBaseUrl(
|
|
604
|
+
gatewayRoot: string,
|
|
605
|
+
provider: keyof typeof OFFICE_GATEWAY_PROVIDER_PATHS,
|
|
606
|
+
): string {
|
|
607
|
+
return `${gatewayRoot}${OFFICE_GATEWAY_PROVIDER_PATHS[provider]}`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** SSRF guard and backward-compatible root normalizer for the `configure` frame's
|
|
611
|
+
* optional gateway URL. Saved full-path URLs are reduced to their HTTPS origin;
|
|
612
|
+
* the selected model then determines the provider path. Loopback/private hosts are
|
|
613
|
+
* intentionally allowed because the target is an operator-chosen internal gateway. */
|
|
510
614
|
export function requireHttpsUrl(raw: string): string {
|
|
511
615
|
let parsed: URL;
|
|
512
616
|
try {
|
|
@@ -517,7 +621,7 @@ export function requireHttpsUrl(raw: string): string {
|
|
|
517
621
|
if (parsed.protocol !== "https:") {
|
|
518
622
|
throw new Error(`configure baseUrl must use https (got "${parsed.protocol}")`);
|
|
519
623
|
}
|
|
520
|
-
return
|
|
624
|
+
return parsed.origin;
|
|
521
625
|
}
|
|
522
626
|
|
|
523
627
|
/** Classify an upstream/provider error into the closed public reason vocabulary.
|
|
@@ -535,6 +639,9 @@ export function classifyChatErrorReason(message: string): ChatErrorReason {
|
|
|
535
639
|
) {
|
|
536
640
|
return "provider-5xx";
|
|
537
641
|
}
|
|
642
|
+
if (/\b(?:401|403)\b|\bunauthorized\b|\bforbidden\b|\bapi key\b|\bauthentication\b|\bcredentials?\b/.test(m)) {
|
|
643
|
+
return "provider-auth";
|
|
644
|
+
}
|
|
538
645
|
if (/\b4\d\d\b|forbidden|unauthorized|invalid model|bad request|not found|too many requests|rate limit/.test(m)) {
|
|
539
646
|
return "provider-4xx";
|
|
540
647
|
}
|
|
@@ -89,8 +89,8 @@ interface ChatRequestBase {
|
|
|
89
89
|
* grants them to the filesystem sandbox for the session and tells the model they
|
|
90
90
|
* are available to read on demand. */
|
|
91
91
|
contextPaths?: string[];
|
|
92
|
-
/** When true, the engine adds
|
|
93
|
-
* turn
|
|
92
|
+
/** When true, the engine adds the active model API's native server-side
|
|
93
|
+
* web-search tool to this turn (the "Search the web" composer toggle). */
|
|
94
94
|
web_search?: boolean;
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -128,6 +128,24 @@ export interface ListSkills {
|
|
|
128
128
|
type: "list_skills";
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Client → engine: enumerate the curated models available to the Office pane. */
|
|
132
|
+
export interface ListModels {
|
|
133
|
+
type: "list_models";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One model option surfaced in the Office composer's model selector. */
|
|
137
|
+
export interface ModelInfo {
|
|
138
|
+
id: string;
|
|
139
|
+
label: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Engine → client: available Office models and the active model id. */
|
|
143
|
+
export interface ModelsList {
|
|
144
|
+
type: "models";
|
|
145
|
+
current: string;
|
|
146
|
+
models: ModelInfo[];
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
/** One skill surfaced to the pane's Skills submenu (name + human description). */
|
|
132
150
|
export interface SkillInfo {
|
|
133
151
|
name: string;
|
|
@@ -196,6 +214,7 @@ export const CHAT_ERROR_REASONS = [
|
|
|
196
214
|
"session-disposed", // the worker session was torn down
|
|
197
215
|
"token-expired", // F5 XC API token expired
|
|
198
216
|
"token-expiring", // F5 XC API token is about to expire
|
|
217
|
+
"provider-auth", // upstream provider rejected its credential
|
|
199
218
|
"provider-4xx", // upstream provider rejected the request (client error)
|
|
200
219
|
"provider-5xx", // upstream provider failed (server error) — retryable
|
|
201
220
|
] as const;
|
|
@@ -278,15 +297,14 @@ export interface SetHostToolsError {
|
|
|
278
297
|
// field. Mirrors the set_host_tools ack/nack shape exactly.
|
|
279
298
|
// ---------------------------------------------------------------------------
|
|
280
299
|
|
|
281
|
-
/** Inbound: the client configures
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* kept. The token lives in session/runtime memory only — never written to disk. */
|
|
300
|
+
/** Inbound: the client configures credentials, selects a model, or both.
|
|
301
|
+
* `baseUrl` is a gateway root and requires a non-empty `token`; xcsh derives its
|
|
302
|
+
* provider path from `model`. A model-only frame reuses xcsh's existing provider
|
|
303
|
+
* credentials. Runtime credentials are never written to disk. */
|
|
286
304
|
export interface Configure {
|
|
287
305
|
type: "configure";
|
|
288
306
|
baseUrl?: string;
|
|
289
|
-
token
|
|
307
|
+
token?: string;
|
|
290
308
|
model?: string;
|
|
291
309
|
}
|
|
292
310
|
|
|
@@ -372,15 +390,22 @@ export function isSetHostTools(msg: Record<string, unknown>): boolean {
|
|
|
372
390
|
return msg.type === "set_host_tools" && Array.isArray(msg.tools);
|
|
373
391
|
}
|
|
374
392
|
|
|
375
|
-
|
|
376
|
-
|
|
393
|
+
export function isListModels(msg: Record<string, unknown>): boolean {
|
|
394
|
+
return msg.type === "list_models";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True for a well-formed `configure` frame. At least a non-empty token or model is
|
|
398
|
+
* required, and a gateway root may never be sent without its token. */
|
|
377
399
|
export function isConfigure(msg: Record<string, unknown>): boolean {
|
|
400
|
+
const hasToken = typeof msg.token === "string" && msg.token.trim().length > 0;
|
|
401
|
+
const hasModel = typeof msg.model === "string" && msg.model.trim().length > 0;
|
|
378
402
|
return (
|
|
379
403
|
msg.type === "configure" &&
|
|
380
|
-
|
|
381
|
-
msg.token.
|
|
404
|
+
(hasToken || hasModel) &&
|
|
405
|
+
(msg.token === undefined || typeof msg.token === "string") &&
|
|
382
406
|
(msg.baseUrl === undefined || typeof msg.baseUrl === "string") &&
|
|
383
|
-
(msg.model === undefined || typeof msg.model === "string")
|
|
407
|
+
(msg.model === undefined || typeof msg.model === "string") &&
|
|
408
|
+
(msg.baseUrl === undefined || hasToken)
|
|
384
409
|
);
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -18,6 +18,7 @@ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
|
18
18
|
import { createAgentSession } from "../sdk";
|
|
19
19
|
import { ContextService } from "../services/xcsh-context";
|
|
20
20
|
import { deriveTenantEnv } from "../services/xcsh-env";
|
|
21
|
+
import { SessionManager } from "../session/session-manager";
|
|
21
22
|
import { resolveBridgeTls } from "./bridge-cert";
|
|
22
23
|
import { ChatHandler } from "./chat-handler";
|
|
23
24
|
import { isPickPath, type PathPicked } from "./chat-protocol";
|
|
@@ -147,6 +148,10 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
|
|
|
147
148
|
const { session } = await deps.createAgentSession({
|
|
148
149
|
cwd,
|
|
149
150
|
hasUI: false,
|
|
151
|
+
// Office conversations can contain private workbook and working-directory
|
|
152
|
+
// data. Keep the entire headless session ephemeral instead of inheriting
|
|
153
|
+
// createAgentSession's file-backed default.
|
|
154
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
150
155
|
toolNames: [...OFFICE_TOOL_NAMES],
|
|
151
156
|
customTools: [],
|
|
152
157
|
// Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
|
|
@@ -299,7 +299,10 @@ export async function getOfficePaneDir(): Promise<string> {
|
|
|
299
299
|
/**
|
|
300
300
|
* Pure request handler: map a URL pathname to a file under `dir` and return it
|
|
301
301
|
* with the content-type inferred from its extension, or a 404. `/` maps to
|
|
302
|
-
* `taskpane.html`.
|
|
302
|
+
* `taskpane.html`. Successful assets are `no-store`: every compiled build uses
|
|
303
|
+
* the same stable URLs, and Excel's WebView otherwise reuses an older pane bundle
|
|
304
|
+
* after a new xcsh binary is sideloaded. Path-traversal is rejected before any
|
|
305
|
+
* filesystem access.
|
|
303
306
|
*/
|
|
304
307
|
export async function handleAssetRequest(pathname: string, dir: string): Promise<Response> {
|
|
305
308
|
const requested = pathname === "/" ? "taskpane.html" : pathname.replace(/^\/+/, "");
|
|
@@ -313,7 +316,14 @@ export async function handleAssetRequest(pathname: string, dir: string): Promise
|
|
|
313
316
|
}
|
|
314
317
|
|
|
315
318
|
const file = Bun.file(fullPath);
|
|
316
|
-
if (await file.exists())
|
|
319
|
+
if (await file.exists()) {
|
|
320
|
+
return new Response(file, {
|
|
321
|
+
headers: {
|
|
322
|
+
"Cache-Control": "no-store",
|
|
323
|
+
"Content-Type": file.type,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
317
327
|
return new Response("Not Found", { status: 404 });
|
|
318
328
|
}
|
|
319
329
|
|
package/src/cli/sandbox-check.ts
CHANGED
|
@@ -168,7 +168,7 @@ function renderReport(report: SandboxCheckReport, json: boolean, verbose: boolea
|
|
|
168
168
|
|
|
169
169
|
/** Run the conformance matrix and report only after every synthetic fixture has been removed. */
|
|
170
170
|
export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promise<SandboxCheckReport> {
|
|
171
|
-
|
|
171
|
+
let backend = containmentStatus(true);
|
|
172
172
|
const checks: SandboxCheckResult[] = [];
|
|
173
173
|
const fixturePaths: string[] = [];
|
|
174
174
|
const knownCleanupLeaves: string[] = [];
|
|
@@ -218,18 +218,20 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
218
218
|
// session parent — which is the operator home for a `~/<workspace>` layout (#2807).
|
|
219
219
|
const liveWorkspace = inheritedWorkspace ?? (await fs.realpath(workspaceInput));
|
|
220
220
|
const liveHome = inheritedHome ?? (await fs.realpath(homeInput));
|
|
221
|
-
|
|
221
|
+
const liveSystemTmp = await fs.realpath(os.tmpdir());
|
|
222
|
+
const liveSessionParent = path.dirname(liveWorkspace);
|
|
223
|
+
const liveAccountRoot = path.dirname(liveHome);
|
|
224
|
+
redactions.push(
|
|
225
|
+
[liveWorkspace, "<workspace>"],
|
|
226
|
+
[liveHome, "<operator-home>"],
|
|
227
|
+
[liveSystemTmp, "<system-temp>"],
|
|
228
|
+
[liveSessionParent, "<session-parent>"],
|
|
229
|
+
[liveAccountRoot, "<account-container>"],
|
|
230
|
+
);
|
|
222
231
|
if (inheritedSibling !== undefined) redactions.push([inheritedSibling, "<session-parent>/<synthetic-sibling>"]);
|
|
223
232
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// without also reopening the protected stores. BashTool therefore prepares one host-owned child
|
|
227
|
-
// before confinement; keep all exact-home live fixtures beneath that already-granted directory.
|
|
228
|
-
const liveWritableRoot =
|
|
229
|
-
inheritedProfile && liveWorkspace === liveHome && inheritedSibling !== undefined
|
|
230
|
-
? inheritedSibling
|
|
231
|
-
: liveWorkspace;
|
|
232
|
-
const fixtureBase = inheritedProfile ? liveWritableRoot : await fs.realpath(os.tmpdir());
|
|
233
|
+
const liveWritableRoot = liveWorkspace;
|
|
234
|
+
const fixtureBase = inheritedProfile ? liveWritableRoot : liveSystemTmp;
|
|
233
235
|
fixtureRoot = await fs.mkdtemp(path.join(fixtureBase, ".xcsh-sandbox-check-policy-"));
|
|
234
236
|
fixturePaths.push(fixtureRoot);
|
|
235
237
|
redactions.push([fixtureRoot, "<synthetic-root>"]);
|
|
@@ -262,23 +264,15 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
262
264
|
fsRoot: fixtureRoot,
|
|
263
265
|
leakRoots: [sessionStore, memoryStore],
|
|
264
266
|
});
|
|
267
|
+
const liveFence = buildContainmentFence({ workspace: liveWorkspace, home: liveHome });
|
|
268
|
+
backend = containmentStatus(true, process.platform, undefined, liveFence);
|
|
265
269
|
|
|
266
270
|
await check("structured tools share the boundary", () => {
|
|
267
271
|
const blocked = [
|
|
268
272
|
evaluateToolCall({ toolName: "read", input: { file_path: workspaces }, cwd: workspace, fence }),
|
|
269
273
|
evaluateToolCall({ toolName: "find", input: { pattern: `${accountRoot}/**/*` }, cwd: workspace, fence }),
|
|
270
|
-
evaluateToolCall({
|
|
271
|
-
|
|
272
|
-
input: { file_path: path.join(otherSession, "state.jsonl") },
|
|
273
|
-
cwd: workspace,
|
|
274
|
-
fence,
|
|
275
|
-
}),
|
|
276
|
-
evaluateToolCall({
|
|
277
|
-
toolName: "read",
|
|
278
|
-
input: { file_path: path.join(otherMemory, "MEMORY.md") },
|
|
279
|
-
cwd: workspace,
|
|
280
|
-
fence,
|
|
281
|
-
}),
|
|
274
|
+
evaluateToolCall({ toolName: "read", input: { file_path: sessionStore }, cwd: workspace, fence }),
|
|
275
|
+
evaluateToolCall({ toolName: "find", input: { pattern: `${memoryStore}/**/*` }, cwd: workspace, fence }),
|
|
282
276
|
];
|
|
283
277
|
const allowed = [
|
|
284
278
|
evaluateToolCall({
|
|
@@ -300,6 +294,18 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
300
294
|
cwd: workspace,
|
|
301
295
|
fence,
|
|
302
296
|
}),
|
|
297
|
+
evaluateToolCall({
|
|
298
|
+
toolName: "read",
|
|
299
|
+
input: { file_path: path.join(otherSession, "state.jsonl") },
|
|
300
|
+
cwd: workspace,
|
|
301
|
+
fence,
|
|
302
|
+
}),
|
|
303
|
+
evaluateToolCall({
|
|
304
|
+
toolName: "read",
|
|
305
|
+
input: { file_path: path.join(otherMemory, "MEMORY.md") },
|
|
306
|
+
cwd: workspace,
|
|
307
|
+
fence,
|
|
308
|
+
}),
|
|
303
309
|
];
|
|
304
310
|
const passed = blocked.every(result => result.block) && allowed.every(result => !result.block);
|
|
305
311
|
return passed
|
|
@@ -385,6 +391,77 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
385
391
|
);
|
|
386
392
|
});
|
|
387
393
|
|
|
394
|
+
await check("system temp supports direct file creation", async () => {
|
|
395
|
+
const displayPath = "<system-temp>/<synthetic-fixture>";
|
|
396
|
+
const template = path.join(liveSystemTmp, ".xcsh-sandbox-check-tmp-XXXXXX");
|
|
397
|
+
const command =
|
|
398
|
+
`probe=$(mktemp ${quote(template)}) || exit $?; ` +
|
|
399
|
+
`trap 'rm -f "$probe"' EXIT; printf temporary > "$probe" && ` +
|
|
400
|
+
`test "$(cat "$probe")" = temporary && rm "$probe"`;
|
|
401
|
+
const result = await shellProbe(
|
|
402
|
+
command,
|
|
403
|
+
liveWorkspace,
|
|
404
|
+
inheritedProfile ? undefined : liveFence,
|
|
405
|
+
abortController.signal,
|
|
406
|
+
);
|
|
407
|
+
return shellOutcome(
|
|
408
|
+
result,
|
|
409
|
+
true,
|
|
410
|
+
"live profile must allow direct system-temp creation and removal",
|
|
411
|
+
displayPath,
|
|
412
|
+
redactions,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await check("system temp remains enumerable", async () => {
|
|
417
|
+
const result = await shellProbe(
|
|
418
|
+
`ls ${quote(liveSystemTmp)} > /dev/null`,
|
|
419
|
+
liveWorkspace,
|
|
420
|
+
inheritedProfile ? undefined : liveFence,
|
|
421
|
+
abortController.signal,
|
|
422
|
+
);
|
|
423
|
+
return shellOutcome(
|
|
424
|
+
result,
|
|
425
|
+
true,
|
|
426
|
+
"live profile must preserve normal system-temp enumeration",
|
|
427
|
+
"<system-temp>",
|
|
428
|
+
redactions,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await check("operator home remains enumerable", async () => {
|
|
433
|
+
const result = await shellProbe(
|
|
434
|
+
`ls ${quote(liveHome)} > /dev/null`,
|
|
435
|
+
liveWorkspace,
|
|
436
|
+
inheritedProfile ? undefined : liveFence,
|
|
437
|
+
abortController.signal,
|
|
438
|
+
);
|
|
439
|
+
return shellOutcome(
|
|
440
|
+
result,
|
|
441
|
+
true,
|
|
442
|
+
"live profile must preserve normal operator-home enumeration",
|
|
443
|
+
"<operator-home>",
|
|
444
|
+
redactions,
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await check("filesystem root remains enumerable", async () => {
|
|
449
|
+
const filesystemRoot = path.parse(liveWorkspace).root;
|
|
450
|
+
const result = await shellProbe(
|
|
451
|
+
`ls ${quote(filesystemRoot)} > /dev/null`,
|
|
452
|
+
liveWorkspace,
|
|
453
|
+
inheritedProfile ? undefined : liveFence,
|
|
454
|
+
abortController.signal,
|
|
455
|
+
);
|
|
456
|
+
return shellOutcome(
|
|
457
|
+
result,
|
|
458
|
+
true,
|
|
459
|
+
"live profile must preserve normal filesystem-root enumeration",
|
|
460
|
+
"<filesystem-root>",
|
|
461
|
+
redactions,
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
388
465
|
await check("named sibling remains reachable", async () => {
|
|
389
466
|
const displayPath = "<session-parent>/<synthetic-sibling>";
|
|
390
467
|
let liveSibling = inheritedSibling;
|
|
@@ -413,17 +490,21 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
413
490
|
});
|
|
414
491
|
|
|
415
492
|
if (backend.osEnforced) {
|
|
416
|
-
await check("session parent
|
|
493
|
+
await check("session parent discovery respects operator home", async () => {
|
|
494
|
+
const probeParent = inheritedProfile ? liveSessionParent : workspaces;
|
|
495
|
+
const parentIsOperatorHome = probeParent === liveHome;
|
|
417
496
|
const result = await shellProbe(
|
|
418
|
-
`ls ${quote(
|
|
497
|
+
`ls ${quote(probeParent)} > /dev/null`,
|
|
419
498
|
workspace,
|
|
420
|
-
fence,
|
|
499
|
+
inheritedProfile ? undefined : fence,
|
|
421
500
|
abortController.signal,
|
|
422
501
|
);
|
|
423
502
|
return shellOutcome(
|
|
424
503
|
result,
|
|
425
|
-
|
|
426
|
-
|
|
504
|
+
parentIsOperatorHome,
|
|
505
|
+
parentIsOperatorHome
|
|
506
|
+
? "operator home must remain enumerable when it is the session parent"
|
|
507
|
+
: "synthetic session parent enumeration must be refused",
|
|
427
508
|
"<synthetic-session-parent>",
|
|
428
509
|
redactions,
|
|
429
510
|
);
|
|
@@ -469,10 +550,11 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
469
550
|
);
|
|
470
551
|
});
|
|
471
552
|
await check("account container cannot be enumerated", async () => {
|
|
553
|
+
const probeAccountRoot = inheritedProfile ? liveAccountRoot : accountRoot;
|
|
472
554
|
const result = await shellProbe(
|
|
473
|
-
`ls ${quote(
|
|
555
|
+
`ls ${quote(probeAccountRoot)} > /dev/null`,
|
|
474
556
|
workspace,
|
|
475
|
-
fence,
|
|
557
|
+
inheritedProfile ? undefined : fence,
|
|
476
558
|
abortController.signal,
|
|
477
559
|
);
|
|
478
560
|
return shellOutcome(
|
|
@@ -493,74 +575,94 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
493
575
|
redactions,
|
|
494
576
|
);
|
|
495
577
|
});
|
|
496
|
-
await check("cross-session stores
|
|
497
|
-
|
|
498
|
-
|
|
578
|
+
await check("cross-session stores hide listings and keep named access", async () => {
|
|
579
|
+
let probeStore = sessionStore;
|
|
580
|
+
let knownPaths = [path.join(otherSession, "state.jsonl"), path.join(otherMemory, "MEMORY.md")];
|
|
581
|
+
let probeFence: ContainmentFence | undefined = fence;
|
|
582
|
+
if (inheritedProfile) {
|
|
583
|
+
const livePrivateContainer = path.join(liveSystemTmp, "xcsh-local");
|
|
584
|
+
try {
|
|
585
|
+
await fs.mkdir(livePrivateContainer, { recursive: true });
|
|
586
|
+
const livePrivateFixture = await fs.mkdtemp(
|
|
587
|
+
path.join(livePrivateContainer, ".xcsh-sandbox-check-private-"),
|
|
588
|
+
);
|
|
589
|
+
fixturePaths.push(livePrivateFixture);
|
|
590
|
+
redactions.push(
|
|
591
|
+
[livePrivateContainer, "<live-private-container>"],
|
|
592
|
+
[livePrivateFixture, "<live-private-container>/<synthetic-session>"],
|
|
593
|
+
);
|
|
594
|
+
const knownState = path.join(livePrivateFixture, "state.jsonl");
|
|
595
|
+
await Bun.write(knownState, "synthetic\n");
|
|
596
|
+
probeStore = livePrivateContainer;
|
|
597
|
+
knownPaths = [knownState];
|
|
598
|
+
probeFence = undefined;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
return exceptionOutcome(
|
|
601
|
+
"create a known path in the live private temp container",
|
|
602
|
+
"<live-private-container>/<synthetic-session>",
|
|
603
|
+
error,
|
|
604
|
+
redactions,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const sessionListing = await shellProbe(
|
|
609
|
+
`ls ${quote(probeStore)} > /dev/null`,
|
|
499
610
|
workspace,
|
|
500
|
-
|
|
611
|
+
probeFence,
|
|
501
612
|
abortController.signal,
|
|
502
613
|
);
|
|
503
614
|
const sessionOutcome = shellOutcome(
|
|
504
|
-
|
|
615
|
+
sessionListing,
|
|
505
616
|
false,
|
|
506
|
-
"synthetic
|
|
507
|
-
"<synthetic-session-store
|
|
617
|
+
"synthetic session-store listing must be refused",
|
|
618
|
+
"<synthetic-session-store>",
|
|
508
619
|
redactions,
|
|
509
620
|
);
|
|
510
621
|
if (!sessionOutcome.passed) return sessionOutcome;
|
|
511
622
|
|
|
512
|
-
const
|
|
513
|
-
`cat ${quote
|
|
623
|
+
const namedReads = await shellProbe(
|
|
624
|
+
`cat ${knownPaths.map(quote).join(" ")} > /dev/null`,
|
|
514
625
|
workspace,
|
|
515
|
-
|
|
626
|
+
probeFence,
|
|
516
627
|
abortController.signal,
|
|
517
628
|
);
|
|
518
629
|
return shellOutcome(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
"synthetic
|
|
522
|
-
"<synthetic-
|
|
630
|
+
namedReads,
|
|
631
|
+
true,
|
|
632
|
+
"known synthetic cross-session paths must keep operator access",
|
|
633
|
+
"<synthetic-session-store>/<known-path>",
|
|
523
634
|
redactions,
|
|
524
635
|
);
|
|
525
636
|
});
|
|
526
637
|
} else {
|
|
527
638
|
for (const name of [
|
|
528
|
-
"session parent
|
|
639
|
+
"session parent discovery respects operator home",
|
|
529
640
|
"explicit grant restores parent enumeration",
|
|
530
641
|
"account container cannot be enumerated",
|
|
531
642
|
"named other account remains reachable",
|
|
532
|
-
"cross-session stores
|
|
643
|
+
"cross-session stores hide listings and keep named access",
|
|
533
644
|
]) {
|
|
534
645
|
add(name, "SKIP", "OS enforcement backend unavailable; path=<probe>; errno=unsupported");
|
|
535
646
|
}
|
|
536
647
|
}
|
|
537
648
|
|
|
538
|
-
await check("operator home
|
|
649
|
+
await check("operator home supports direct file creation", async () => {
|
|
539
650
|
const displayPath = "<operator-home>/<synthetic-fixture>";
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// Standalone and Seatbelt checks retain the direct-home probe.
|
|
546
|
-
const configBase =
|
|
547
|
-
inheritedProfile && backend.backend === "landlock" ? path.join(liveHome, ".config", "gh") : liveHome;
|
|
548
|
-
liveConfig = await fs.mkdtemp(path.join(configBase, ".xcsh-sandbox-check-home-"));
|
|
549
|
-
fixturePaths.push(liveConfig);
|
|
550
|
-
redactions.push([liveConfig, displayPath]);
|
|
551
|
-
} catch (error) {
|
|
552
|
-
return exceptionOutcome("create operator-home fixture", displayPath, error, redactions);
|
|
553
|
-
}
|
|
651
|
+
const template = path.join(liveHome, ".xcsh-sandbox-check-home-XXXXXX");
|
|
652
|
+
const command =
|
|
653
|
+
`probe=$(mktemp ${quote(template)}) || exit $?; ` +
|
|
654
|
+
`trap 'rm -f "$probe"' EXIT; printf operator > "$probe" && ` +
|
|
655
|
+
`test "$(cat "$probe")" = operator && rm "$probe"`;
|
|
554
656
|
const result = await shellProbe(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
undefined,
|
|
657
|
+
command,
|
|
658
|
+
liveWorkspace,
|
|
659
|
+
inheritedProfile ? undefined : liveFence,
|
|
558
660
|
abortController.signal,
|
|
559
661
|
);
|
|
560
662
|
return shellOutcome(
|
|
561
663
|
result,
|
|
562
664
|
true,
|
|
563
|
-
"live profile must allow operator-home
|
|
665
|
+
"live profile must allow direct operator-home creation and removal",
|
|
564
666
|
displayPath,
|
|
565
667
|
redactions,
|
|
566
668
|
);
|
|
@@ -157,16 +157,16 @@ const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
|
|
|
157
157
|
/**
|
|
158
158
|
* Binary-baked default model role. Ships in the binary so a fresh install needs
|
|
159
159
|
* NO `~/.xcsh/agent/config.yml` — `/login` only supplies the (PII) proxy URL + key.
|
|
160
|
-
*
|
|
161
|
-
* Keep the effort explicit in the role so returning
|
|
162
|
-
* inherit that role's effort.
|
|
160
|
+
* Claude Opus 5 High is the vision-capable production default through the
|
|
161
|
+
* Anthropic route on LiteLLM. Keep the effort explicit in the role so returning
|
|
162
|
+
* from a lower-effort role cannot inherit that role's effort.
|
|
163
163
|
*/
|
|
164
|
-
export const DEFAULT_MODEL_ROLE = "
|
|
164
|
+
export const DEFAULT_MODEL_ROLE = "anthropic/claude-opus-5:high";
|
|
165
165
|
/** Fast role for lightweight work (commit messages, titles, memory summaries). */
|
|
166
166
|
const SMOL_MODEL_ROLE = "litellm/gpt-5.6-sol:low";
|
|
167
167
|
/**
|
|
168
|
-
* Baked role map.
|
|
169
|
-
*
|
|
168
|
+
* Baked role map. `smol` keeps GPT-5.6 Sol at low effort for latency-sensitive
|
|
169
|
+
* work; both `default` and `slow` restore vision-capable Claude Opus 5 High.
|
|
170
170
|
*/
|
|
171
171
|
const DEFAULT_MODEL_ROLES: Record<string, string> = {
|
|
172
172
|
default: DEFAULT_MODEL_ROLE,
|
|
@@ -1810,22 +1810,19 @@ export const SETTINGS_SCHEMA = {
|
|
|
1810
1810
|
ui: { tab: "providers", label: "Hide Secrets", description: "Obfuscate secrets before sending to AI providers" },
|
|
1811
1811
|
},
|
|
1812
1812
|
|
|
1813
|
-
// Session filesystem isolation
|
|
1814
|
-
//
|
|
1815
|
-
// allowlist, so concurrent sessions in different customer folders cannot read or
|
|
1816
|
-
// write each other's files, secrets, or memory. See src/sandbox/.
|
|
1813
|
+
// Session filesystem isolation is a discovery courtesy, not a user-rights policy. It hides selected
|
|
1814
|
+
// cross-session container listings while named paths retain the operator's normal access.
|
|
1817
1815
|
"sandbox.enabled": {
|
|
1818
1816
|
type: "boolean",
|
|
1819
1817
|
default: true,
|
|
1820
1818
|
ui: {
|
|
1821
1819
|
tab: "sandbox",
|
|
1822
1820
|
label: "Filesystem isolation",
|
|
1823
|
-
description: "
|
|
1821
|
+
description: "Hide cross-session container listings without restricting named operator access",
|
|
1824
1822
|
},
|
|
1825
1823
|
},
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
// (other sessions' memories/sessions and the shared tenant contexts) always win.
|
|
1824
|
+
// Historical names retained for compatibility. Either list now restores discovery and leaves the
|
|
1825
|
+
// operator's ordinary read/write rights unchanged; `--allow-path <dir>` maps into both.
|
|
1829
1826
|
"sandbox.allowRead": { type: "array", default: [] as string[] },
|
|
1830
1827
|
"sandbox.allowWrite": { type: "array", default: [] as string[] },
|
|
1831
1828
|
|
|
@@ -6,15 +6,13 @@ import { resolveSessionFence } from "../../../sandbox/session-fence";
|
|
|
6
6
|
/**
|
|
7
7
|
* Session filesystem sandbox (bundled, default-on).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* concurrent sessions in different customer folders cannot read or write each other's
|
|
12
|
-
* files, secrets, or memory. Enforcement is a `tool_call` gate: the extension wrapper
|
|
9
|
+
* Removes casual cross-session discovery from model-invoked filesystem tools while preserving the
|
|
10
|
+
* operator's normal rights on every named path. Enforcement is a `tool_call` gate: the extension wrapper
|
|
13
11
|
* blocks the tool when this returns `{ block: true }`, and fails safe (a thrown handler
|
|
14
12
|
* also blocks).
|
|
15
13
|
*
|
|
16
14
|
* The boundary is derived from `ctx.cwd` (always the live session's directory) plus the
|
|
17
|
-
* `sandbox.*` settings. Controlled by `sandbox.enabled` (default true); widened per run
|
|
15
|
+
* `sandbox.*` settings. Controlled by `sandbox.enabled` (default true); discovery is widened per run
|
|
18
16
|
* with `--allow-path` / `--no-sandbox` or the `sandbox.allow*` settings.
|
|
19
17
|
*/
|
|
20
18
|
export default function sandboxGuard(pi: ExtensionAPI): void {
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.4.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.4.2",
|
|
21
|
+
"commit": "b85f1346a0ed047c05151f127ba9628612b386a3",
|
|
22
|
+
"shortCommit": "b85f134",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.4.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.4.2",
|
|
25
|
+
"commitDate": "2026-08-05T05:46:35Z",
|
|
26
|
+
"buildDate": "2026-08-05T06:17:35.937Z",
|
|
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/v20.4.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/b85f1346a0ed047c05151f127ba9628612b386a3",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.4.2"
|
|
33
33
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - xcsh://plugin/<name>/contract -> the manifest verbatim
|
|
12
12
|
* - xcsh://plugin/<name>/schema -> file at manifest key "schema"
|
|
13
13
|
* - xcsh://plugin/<name>/engine -> manifest engine block, entry resolved to abs path
|
|
14
|
-
* - xcsh://plugin/<name>/file/<relpath> -> any root-relative file
|
|
14
|
+
* - xcsh://plugin/<name>/file/<relpath> -> any root-relative file or directory
|
|
15
15
|
*
|
|
16
16
|
* Text resources resolve to their CONTENTS. A declared binary resource (a .xlsx
|
|
17
17
|
* template, an image) resolves to its LOCATION — `{binary, path, bytes}` — because a
|
|
@@ -210,15 +210,32 @@ export class PluginResolver {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
/**
|
|
213
|
-
* Resolve one on-disk
|
|
213
|
+
* Resolve one on-disk path, as contents for text and as a locator for binary
|
|
214
|
+
* files or directories.
|
|
214
215
|
* Shared by the `file/<relpath>` route and the named-key route so the two cannot
|
|
215
216
|
* disagree about what a `.xlsx` is.
|
|
216
217
|
*/
|
|
217
218
|
async #resource(url: InternalUrl, target: string): Promise<InternalResource> {
|
|
219
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
|
220
|
+
try {
|
|
221
|
+
stat = await fs.stat(target);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`File not found: ${target}`);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (stat.isDirectory()) {
|
|
227
|
+
const body = JSON.stringify({ directory: true });
|
|
228
|
+
return {
|
|
229
|
+
url: url.href,
|
|
230
|
+
content: body,
|
|
231
|
+
contentType: "application/json",
|
|
232
|
+
size: Buffer.byteLength(body, "utf-8"),
|
|
233
|
+
sourcePath: target,
|
|
234
|
+
notes: ["Directory resource: this is its location. Inspect it with a filesystem tool."],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
218
237
|
if (isBinaryResource(target)) {
|
|
219
|
-
const
|
|
220
|
-
if (!(await file.exists())) throw new Error(`File not found: ${target}`);
|
|
221
|
-
const body = JSON.stringify({ binary: true, path: target, bytes: file.size });
|
|
238
|
+
const body = JSON.stringify({ binary: true, path: target, bytes: stat.size });
|
|
222
239
|
return {
|
|
223
240
|
url: url.href,
|
|
224
241
|
content: body,
|
|
@@ -15,13 +15,6 @@ export interface LiteLLMLoginModelChoice extends LoginModelChoice {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export const LITELLM_LOGIN_MODEL_CHOICES: readonly LiteLLMLoginModelChoice[] = [
|
|
18
|
-
{
|
|
19
|
-
label: "GPT-5.6 Sol",
|
|
20
|
-
description: "OpenAI-compatible model with high reasoning",
|
|
21
|
-
provider: "litellm",
|
|
22
|
-
modelId: "gpt-5.6-sol",
|
|
23
|
-
thinkingLevel: ThinkingLevel.High,
|
|
24
|
-
},
|
|
25
18
|
{
|
|
26
19
|
label: "Claude Opus 5",
|
|
27
20
|
description: "Anthropic Messages model with high reasoning",
|
|
@@ -29,6 +22,13 @@ export const LITELLM_LOGIN_MODEL_CHOICES: readonly LiteLLMLoginModelChoice[] = [
|
|
|
29
22
|
modelId: "claude-opus-5",
|
|
30
23
|
thinkingLevel: ThinkingLevel.High,
|
|
31
24
|
},
|
|
25
|
+
{
|
|
26
|
+
label: "GPT-5.6 Sol",
|
|
27
|
+
description: "OpenAI-compatible model with high reasoning",
|
|
28
|
+
provider: "litellm",
|
|
29
|
+
modelId: "gpt-5.6-sol",
|
|
30
|
+
thinkingLevel: ThinkingLevel.High,
|
|
31
|
+
},
|
|
32
32
|
];
|
|
33
33
|
|
|
34
34
|
export const GOOGLE_ANTIGRAVITY_LOGIN_MODEL_CHOICE: LoginModelChoice = {
|
|
@@ -16,9 +16,9 @@ followed symlinks — so how a path is spelled does not change what is reachable
|
|
|
16
16
|
- Cross-tenant isolation removes the discovery step. The session container, local-account containers,
|
|
17
17
|
data roots, and mounted-data containers cannot be enumerated, but a descendant path the operator
|
|
18
18
|
names directly can still be read, written, or entered. An explicit read grant restores enumeration.
|
|
19
|
-
- Xcsh-private cross-session stores
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
- Xcsh-private cross-session stores follow the same discovery boundary: their container cannot be
|
|
20
|
+
enumerated, while a descendant path the operator names directly keeps the operator's normal rights.
|
|
21
|
+
This preserves `/tmp`, home, credentials, package managers, and ordinary tooling without workarounds.
|
|
22
22
|
|
|
23
23
|
Structured filesystem tools and the `bash` runtime consult the same fence. Bash command text is not
|
|
24
24
|
scanned for path-looking strings; the operating system decides when a process actually opens a path.
|
|
@@ -33,10 +33,10 @@ operator. The rule of thumb is that if a file belongs to someone other than the
|
|
|
33
33
|
working with, it is not yours to read.
|
|
34
34
|
|
|
35
35
|
{{#if containment.landlock}}
|
|
36
|
-
Three things behave differently under this backend, and none
|
|
36
|
+
Three things behave differently under this backend, and none is a bug to work around:
|
|
37
37
|
|
|
38
|
-
- `ls /` can fail because a kernel rule cannot expose a directory
|
|
39
|
-
|
|
38
|
+
- `ls /` can fail because a kernel rule cannot expose a directory whose descendants have different
|
|
39
|
+
enumeration rights. Listing a specific reachable directory works normally.
|
|
40
40
|
- `sudo` and other setuid programs do not work, because confining a process requires giving up the
|
|
41
41
|
ability to gain privileges.
|
|
42
42
|
- Interactive terminal programs (`top`, `less`, an interactive `ssh`) run without a real terminal here,
|
|
@@ -47,7 +47,15 @@ Three things behave differently under this backend, and none of them is a bug to
|
|
|
47
47
|
{{/if}}
|
|
48
48
|
{{/if}}
|
|
49
49
|
{{else}}
|
|
50
|
-
|
|
50
|
+
{{#if containment.discoveryOnly}}
|
|
51
|
+
This Linux session deliberately does **not** arm Landlock for its discovery-only profile. Landlock
|
|
52
|
+
cannot hide one nested directory listing without also breaking ordinary ancestor listings such as
|
|
53
|
+
`ls ~`, `ls /tmp`, and `ls /`; arming it also disables PTYs and setuid tools such as `sudo`. Those
|
|
54
|
+
costs would turn a cross-context courtesy into a user-rights control.
|
|
55
|
+
|
|
56
|
+
{{else}}
|
|
57
|
+
This session is **not using an OS-level backend**, so for `bash` the boundary is enforced only by
|
|
58
|
+
{{/if}}
|
|
51
59
|
precise pre-checks for an explicit `cwd`, literal redirections, known write operands, and literal
|
|
52
60
|
directory changes. Command and source text are never scanned for path-looking strings.
|
|
53
61
|
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
* profile could not even `execvp /bin/cat`.
|
|
8
8
|
*
|
|
9
9
|
* So the fence is gentle. It leaves `/usr`, `/tmp`, package caches, the network and process execution
|
|
10
|
-
* alone. Its cross-tenant courtesy removes discovery by enumerating session, account, and
|
|
11
|
-
* containers while keeping named operator access
|
|
12
|
-
* recursively unless the operator grants it explicitly (#2931).
|
|
10
|
+
* alone. Its cross-tenant courtesy removes discovery by enumerating session, account, data, and
|
|
11
|
+
* xcsh-private containers while keeping named operator access (#2931, #2952).
|
|
13
12
|
*
|
|
14
13
|
* Produced declaratively rather than as an ordered rule list, because the two backends disagree about
|
|
15
14
|
* order: seatbelt evaluates rules in sequence with the last match winning, while Landlock only grants
|
|
@@ -375,12 +374,14 @@ function otherFilesystemRoots(fsRoot: string): string[] {
|
|
|
375
374
|
*
|
|
376
375
|
* `local://` content lands at `<tmp>/xcsh-local/<sessionId>` (`internal-urls/local-protocol.ts`) and a
|
|
377
376
|
* task's artifacts at `<tmp>/xcsh-tasks/<id>` (`task/index.ts`) whenever no session artifacts dir is
|
|
378
|
-
* configured. Those are the same class as `~/.xcsh/agent/sessions` —
|
|
379
|
-
*
|
|
377
|
+
* configured. Those are the same class as `~/.xcsh/agent/sessions` — another session's working notes —
|
|
378
|
+
* so their parent listings belong in the leak roots rather than being covered incidentally.
|
|
380
379
|
*
|
|
381
380
|
* Nothing else in the temp dir is touched: `xcsh://about` promises `/tmp` is reachable, and refusing it
|
|
382
|
-
* wholesale is the false refusal #2582 removed.
|
|
383
|
-
* `
|
|
381
|
+
* wholesale is the false refusal #2582 removed. These roots lose enumeration only. A recursive deny
|
|
382
|
+
* beneath `/tmp` makes Landlock split that writable parent, which prevents ordinary programs from
|
|
383
|
+
* creating a direct child there (#2952). Named access therefore keeps the operator's normal rights,
|
|
384
|
+
* while the session's own local root remains discoverable through its known path.
|
|
384
385
|
*
|
|
385
386
|
* **Two fixed parents, deliberately never enumerated.** The first version listed the temp dir looking for
|
|
386
387
|
* `xcsh-task-*` siblings, which cost 15ms of a 25-42ms fence build on a 17k-entry temp directory — per
|
|
@@ -475,7 +476,10 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
|
|
|
475
476
|
const parentExplicitlyReadable = [...extraResolved, ...readOnlyResolved].some(root =>
|
|
476
477
|
pathIsWithin(root, parentToProtect),
|
|
477
478
|
);
|
|
478
|
-
|
|
479
|
+
// Home itself is an operator workspace, not a customer-container boundary. Hiding its listing when a
|
|
480
|
+
// project is a direct child made ordinary shell navigation fail even on Seatbelt. Deeper project
|
|
481
|
+
// containers are still protected, but the operator always retains a normal `ls ~` experience.
|
|
482
|
+
if (parentToProtect !== home && !tooBroadToDeny(parentToProtect, fsRoot) && !parentExplicitlyReadable) {
|
|
479
483
|
denyEnumerate.add(parentToProtect);
|
|
480
484
|
}
|
|
481
485
|
|
|
@@ -532,25 +536,27 @@ export function buildContainmentFence(options: ContainmentOptions): ContainmentF
|
|
|
532
536
|
denyEnumerate.add(resolved);
|
|
533
537
|
}
|
|
534
538
|
|
|
535
|
-
// Cross-session leak roots
|
|
536
|
-
//
|
|
537
|
-
//
|
|
539
|
+
// Cross-session leak roots lose their exact directory listing, just like sibling workspace and
|
|
540
|
+
// account containers. Named descendants keep the operator's normal filesystem rights. This is
|
|
541
|
+
// deliberate rather than a weaker approximation: Landlock is allow-only, so recursively denying a
|
|
542
|
+
// child of `/tmp` or home prevents creating any new direct child in that parent (#2952). A professional
|
|
543
|
+
// tool must not require TMPDIR workarounds or a pre-created home subdirectory merely to run.
|
|
538
544
|
//
|
|
539
|
-
// Emitted even when absent: a
|
|
540
|
-
//
|
|
545
|
+
// Emitted even when absent: a root created after the session starts must already have its listing
|
|
546
|
+
// protected. This also covers relocated agent state without enumerating home or the OS temp dir.
|
|
541
547
|
const leaks = options.leakRoots ?? [
|
|
542
548
|
getMemoriesDir(),
|
|
543
549
|
getSessionsDir(),
|
|
544
550
|
getXCSHContextsDir(),
|
|
545
551
|
...sharedTempLeakRoots(),
|
|
546
552
|
];
|
|
547
|
-
const
|
|
553
|
+
const explicitlyReadable = [...extraResolved, ...readOnlyResolved];
|
|
548
554
|
for (const leak of leaks) {
|
|
549
555
|
const resolved = canonicalThroughExisting(leak);
|
|
550
|
-
// A grant at or above a private root
|
|
551
|
-
//
|
|
552
|
-
if (
|
|
553
|
-
|
|
556
|
+
// A full or read grant at or above a private root explicitly restores its listing. A write-only
|
|
557
|
+
// grant does not imply permission to discover entries, so it leaves this exact protection intact.
|
|
558
|
+
if (explicitlyReadable.some(root => pathIsWithin(root, resolved))) continue;
|
|
559
|
+
denyEnumerate.add(resolved);
|
|
554
560
|
}
|
|
555
561
|
|
|
556
562
|
return {
|
|
@@ -602,6 +608,8 @@ export interface ContainmentStatus {
|
|
|
602
608
|
readonly backend: ContainmentBackend;
|
|
603
609
|
/** True when the kernel enforces it, false when only precise tool-call pre-checks run. */
|
|
604
610
|
readonly osEnforced: boolean;
|
|
611
|
+
/** Linux discovery-only profiles stay scanner-only so Landlock cannot remove ordinary ancestor listings. */
|
|
612
|
+
readonly discoveryOnly?: true;
|
|
605
613
|
/**
|
|
606
614
|
* Set when the backend enforces reads and writes but cannot govern truncation.
|
|
607
615
|
*
|
|
@@ -625,14 +633,34 @@ export interface ContainmentStatus {
|
|
|
625
633
|
*
|
|
626
634
|
* Deliberately not surfaced at startup or anywhere in the TUI — the operator asked for no UI change.
|
|
627
635
|
*/
|
|
636
|
+
/**
|
|
637
|
+
* Whether Linux needs to arm Landlock for this fence.
|
|
638
|
+
*
|
|
639
|
+
* Exact enumeration denies are the production session courtesy, but Landlock cannot express one without
|
|
640
|
+
* also removing READ_DIR from every ancestor. Arming it made `ls ~`, `ls /tmp`, and `ls /` fail and also
|
|
641
|
+
* set `no_new_privs`, disabling sudo. Keep those checks in brush and the structured-tool gate. Recursive
|
|
642
|
+
* or directional low-level policies still require the kernel backend and retain their stricter contract.
|
|
643
|
+
*/
|
|
644
|
+
export function requiresLandlock(fence: ContainmentFence): boolean {
|
|
645
|
+
return fence.deny.length > 0 || fence.allowReadOnly.length > 0 || fence.allowWriteOnly.length > 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
628
648
|
export function containmentStatus(
|
|
629
649
|
enabled: boolean,
|
|
630
650
|
platform: string = process.platform,
|
|
631
651
|
probe: () => { backend: string; truncateHandled?: boolean } | undefined = probeNativeBackend,
|
|
652
|
+
fence?: ContainmentFence,
|
|
632
653
|
): ContainmentStatus {
|
|
633
654
|
if (!enabled) return { enabled: false, backend: "disabled", osEnforced: false };
|
|
634
655
|
// macOS always has seatbelt, so there is nothing to ask.
|
|
635
656
|
if (platform === "darwin") return { enabled: true, backend: "seatbelt", osEnforced: true };
|
|
657
|
+
// Landlock is subtree-based. Applying it to an exact-listing-only courtesy removes normal access to
|
|
658
|
+
// ancestor listings and sets no_new_privs even though it cannot faithfully enforce the intended rule.
|
|
659
|
+
// Do not even probe in this common path: avoiding the syscall is part of keeping the sandbox off the
|
|
660
|
+
// command's latency path.
|
|
661
|
+
if (platform === "linux" && fence !== undefined && !requiresLandlock(fence)) {
|
|
662
|
+
return { enabled: true, backend: "scanner-only", osEnforced: false, discoveryOnly: true };
|
|
663
|
+
}
|
|
636
664
|
// Everywhere else the answer cannot be inferred from the platform name. Landlock can be compiled
|
|
637
665
|
// out of the kernel, left out of its boot-time LSM list, or too old to allow cross-directory
|
|
638
666
|
// rename — and none of that is visible from `process.platform`. Asking the native layer is the
|
|
@@ -67,7 +67,7 @@ export interface SessionFenceExtras {
|
|
|
67
67
|
* The session's fence for `workspace`, or undefined when sandboxing is off.
|
|
68
68
|
*
|
|
69
69
|
* Cached on the full effective configuration — the workspace plus the resolved enable flag, both
|
|
70
|
-
*
|
|
70
|
+
* discovery-grant lists and any extra roots — not on the workspace alone. Keying on the lists is what lets
|
|
71
71
|
* a mid-session `settings.override("sandbox.allowRead", …)` take effect, as when the Office pane grants
|
|
72
72
|
* a user-picked folder; a workspace-only key would keep serving the stale fence and block the path that
|
|
73
73
|
* was just granted. The key only ever triggers more rebuilds, never fewer restrictions.
|
|
@@ -94,15 +94,15 @@ export function resolveSessionFence(
|
|
|
94
94
|
const cached = cache.get(signature);
|
|
95
95
|
if (cached) return cached;
|
|
96
96
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// Session settings restore discovery; they do not reduce the operator's normal rights. The fence is a
|
|
98
|
+
// cross-context courtesy, not a read-only/write-only privilege boundary. Treat either historical
|
|
99
|
+
// allow-list as a full named-path grant so an old `sandbox.allowRead` entry cannot make a professional
|
|
100
|
+
// tool fail when it writes credentials, state, or build output there. The low-level fence keeps
|
|
101
|
+
// directional roots for specialized callers and tests, but ordinary xcsh sessions never emit them.
|
|
100
102
|
const fence = buildContainmentFence({
|
|
101
103
|
workspace,
|
|
102
104
|
sessionTmp: extras.sessionTmp,
|
|
103
|
-
extraRoots: extras.extraRoots,
|
|
104
|
-
readOnlyRoots: allowRead,
|
|
105
|
-
writeOnlyRoots: allowWrite,
|
|
105
|
+
extraRoots: [...(extras.extraRoots ?? []), ...allowRead, ...allowWrite],
|
|
106
106
|
});
|
|
107
107
|
if (cache.size >= CACHE_LIMIT) {
|
|
108
108
|
const oldest = cache.keys().next();
|
package/src/sdk.ts
CHANGED
|
@@ -1167,7 +1167,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1167
1167
|
},
|
|
1168
1168
|
// Read live rather than captured, for the same reason as the model: `--no-sandbox` and
|
|
1169
1169
|
// `sandbox.enabled` are per-session, so the answer must reflect this session (#2554).
|
|
1170
|
-
getContainment: () =>
|
|
1170
|
+
getContainment: () => {
|
|
1171
|
+
const fence = resolveSessionFence(process.cwd(), settings);
|
|
1172
|
+
return containmentStatus(fence !== undefined, process.platform, undefined, fence);
|
|
1173
|
+
},
|
|
1171
1174
|
// Read live rather than captured: `session.model` is a read-through to agent state, so a
|
|
1172
1175
|
// mid-session Ctrl+P switch shows up on the next xcsh://about read (#2459).
|
|
1173
1176
|
getActiveModel: () =>
|
|
@@ -172,10 +172,10 @@ function shellEscape(p: string): string {
|
|
|
172
172
|
/**
|
|
173
173
|
* Refuse a resolved path the session is not allowed to read.
|
|
174
174
|
*
|
|
175
|
-
* Without this the expander and the sandbox
|
|
176
|
-
*
|
|
177
|
-
* `
|
|
178
|
-
*
|
|
175
|
+
* Without this the expander and the sandbox can disagree when an operator configures an explicit
|
|
176
|
+
* directional or recursive boundary. Session-owned roots are still carved out so `artifact://`,
|
|
177
|
+
* `agent://` and `local://` keep working under those opt-in policies. The default production fence
|
|
178
|
+
* preserves named operator access and therefore reaches this check only for enumeration attempts.
|
|
179
179
|
*/
|
|
180
180
|
function enforceReadBoundary(scheme: SupportedInternalScheme, resolved: string, options: InternalUrlExpansionOptions) {
|
|
181
181
|
const boundary = options.readBoundary;
|
package/src/tools/bash.ts
CHANGED
|
@@ -793,7 +793,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
793
793
|
// Only worth giving up when there is an OS backend for the non-PTY path to use and none for this
|
|
794
794
|
// one. Where no backend exists — Linux without Landlock, Windows — both paths are scanner-only,
|
|
795
795
|
// so disabling PTY would remove interactive terminals and improve containment by nothing.
|
|
796
|
-
const osBackend = containmentStatus(fence !== undefined);
|
|
796
|
+
const osBackend = containmentStatus(fence !== undefined, process.platform, undefined, fence);
|
|
797
797
|
const ptyConfinable = !osBackend.osEnforced || osBackend.backend === "seatbelt";
|
|
798
798
|
const usePty = pty && ptyConfinable && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
|
|
799
799
|
let sandboxCheckSibling: string | undefined;
|