From 86ab88d1446ffa261c6a514f0abb3880f9124c23 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 2 Jul 2026 17:02:22 -0300 Subject: [PATCH 01/12] feat(console): register-trigger + state tool views; fix duplicated request pane (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(console): register-trigger + state tool views; fix duplicated request pane - FunctionCallMessage: suppress the top request pane for every completed call, fixing REQUEST rendering twice on generic (no-custom-view) tool cards. - engine::register_trigger: new rich view — trigger_type → function_id, config chips for state triggers, harness::react model/join(id·key·expect·rearm)/task, plus the harness subscribe variant (label, once, no function_id → "notify session"). Falls back to raw request JSON so the terminal pane is never blank. - state::* : new family view for get/set/delete/update/list/list_groups — scope/key chips + the unwrapped result as one highlighted JSON block. - Tests (engine + state parsers) and Storybook fixtures (EngineFamily, new StateFamily gallery). Claude-Session: https://claude.ai/code/session_01SB8sknFhJLojcaBdQazmPH * fix(console): guard state view error detection against denial-shaped values state::* results are arbitrary JSON, so running every result through the shared sandbox error parser flagged a successful get of a value like { status: "denied" } / { denied_by: … } as a red "Denied" error, hiding the real JSON on the default tab. Only run the parser for non-success envelopes; genuine errors ({ error: { kind: "function_error" } }, wire/ denial shapes) still surface since they are never { content, details }. Also dedupe the harness::react allow-list chips so the React key stays unique when the engine-supplied allow list repeats a function id. Adds state view render tests covering both directions. --- .../chat/FunctionCallMessage.stories.tsx | 6 + .../components/chat/FunctionCallMessage.tsx | 12 +- .../chat/engine/RegisterTriggerView.tsx | 208 ++++++++++++++++++ .../chat/engine/__tests__/parsers.test.ts | 79 +++++++ .../web/src/components/chat/engine/index.tsx | 5 + .../web/src/components/chat/engine/parsers.ts | 76 +++++++ .../src/components/chat/state/StateView.tsx | 59 +++++ .../chat/state/__tests__/parsers.test.ts | 71 ++++++ .../chat/state/__tests__/view.test.tsx | 58 +++++ .../web/src/components/chat/state/index.tsx | 75 +++++++ .../web/src/components/chat/state/parsers.ts | 32 +++ .../src/stories/fixtures/engine-fixtures.ts | 68 ++++++ .../src/stories/fixtures/state-fixtures.ts | 81 +++++++ 13 files changed, 827 insertions(+), 3 deletions(-) create mode 100644 console/web/src/components/chat/engine/RegisterTriggerView.tsx create mode 100644 console/web/src/components/chat/state/StateView.tsx create mode 100644 console/web/src/components/chat/state/__tests__/parsers.test.ts create mode 100644 console/web/src/components/chat/state/__tests__/view.test.tsx create mode 100644 console/web/src/components/chat/state/index.tsx create mode 100644 console/web/src/components/chat/state/parsers.ts create mode 100644 console/web/src/stories/fixtures/state-fixtures.ts diff --git a/console/web/src/components/chat/FunctionCallMessage.stories.tsx b/console/web/src/components/chat/FunctionCallMessage.stories.tsx index 565553b52..5f64dc683 100644 --- a/console/web/src/components/chat/FunctionCallMessage.stories.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.stories.tsx @@ -6,6 +6,7 @@ import { harnessFixtures } from '@/stories/fixtures/harness-fixtures' import { routerFixtures } from '@/stories/fixtures/router-fixtures' import { sandboxFixtures } from '@/stories/fixtures/sandbox-fixtures' import { shellFixtures } from '@/stories/fixtures/shell-fixtures' +import { stateFixtures } from '@/stories/fixtures/state-fixtures' import { webFixtures } from '@/stories/fixtures/web-fixtures' import { workerFixtures } from '@/stories/fixtures/worker-fixtures' import { workflowFixtures } from '@/stories/fixtures/workflow-fixtures' @@ -173,3 +174,8 @@ export const HarnessFamily: Story = { name: 'harness family', render: () => , } + +export const StateFamily: Story = { + name: 'state family', + render: () => , +} diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx index 65ac93bb9..33590c596 100644 --- a/console/web/src/components/chat/FunctionCallMessage.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -15,6 +15,7 @@ import { SandboxToolView, } from '@/components/chat/sandbox' import { ShellFunctionIdLabel, ShellToolView } from '@/components/chat/shell' +import { StateFunctionIdLabel, StateToolView } from '@/components/chat/state' import { WebFunctionIdLabel, WebToolView } from '@/components/chat/web' import { WorkerFunctionIdLabel, WorkerToolView } from '@/components/chat/worker' import { @@ -140,6 +141,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) { if (HarnessToolView.isHarnessFunction(functionId)) { return } + if (StateToolView.isStateFunction(functionId)) { + return + } return {functionId} } @@ -170,7 +174,8 @@ export function FunctionCallMessage({ ShellToolView.tryRenderPreview(message) ?? WorkflowToolView.tryRenderPreview(message) ?? RouterToolView.tryRenderPreview(message) ?? - HarnessToolView.tryRenderPreview(message) + HarnessToolView.tryRenderPreview(message) ?? + StateToolView.tryRenderPreview(message) const customTerminal = !pending ? (SandboxToolView.tryRender(message) ?? EngineToolView.tryRender(message) ?? @@ -181,13 +186,14 @@ export function FunctionCallMessage({ ShellToolView.tryRender(message) ?? WorkflowToolView.tryRender(message) ?? RouterToolView.tryRender(message) ?? - HarnessToolView.tryRender(message)) + HarnessToolView.tryRender(message) ?? + StateToolView.tryRender(message)) : null const hasCustomTerminal = customTerminal != null const showRequestPaneAbove = !(pending && customPreview) && !(running && hasCustomTerminal) && - !(!pending && !running && hasCustomTerminal) + !(!pending && !running) const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => { const handler = diff --git a/console/web/src/components/chat/engine/RegisterTriggerView.tsx b/console/web/src/components/chat/engine/RegisterTriggerView.tsx new file mode 100644 index 000000000..6c6c026d8 --- /dev/null +++ b/console/web/src/components/chat/engine/RegisterTriggerView.tsx @@ -0,0 +1,208 @@ +import type { ReactNode } from 'react' +import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { + type ReactOptions, + type ReactSpec, + type RegisterTriggerRequest, + type RegisterTriggerResponse, + reactOptionsSchema, + reactSpecSchema, + registerTriggerRequestSchema, + registerTriggerResponseSchema, + type StateTriggerConfig, + safeParseRequest, + safeParseResponse, + stateTriggerConfigSchema, +} from './parsers' +import { FilterChip } from './shared' + +interface RegisterTriggerViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function RegisterTriggerView({ + input, + output, + running, +}: RegisterTriggerViewProps) { + const req = safeParseRequest( + registerTriggerRequestSchema, + input, + ) + // Never render blank: an unrecognized payload falls back to raw JSON rather + // than an empty terminal pane (the switch always mounts this component). + if (!req) return + + const stateCfg = + req.trigger_type === 'state' + ? safeParseRequest( + stateTriggerConfigSchema, + req.config, + ) + : null + const react = + req.function_id === 'harness::react' + ? safeParseRequest(reactSpecSchema, req.metadata) + : null + const allow = react + ? safeParseRequest(reactOptionsSchema, react.options) + ?.functions?.allow + : undefined + + const resp = running + ? null + : safeParseResponse( + registerTriggerResponseSchema, + output, + ) + const regId = resp?.id ?? resp?.subscription_id + const once = resp?.once ?? req.once + + const hasStateChips = + !!stateCfg && + (!!stateCfg.scope || !!stateCfg.key || !!stateCfg.condition_function_id) + + return ( +
+ + + {req.label ? : null} + {typeof once === 'boolean' ? ( + + ) : null} + {regId ? ( + + + id + + + {shortenId(regId)} + + + ) : null} + + +
+ + {req.trigger_type} + + + {req.function_id ? ( + + {req.function_id} + + ) : ( + + notify session + + )} +
+ + {hasStateChips ? ( +
+ {stateCfg?.scope ? ( + + ) : null} + {stateCfg?.key ? ( + + ) : null} + {stateCfg?.condition_function_id ? ( + + ) : null} +
+ ) : req.config !== undefined && !isEmpty(req.config) ? ( + + ) : null} + + {react ? ( + <> +
+ + {allow?.length + ? Array.from(new Set(allow)).map((fn) => ( + + {fn} + + )) + : null} +
+ {react.join ? ( +
+ + join + + {react.join.id} + · + + key {react.join.key} + + · + + expect{' '} + + [{react.join.expect.join(', ')}] + + + {react.join.rearm ? ( + <> + · + rearm + + ) : null} +
+ ) : null} + + + ) : req.metadata !== undefined ? ( + + ) : null} +
+ ) +} + +function isEmpty(v: unknown): boolean { + if (v === null || v === undefined) return true + if (typeof v === 'object') { + return Object.keys(v as Record).length === 0 + } + return false +} + +function shortenId(id: string): string { + if (id.length <= 14) return id + return `${id.slice(0, 8)}…${id.slice(-4)}` +} + +function PaneLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function LabeledJson({ label, value }: { label: string; value: unknown }) { + return ( +
+ {label} + +
+ ) +} + +function LabeledText({ label, text }: { label: string; text: string }) { + return ( +
+ {label} +
+        {text}
+      
+
+ ) +} diff --git a/console/web/src/components/chat/engine/__tests__/parsers.test.ts b/console/web/src/components/chat/engine/__tests__/parsers.test.ts index e15c916ec..84379f7f6 100644 --- a/console/web/src/components/chat/engine/__tests__/parsers.test.ts +++ b/console/web/src/components/chat/engine/__tests__/parsers.test.ts @@ -6,8 +6,11 @@ import { functionsListRequestSchema, functionsListResponseSchema, isEngineListFunction, + reactSpecSchema, registeredTriggersListRequestSchema, registeredTriggersListResponseSchema, + registerTriggerRequestSchema, + registerTriggerResponseSchema, safeParseRequest, safeParseResponse, triggersListRequestSchema, @@ -361,6 +364,82 @@ describe('engine::workers::register', () => { }) }) +describe('engine::register_trigger', () => { + it('is included in the engine function id set', () => { + expect(ENGINE_FUNCTION_IDS).toContain('engine::register_trigger') + expect(isEngineListFunction('engine::register_trigger')).toBe(true) + }) + + it('parses a state-trigger → harness::react registration', () => { + const req = safeParseRequest(registerTriggerRequestSchema, { + trigger_type: 'state', + function_id: 'harness::react', + config: { key: 'build', scope: 'ops' }, + metadata: { + model: 'claude-sonnet-5', + task: 'You are the GATE REVIEWER', + options: { functions: { allow: ['state::get'] } }, + join: { + id: 'gate-decision-join', + key: 'build', + expect: ['build', 'tests'], + rearm: true, + }, + }, + }) + expect(req?.trigger_type).toBe('state') + expect(req?.function_id).toBe('harness::react') + const react = safeParseRequest(reactSpecSchema, req?.metadata) + expect(react?.model).toBe('claude-sonnet-5') + expect(react?.join?.expect).toEqual(['build', 'tests']) + expect(react?.join?.rearm).toBe(true) + }) + + it('rejects a react spec whose join.expect is a count, not an array', () => { + expect( + safeParseRequest(reactSpecSchema, { + model: 'm', + task: 't', + join: { id: 'j', key: 'build', expect: 2 }, + }), + ).toBeNull() + }) + + it('parses the harness subscribe variant (no function_id, has label/once)', () => { + const req = safeParseRequest(registerTriggerRequestSchema, { + trigger_type: 'state', + config: { key: 'progress', scope: 'research' }, + label: 'research-progress-watch', + once: false, + }) + expect(req?.trigger_type).toBe('state') + expect(req?.function_id).toBeUndefined() + expect(req?.label).toBe('research-progress-watch') + expect(req?.once).toBe(false) + }) + + it('rejects a request missing the required trigger_type', () => { + expect( + safeParseRequest(registerTriggerRequestSchema, { config: {} }), + ).toBeNull() + }) + + it('parses the engine response { id }', () => { + expect( + safeParseResponse(registerTriggerResponseSchema, wrap({ id: 'trg-1' })), + ).toEqual({ id: 'trg-1' }) + }) + + it('parses the harness-intercepted response { subscription_id, once }', () => { + expect( + safeParseResponse(registerTriggerResponseSchema, { + subscription_id: 'sub-1', + once: false, + }), + ).toEqual({ subscription_id: 'sub-1', once: false }) + }) +}) + describe('unwrapEnvelope re-export', () => { it('peels the harness envelope', () => { const inner = { functions: [] } diff --git a/console/web/src/components/chat/engine/index.tsx b/console/web/src/components/chat/engine/index.tsx index 999162126..cf836456a 100644 --- a/console/web/src/components/chat/engine/index.tsx +++ b/console/web/src/components/chat/engine/index.tsx @@ -5,6 +5,7 @@ import { FunctionInfoView } from './FunctionInfoView' import { FunctionsListView } from './FunctionsListView' import { isEngineListFunction, unwrapEnvelope } from './parsers' import { RegisteredTriggersListView } from './RegisteredTriggersListView' +import { RegisterTriggerView } from './RegisterTriggerView' import { TriggersListView } from './TriggersListView' import { WorkerInfoView } from './WorkerInfoView' import { WorkersListView } from './WorkersListView' @@ -73,6 +74,10 @@ function tryRender(message: FunctionCallMessage): React.ReactNode | null { return ( ) + case 'engine::register_trigger': + return ( + + ) default: return null } diff --git a/console/web/src/components/chat/engine/parsers.ts b/console/web/src/components/chat/engine/parsers.ts index 233096362..ddf052ef3 100644 --- a/console/web/src/components/chat/engine/parsers.ts +++ b/console/web/src/components/chat/engine/parsers.ts @@ -23,6 +23,7 @@ export const ENGINE_FUNCTION_IDS = [ 'engine::workers::list', 'engine::workers::info', 'engine::workers::register', + 'engine::register_trigger', ] as const export type EngineFunctionId = (typeof ENGINE_FUNCTION_IDS)[number] @@ -241,6 +242,81 @@ export type WorkersRegisterResponse = z.infer< typeof workersRegisterResponseSchema > +/* ---------------- engine::register_trigger ---------------- */ + +/** + * Registration request. Covers both wire shapes seen under this id: + * - engine `RegisterTriggerInput` (iii-sdk `protocol.rs`): `{ trigger_type, + * function_id, config, metadata? }`. + * - harness `SubscribeArgs` (`harness/src/functions/subscribe.rs`): + * `{ trigger_type, config?, label?, once?, function_id?, metadata? }` — + * `function_id` omitted means "notify this session". + * Only `trigger_type` is guaranteed; everything else is optional so the view + * always renders. `config`/`metadata` are opaque JSON parsed per-provider. + */ +export const registerTriggerRequestSchema = z.object({ + trigger_type: z.string(), + function_id: z.string().optional(), + config: z.unknown().optional(), + metadata: z.unknown().optional(), + label: z.string().optional(), + once: z.boolean().optional(), +}) +export type RegisterTriggerRequest = z.infer< + typeof registerTriggerRequestSchema +> + +/** `config` shape for `trigger_type: "state"` (all fields optional filters). */ +export const stateTriggerConfigSchema = z.object({ + scope: z.string().optional(), + key: z.string().optional(), + condition_function_id: z.string().optional(), +}) +export type StateTriggerConfig = z.infer + +/** + * `metadata` shape for `function_id: "harness::react"` — the reactive bridge. + * Wire source: `harness/src/functions/react.rs` (`ReactSpec` / `JoinSpec`). + * `options` is free-form (mirrors `harness::spawn` SpawnOptions); the common + * `options.functions.allow: string[]` is surfaced by the view. + */ +export const joinSpecSchema = z.object({ + id: z.string(), + expect: z.array(z.string()), + key: z.string(), + rearm: z.boolean().optional(), +}) +export type JoinSpec = z.infer + +export const reactSpecSchema = z.object({ + model: z.string(), + task: z.string(), + session_id: z.string().optional(), + provider: z.string().optional(), + options: z.unknown().optional(), + parent_session_id: z.string().optional(), + join: joinSpecSchema.optional(), +}) +export type ReactSpec = z.infer + +/** `options.functions.allow` — the only bit of the free-form `options` the + * view reads. Non-strict so unknown option keys pass through. */ +export const reactOptionsSchema = z.object({ + functions: z.object({ allow: z.array(z.string()).optional() }).optional(), +}) +export type ReactOptions = z.infer + +/** Engine returns `{ id }`; the harness-intercepted path returns + * `{ subscription_id, once }`. Model both loosely. */ +export const registerTriggerResponseSchema = z.object({ + id: z.string().optional(), + subscription_id: z.string().optional(), + once: z.boolean().optional(), +}) +export type RegisterTriggerResponse = z.infer< + typeof registerTriggerResponseSchema +> + /* ---------------- generic helpers ---------------- */ export function safeParseRequest( diff --git a/console/web/src/components/chat/state/StateView.tsx b/console/web/src/components/chat/state/StateView.tsx new file mode 100644 index 000000000..7a8860458 --- /dev/null +++ b/console/web/src/components/chat/state/StateView.tsx @@ -0,0 +1,59 @@ +import { FilterChip } from '@/components/chat/engine/shared' +import { MetaRow, StatusPill } from '@/components/chat/sandbox/shared' +import { JsonHighlight } from '@/lib/syntax' +import { safeParseRequest, stateRequestSchema, unwrapEnvelope } from './parsers' + +interface StateViewProps { + functionId: string + input: unknown + output: unknown + running?: boolean +} + +/** `null`, `undefined`, `""`, `[]`, `{}` render as a compact "empty" note + * rather than a noisy JSON block. Mirrors `ValuePane`'s `isEmptyValue`. */ +function isEmptyValue(v: unknown): boolean { + if (v === null || v === undefined) return true + if (typeof v === 'string') return v.length === 0 + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'object') { + return Object.keys(v as Record).length === 0 + } + return false +} + +export function StateView({ + functionId, + input, + output, + running, +}: StateViewProps) { + const req = safeParseRequest(stateRequestSchema, input) + const op = functionId.startsWith('state::') + ? functionId.slice('state::'.length) + : functionId + + const value = running ? undefined : unwrapEnvelope(output) + const empty = !running && isEmptyValue(value) + + return ( +
+ + + {req?.scope ? : null} + {req?.key ? : null} + + {running ? ( +
+ · running… +
+ ) : empty ? ( +
+ · empty +
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/state/__tests__/parsers.test.ts b/console/web/src/components/chat/state/__tests__/parsers.test.ts new file mode 100644 index 000000000..796c49e80 --- /dev/null +++ b/console/web/src/components/chat/state/__tests__/parsers.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + isStateFunction, + safeParseRequest, + stateRequestSchema, + unwrapEnvelope, +} from '../parsers' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +describe('isStateFunction', () => { + it('matches every state:: op via prefix', () => { + for (const id of [ + 'state::get', + 'state::set', + 'state::delete', + 'state::update', + 'state::list', + 'state::list_groups', + ]) { + expect(isStateFunction(id)).toBe(true) + } + }) + + it('rejects non-state ids', () => { + expect(isStateFunction('stateful::x')).toBe(false) + expect(isStateFunction('engine::register_trigger')).toBe(false) + expect(isStateFunction('state')).toBe(false) + }) +}) + +describe('stateRequestSchema', () => { + it('parses scope + key', () => { + expect( + safeParseRequest(stateRequestSchema, { scope: 'ops', key: 'build' }), + ).toEqual({ scope: 'ops', key: 'build' }) + }) + + it('tolerates missing fields (list_groups)', () => { + expect(safeParseRequest(stateRequestSchema, {})).toEqual({}) + expect(safeParseRequest(stateRequestSchema, undefined)).toEqual({}) + }) + + it('ignores extra request fields (value / ops)', () => { + expect( + safeParseRequest(stateRequestSchema, { + scope: 'ops', + key: 'build', + value: { status: 'green' }, + }), + ).toEqual({ scope: 'ops', key: 'build' }) + }) +}) + +describe('unwrapEnvelope re-export', () => { + it('peels the harness envelope to the stored value', () => { + const value = { commit: 'abc123', status: 'green' } + expect(unwrapEnvelope(wrap(value))).toEqual(value) + }) + + it('returns primitives unchanged', () => { + expect(unwrapEnvelope(null)).toBeNull() + expect(unwrapEnvelope(42)).toBe(42) + }) +}) diff --git a/console/web/src/components/chat/state/__tests__/view.test.tsx b/console/web/src/components/chat/state/__tests__/view.test.tsx new file mode 100644 index 000000000..29ebf1e30 --- /dev/null +++ b/console/web/src/components/chat/state/__tests__/view.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import type { FunctionCallMessage } from '@/types/chat' +import { StateToolView } from '../index' +import { StateView } from '../StateView' + +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +function msg(output: unknown): FunctionCallMessage { + return { + id: 'state-view-test', + role: 'function-call', + functionId: 'state::get', + input: { scope: 'ops', key: 'gate' }, + output, + durationMs: 1, + createdAt: 0, + } +} + +/** Regression: state values are arbitrary JSON, so a *successful* get whose + * stored value merely looks like a denial (`{ status: 'denied' }`) must render + * as the value view — not the red error view — while a genuine + * `function_error` envelope still surfaces as the error view. */ +describe('StateToolView.tryRender error discrimination', () => { + it('renders a denial-shaped stored value as the value view, not an error', () => { + const node = StateToolView.tryRender( + msg(wrap({ status: 'denied', reason: 'gate closed' })), + ) + expect(node).not.toBeNull() + expect((node as { type: unknown }).type).toBe(StateView) + }) + + it('renders a { denied_by } stored value as the value view, not an error', () => { + const node = StateToolView.tryRender(msg(wrap({ denied_by: 'user' }))) + expect((node as { type: unknown }).type).toBe(StateView) + }) + + it('still renders a real function_error envelope as the error view', () => { + const node = StateToolView.tryRender( + msg({ + error: { + kind: 'function_error', + message: 'boom', + details: { status: 'denied', denied_by: 'gate_unavailable' }, + }, + }), + ) + expect(node).not.toBeNull() + expect((node as { type: unknown }).type).toBe(SandboxErrorView) + }) +}) diff --git a/console/web/src/components/chat/state/index.tsx b/console/web/src/components/chat/state/index.tsx new file mode 100644 index 000000000..bd6768621 --- /dev/null +++ b/console/web/src/components/chat/state/index.tsx @@ -0,0 +1,75 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import { parseSandboxErrorDisplay } from '@/components/chat/sandbox/parsers' +import type { FunctionCallMessage } from '@/types/chat' +import { isStateFunction } from './parsers' +import { StateView } from './StateView' + +/** + * Header label for `state::*` ids — dims the namespace prefix so the op + * (`get`, `set`, …) reads clearly. Mirrors `EngineFunctionIdLabel`. + */ +export function StateFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('state::')) { + return {functionId} + } + const tail = functionId.slice('state::'.length) + return ( + <> + state:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isStateFunction(message.functionId)) return null + if (message.pendingApproval) return null + + const running = !!message.running + const rawOutput = message.output + + // Reuse the shared error parser for gate/transport-level errors — the + // `function_error` envelope is shared infra, not sandbox-specific. But state + // values are arbitrary JSON, and a *successful* result whose stored value + // looks like a denial (e.g. `{ status: 'denied' }`, `{ denied_by: … }`) would + // otherwise be misread as an error. A real error is `{ error: { kind: + // 'function_error', … } }`; a success is a `{ content, details }` harness + // envelope. Skip the parser for success envelopes so only genuine errors — + // which are never `{ content, details }` shaped — reach it. + const isSuccessEnvelope = + !!rawOutput && + typeof rawOutput === 'object' && + !Array.isArray(rawOutput) && + Array.isArray((rawOutput as Record).content) && + 'details' in (rawOutput as Record) + const errorDisplay = + !running && rawOutput != null && !isSuccessEnvelope + ? parseSandboxErrorDisplay(rawOutput) + : null + if (errorDisplay) { + return + } + + return ( + + ) +} + +/** `state::*` calls have no bespoke pending preview. */ +function tryRenderPreview( + _message: FunctionCallMessage, +): React.ReactNode | null { + return null +} + +export const StateToolView = { + isStateFunction, + tryRender, + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/state/parsers.ts b/console/web/src/components/chat/state/parsers.ts new file mode 100644 index 000000000..43ca85651 --- /dev/null +++ b/console/web/src/components/chat/state/parsers.ts @@ -0,0 +1,32 @@ +/** + * Parsers for the `state::*` family (`state::get|set|delete|update|list| + * list_groups`). All ops are keyed by `scope` (+ `key`, except `list` / + * `list_groups`); stored values are arbitrary JSON. The minimal view only + * surfaces `scope`/`key` from the request and renders the unwrapped result + * as JSON, so the request schema stays deliberately loose. + * + * Wire source: engine state worker (`state::*` are engine built-ins). + */ +import { z } from 'zod' +import { unwrapEnvelope } from '@/components/chat/sandbox/parsers' + +export { unwrapEnvelope } + +/** Every `state::` id is handled by this family (prefix match). */ +export function isStateFunction(id: string): boolean { + return id.startsWith('state::') +} + +export const stateRequestSchema = z.object({ + scope: z.string().optional(), + key: z.string().optional(), +}) +export type StateRequest = z.infer + +export function safeParseRequest( + schema: z.ZodType, + value: unknown, +): T | null { + const parsed = schema.safeParse(value ?? {}) + return parsed.success ? parsed.data : null +} diff --git a/console/web/src/stories/fixtures/engine-fixtures.ts b/console/web/src/stories/fixtures/engine-fixtures.ts index 7cb26f54f..de16839ce 100644 --- a/console/web/src/stories/fixtures/engine-fixtures.ts +++ b/console/web/src/stories/fixtures/engine-fixtures.ts @@ -559,6 +559,70 @@ export const engineWorkerRegisterRunning = base( { running: true }, ) +/* ---------------- engine::register_trigger ---------------- */ + +export const engineRegisterTriggerReact = base( + 'engine-register-trigger-react', + 'engine::register_trigger', + { + trigger_type: 'state', + function_id: 'harness::react', + config: { key: 'build', scope: 'ops' }, + metadata: { + model: 'claude-sonnet-5', + session_id: 'console-f0aac029-62a3-43f8-953b-45d0d903a866', + task: 'You are the GATE REVIEWER for a deploy pipeline. Both state records ops/build and ops/tests must be green before you approve.', + options: { functions: { allow: ['state::get'] } }, + join: { + id: 'gate-decision-join', + key: 'build', + expect: ['build', 'tests'], + rearm: true, + }, + }, + }, + wrapHarness({ id: 'trg-abc123def456' }), +) + +export const engineRegisterTriggerCron = base( + 'engine-register-trigger-cron', + 'engine::register_trigger', + { + trigger_type: 'cron', + function_id: 'ops::nightly-sweep', + config: { expression: '0 0 3 * * *' }, + }, + wrapHarness({ id: 'trg-cron-01' }), +) + +export const engineRegisterTriggerSubscribe = base( + 'engine-register-trigger-subscribe', + 'engine::register_trigger', + { + trigger_type: 'state', + config: { key: 'progress', scope: 'research' }, + label: 'research-progress-watch', + once: false, + }, + wrapHarness({ + once: false, + subscription_id: 'sub_6c9c9f043f5f449dab6569d2f27a8c05', + }), +) + +export const engineRegisterTriggerRunning = base( + 'engine-register-trigger-running', + 'engine::register_trigger', + { + trigger_type: 'state', + function_id: 'harness::react', + config: { scope: 'ops' }, + metadata: { model: 'claude-sonnet-5', task: 'watch for changes' }, + }, + undefined, + { running: true }, +) + export const engineFixtures = [ engineFunctionsListDone, engineFunctionsListRaw, @@ -578,6 +642,10 @@ export const engineFixtures = [ engineWorkerInfoNotFound, engineWorkerRegisterDone, engineWorkerRegisterRunning, + engineRegisterTriggerReact, + engineRegisterTriggerCron, + engineRegisterTriggerSubscribe, + engineRegisterTriggerRunning, engineRunning, engineFunctionsListGateError, ] as const diff --git a/console/web/src/stories/fixtures/state-fixtures.ts b/console/web/src/stories/fixtures/state-fixtures.ts new file mode 100644 index 000000000..9c0d0342d --- /dev/null +++ b/console/web/src/stories/fixtures/state-fixtures.ts @@ -0,0 +1,81 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 2, + createdAt: now, + ...extra, + } +} + +/* ---------------- state::get ---------------- */ + +export const stateGetValue = base( + 'state-get-value', + 'state::get', + { scope: 'ops', key: 'build' }, + wrapHarness({ commit: 'abc123', status: 'green' }), +) + +export const stateGetMissing = base( + 'state-get-missing', + 'state::get', + { scope: 'ops', key: 'nonexistent' }, + wrapHarness(null), +) + +export const stateGetRunning = base( + 'state-get-running', + 'state::get', + { scope: 'ops', key: 'build' }, + undefined, + { running: true }, +) + +/* ---------------- state::set / delete ---------------- */ + +export const stateSet = base( + 'state-set', + 'state::set', + { scope: 'ops', key: 'build', value: { status: 'green' } }, + wrapHarness({ old_value: { status: 'red' }, new_value: { status: 'green' } }), +) + +export const stateDelete = base( + 'state-delete', + 'state::delete', + { scope: 'ops', key: 'build' }, + wrapHarness({ status: 'green' }), +) + +/* ---------------- state::list_groups ---------------- */ + +export const stateListGroups = base( + 'state-list-groups', + 'state::list_groups', + {}, + wrapHarness({ groups: ['ops', 'build', 'sessions'] }), +) + +export const stateFixtures = [ + stateGetValue, + stateGetMissing, + stateGetRunning, + stateSet, + stateDelete, + stateListGroups, +] as const From 0680fc3d903ef94aa1d37b0b6e0c26d1e513a42c Mon Sep 17 00:00:00 2001 From: "workers-ci[bot]" Date: Fri, 3 Jul 2026 00:37:29 +0000 Subject: [PATCH 02/12] chore(console): bump to v1.2.0 --- console/Cargo.lock | 2 +- console/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/console/Cargo.lock b/console/Cargo.lock index 0bb70111d..fad768e96 100644 --- a/console/Cargo.lock +++ b/console/Cargo.lock @@ -251,7 +251,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/console/Cargo.toml b/console/Cargo.toml index ec4e7b1ab..b9f8efa83 100644 --- a/console/Cargo.toml +++ b/console/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "console" -version = "1.1.0" +version = "1.2.0" edition = "2021" publish = false From 298df7bdff1fa413fdf07ad2ff7751f476f628c7 Mon Sep 17 00:00:00 2001 From: Ytallo Date: Thu, 2 Jul 2026 22:54:28 -0300 Subject: [PATCH 03/12] fix: guarantee the picked working directory reaches every shell/coder call (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(harness): stamp the session working_dir on every scoped invocation path The console-picked working directory is guaranteed reachable through the per-call base_dir stamp, but three invocation paths skipped it: - the deferred approval-release path invoked the recovered transcript arguments un-stamped, so an approved shell/coder call lost the session scope and a model-supplied base_dir survived un-stripped - sub-agent turns were seeded with no metadata, so children could never reach the parent session's directory - harness::function::trigger invoked its target without the stamp pre_trigger hooks now receive the stamped arguments (an approver reviews the base_dir the call actually runs under), with an idempotent re-stamp after the chain so a hook rewrite can never widen the scope. TurnOptions::working_dir() is the shared accessor for all stamp sites; children inherit only the parent's working_dir, never its per-turn tracing metadata. * fix(console): validate browsed directory picks against the live shell worker Browsed "use this folder" selections bypassed shell::workspace::validate. Every selection path — pasted, remembered, or browsed — now round-trips through validate, so a stale listing can't select a vanished directory and the worker-echoed canonical path is what gets stored. * refactor(harness): share WORKING_DIR_KEY across read/write sides Extract the working_dir metadata key into a single WORKING_DIR_KEY constant (mirroring workspace_inject::BASE_DIR_FIELD) used by both TurnOptions::working_dir (read) and subagent::inherit_workspace (write), so a rename cannot silently desync the two and drop child scope. Also complete the working_dir() doc enumeration to include function::trigger, the third stamping path. --- .../src/components/chat/DirectoryPicker.tsx | 22 +++--- harness/src/deferred.rs | 10 +++ harness/src/functions/function_trigger.rs | 16 +++- harness/src/subagent.rs | 77 ++++++++++++++++++- harness/src/turn_loop.rs | 33 ++++---- harness/src/types/turn.rs | 39 ++++++++++ 6 files changed, 168 insertions(+), 29 deletions(-) diff --git a/console/web/src/components/chat/DirectoryPicker.tsx b/console/web/src/components/chat/DirectoryPicker.tsx index c918145a4..cba91bb4c 100644 --- a/console/web/src/components/chat/DirectoryPicker.tsx +++ b/console/web/src/components/chat/DirectoryPicker.tsx @@ -22,10 +22,11 @@ import { cn } from '@/lib/utils' * or "browse to add" a new directory. Browsing uses shell's operator workspace * control plane one level at a time. The search box filters the current level * live; typing/pasting an absolute path jumps straight there (browse) or - * selects it (projects). A pasted/remembered dir is validated against the live - * shell worker before it's accepted. The chosen dir is what the harness scopes - * the chat to (`base_dir`); it is re-scopable mid-conversation (a change drops - * a visible transcript marker). + * selects it (projects). Every selection — pasted, remembered, or browsed — is + * validated against the live shell worker before it's accepted, and the + * worker-echoed canonical path is what gets stored. The chosen dir is what the + * harness scopes the chat to (`base_dir`); it is re-scopable mid-conversation + * (a change drops a visible transcript marker). */ interface DirectoryPickerProps { @@ -270,9 +271,11 @@ export function DirectoryPicker({ [onChange], ) - // Validate a pasted/remembered dir against the LIVE worker roots before - // accepting it — a remembered project may be deleted, on another machine, or - // outside the configured roots. Browsed dirs are already known-valid. + // Validate a dir against the LIVE worker before accepting it — a + // remembered project may be deleted, on another machine, or denylisted, and + // even a just-browsed dir can vanish between listing and clicking. Every + // selection path goes through here so the worker-echoed canonical path is + // what gets stored. const validateAndSelect = useCallback( async (raw: string) => { const dir = raw.trim().replace(/\/+$/, '') || '/' @@ -503,8 +506,9 @@ export function DirectoryPicker({ {path ? ( diff --git a/harness/src/deferred.rs b/harness/src/deferred.rs index 3b1bceecf..e0ad12757 100644 --- a/harness/src/deferred.rs +++ b/harness/src/deferred.rs @@ -90,6 +90,16 @@ pub async fn resolve( let arguments = find_call_arguments(deps, &record, &req.function_call_id) .await .unwrap_or(Value::Null); + // The release path runs OUTSIDE the turn loop, so re-apply the + // workspace stamp the loop would have added: without it an + // approved shell/coder call runs un-scoped (the session's picked + // directory becomes unreachable) and a model-supplied base_dir + // recovered from the transcript would survive un-stripped. + let arguments = crate::workspace_inject::inject( + &function_id, + arguments, + record.options.working_dir(), + ); if let Some(cp) = record.calls.get_mut(&req.function_call_id) { cp.state = CallState::Triggered; } diff --git a/harness/src/functions/function_trigger.rs b/harness/src/functions/function_trigger.rs index 79f6eb803..f32793691 100644 --- a/harness/src/functions/function_trigger.rs +++ b/harness/src/functions/function_trigger.rs @@ -92,7 +92,17 @@ pub async fn handle( )); } - let mut arguments = req.call.arguments.clone(); + // Stamp the turn's workspace scope onto scoped shell/coder args BEFORE the + // hook chain (an approver must see the base_dir the call will run under) + // and re-apply it before invocation so neither a direct caller nor a hook + // rewrite can widen the session scope. No turn record → no scope: any + // caller-supplied base_dir is stripped. + let working_dir = record.as_ref().and_then(|r| r.options.working_dir()); + let mut arguments = crate::workspace_inject::inject( + &req.call.function_id, + req.call.arguments.clone(), + working_dir, + ); if let Some(rec) = &record { match deps .hooks @@ -130,6 +140,10 @@ pub async fn handle( } } + // Re-stamp after the hook chain (idempotent) — a hook rewrite must not + // widen or drop the session scope. + let arguments = crate::workspace_inject::inject(&req.call.function_id, arguments, working_dir); + // Single invocation chokepoint: subscription control calls are intercepted // (trusted session injected); everything else invokes the target. let raw = crate::functions::subscribe::invoke( diff --git a/harness/src/subagent.rs b/harness/src/subagent.rs index 23a4f08c1..ae6410f74 100644 --- a/harness/src/subagent.rs +++ b/harness/src/subagent.rs @@ -16,7 +16,7 @@ use crate::prompt; use crate::trigger::{PendingInfo, ResultData}; use crate::types::content::ContentBlock; use crate::types::message::AgentMessage; -use crate::types::turn::{ParentLink, TurnOptions, TurnRecord, TurnStatus}; +use crate::types::turn::{ParentLink, TurnOptions, TurnRecord, TurnStatus, WORKING_DIR_KEY}; /// The ids of a freshly-seeded child turn. pub struct ChildIds { @@ -196,7 +196,7 @@ async fn seed_child( .and_then(|o| o.output.clone()) .unwrap_or_default(), functions, - metadata: None, + metadata: inherit_workspace(parent_record), max_validation_retries: cfg.max_validation_retries, }, calls: Default::default(), @@ -216,6 +216,15 @@ async fn seed_child( }) } +/// A child inherits ONLY the parent's workspace scope (`working_dir`), so the +/// session's picked directory stays reachable from sub-agent shell/coder +/// calls. The rest of the parent metadata (message ids, tracing passthrough) +/// belongs to the parent's turn and must not leak onto the child. +fn inherit_workspace(parent: Option<&TurnRecord>) -> Option { + let dir = parent.and_then(|p| p.options.working_dir())?; + Some(json!({ WORKING_DIR_KEY: dir })) +} + fn is_error(code: &str, message: String) -> ResultData { ResultData { content: vec![ContentBlock::text(message.clone())], @@ -223,3 +232,67 @@ fn is_error(code: &str, message: String) -> ResultData { details: json!({ "error": code, "message": message }), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::output::OutputContract; + + fn parent_record(metadata: Option) -> TurnRecord { + TurnRecord { + turn_id: "t_parent".into(), + session_id: "s_parent".into(), + status: TurnStatus::AwaitingFunctions, + step: 1, + turn_count: 1, + depth: 0, + abort: false, + watermark_entry_id: None, + stream_request_id: None, + options: TurnOptions { + model: "m".into(), + provider: None, + system_prompt: None, + mode: None, + max_turns: 16, + thinking_level: None, + output: OutputContract::Text, + functions: None, + metadata, + max_validation_retries: 2, + }, + calls: Default::default(), + parent: None, + result: None, + result_error: None, + validation_retries: 0, + created_at: 1, + updated_at: 1, + } + } + + #[test] + fn child_inherits_the_parent_working_dir() { + let parent = parent_record(Some(json!({ + "working_dir": "/work/project", + "message_id": "m_1", + "session_id": "s_console", + }))); + assert_eq!( + inherit_workspace(Some(&parent)), + Some(json!({ "working_dir": "/work/project" })) + ); + } + + #[test] + fn child_metadata_stays_none_without_a_parent_working_dir() { + // Direct spawns have no parent record; parents without a picked + // directory must not fabricate one. Other metadata keys are per-turn + // tracing and never leak onto the child. + assert_eq!(inherit_workspace(None), None); + let unscoped = parent_record(Some(json!({ "message_id": "m_1" }))); + assert_eq!(inherit_workspace(Some(&unscoped)), None); + let bare = parent_record(None); + assert_eq!(inherit_workspace(Some(&bare)), None); + } +} diff --git a/harness/src/turn_loop.rs b/harness/src/turn_loop.rs index 4db4eb4ad..f7959b200 100644 --- a/harness/src/turn_loop.rs +++ b/harness/src/turn_loop.rs @@ -370,7 +370,15 @@ pub async fn run_step( continue; } - // pre_trigger chain: deny / hold / rewrite arguments. + // pre_trigger chain: deny / hold / rewrite arguments. Hooks see + // args ALREADY carrying the workspace stamp so an approver reviews + // the base_dir the call will actually run under; the stamp is + // re-applied after the chain so a hook rewrite can never widen it. + let staged_args = crate::workspace_inject::inject( + &call.function_id, + call.arguments.clone(), + record.options.working_dir(), + ); let (eff_args, pre_ann) = match deps .hooks .run_pre_trigger( @@ -378,7 +386,7 @@ pub async fn run_step( payload.step, &call.id, &call.function_id, - &call.arguments, + &staged_args, ) .await { @@ -466,14 +474,11 @@ pub async fn run_step( // this overwrites any model-supplied base_dir so the model cannot // widen its own workspace. A None working_dir is a no-op (the args // pass through byte-for-byte unchanged). - let working_dir = record - .options - .metadata - .as_ref() - .and_then(|m| m.get("working_dir")) - .and_then(Value::as_str); - let scoped_args = - crate::workspace_inject::inject(&call.function_id, eff_args, working_dir); + let scoped_args = crate::workspace_inject::inject( + &call.function_id, + eff_args, + record.options.working_dir(), + ); // Single invocation chokepoint: subscription control calls are // intercepted (trusted session injected); everything else invokes the @@ -1039,13 +1044,7 @@ async fn assemble_context( /// (`workspace_inject::inject`); this line just tells the model where it is so /// it reasons about relative paths sensibly. fn with_working_dir_aid(system_prompt: Option, record: &TurnRecord) -> Option { - let working_dir = record - .options - .metadata - .as_ref() - .and_then(|m| m.get("working_dir")) - .and_then(Value::as_str); - let Some(dir) = working_dir else { + let Some(dir) = record.options.working_dir() else { return system_prompt; }; let line = format!("Your working directory is {dir}."); diff --git a/harness/src/types/turn.rs b/harness/src/types/turn.rs index b78af271a..d6ef3bc24 100644 --- a/harness/src/types/turn.rs +++ b/harness/src/types/turn.rs @@ -12,6 +12,13 @@ use crate::prompt::Mode; use crate::types::model::ThinkingLevel; use crate::types::output::OutputContract; +/// The options-metadata key that carries the session working directory. The +/// read side (`TurnOptions::working_dir`) and the sub-agent write side +/// (`subagent::inherit_workspace`) share this constant so a rename can't +/// silently desync the two and drop child scope. Mirrors +/// `workspace_inject::BASE_DIR_FIELD`. +pub const WORKING_DIR_KEY: &str = "working_dir"; + /// The coarse, harness-internal turn lifecycle (harness.md § API Reference). /// Finer-grained than the session's `status`, which the loop derives from it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -85,6 +92,21 @@ fn default_max_validation_retries() -> u32 { 2 } +impl TurnOptions { + /// The session working directory this turn is scoped to: the + /// `working_dir` key of the frozen options metadata. Every path that + /// invokes a scoped `shell::*` / `coder::*` call (turn loop, deferred + /// release, `function::trigger`) must stamp this via + /// `workspace_inject::inject` so the console-picked directory stays + /// reachable. + pub fn working_dir(&self) -> Option<&str> { + self.metadata + .as_ref() + .and_then(|m| m.get(WORKING_DIR_KEY)) + .and_then(Value::as_str) + } +} + /// Lifecycle of one function call within a turn (harness.md § Per-call /// checkpoints). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -247,6 +269,23 @@ mod tests { } } + #[test] + fn working_dir_reads_the_metadata_key() { + let mut r = record(); + r.options.metadata = Some(json!({ "working_dir": "/work/p", "message_id": "m_1" })); + assert_eq!(r.options.working_dir(), Some("/work/p")); + } + + #[test] + fn working_dir_is_none_without_metadata_or_key_or_string() { + let mut r = record(); + assert_eq!(r.options.working_dir(), None); + r.options.metadata = Some(json!({ "message_id": "m_1" })); + assert_eq!(r.options.working_dir(), None); + r.options.metadata = Some(json!({ "working_dir": 7 })); + assert_eq!(r.options.working_dir(), None); + } + #[test] fn pending_call_ids_lists_triggered_and_pending() { let mut r = record(); From a0e9684fc6a4a0cb9bcd945a82bcc99e1e461e96 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 08:23:55 -0300 Subject: [PATCH 04/12] feat(shell)!: consolidate env config into a nested env block (0.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: the top-level inherit_env and allowed_env config keys are replaced by env.inherit / env.allow, with no legacy aliases. The old keys are rejected at parse with a migration hint — serde would otherwise ignore them and silently boot with env forwarding off. A stored configuration value carrying the old keys fails closed at boot; rewrite it via configuration::set (id: shell) after deploying the new binary. Also: - --version flag; --url documented in --help including the III_URL env var - pre-connect reachability probe: one loud ERROR with a fix hint when the engine is unreachable, before the SDK's silent 2s-backoff retry loop - schema descriptions on every operator-visible config field (incl. the nested env/fs/sandbox blocks) so the console config UI documents each knob inline; pinned by a unit test - README Running section documenting the binary's operator surface (--config, --url/III_URL, --version, RUST_LOG); ARCHITECTURE CLI table and defaults table updated, code-defaults vs shipped-seed distinction spelled out - from_yaml re-deserializes from text after the removed-key check so unquoted boolean-like strings (allowlist: [false]) keep parsing; regression-tested - e2e fixtures migrated to the nested env block; tests/e2e/config/ (engine- externalized runtime state) gitignored and the tracked fixtures kept self-contained --- shell/ARCHITECTURE.md | 14 +- shell/CHANGELOG.md | 42 +++++ shell/Cargo.lock | 3 +- shell/Cargo.toml | 3 +- shell/README.md | 57 ++++++- shell/config.yaml | 31 ++-- shell/src/config.rs | 256 +++++++++++++++++++++++++++-- shell/src/exec/host.rs | 63 +++++-- shell/src/exec/policy.rs | 51 +++--- shell/src/functions/exec.rs | 2 +- shell/src/functions/exec_bg.rs | 12 +- shell/src/functions/kill.rs | 5 +- shell/src/functions/types.rs | 6 +- shell/src/main.rs | 121 +++++++++++++- shell/tests/e2e/.gitignore | 5 + shell/tests/e2e/config-jailed.yaml | 5 +- shell/tests/e2e/config.yaml | 5 +- shell/tests/e2e/run-tests.sh | 2 +- 18 files changed, 597 insertions(+), 86 deletions(-) diff --git a/shell/ARCHITECTURE.md b/shell/ARCHITECTURE.md index 446a9c67e..5bc65e70d 100644 --- a/shell/ARCHITECTURE.md +++ b/shell/ARCHITECTURE.md @@ -30,7 +30,10 @@ iii -c ./config.yaml | flag | default | purpose | |------|---------|---------| | `--config ` | `./config.yaml` | Optional seed config: the YAML is passed as `initial_value` when registering the schema with the `configuration` worker on first boot. It is **not** the live source of truth — the live value is fetched over RPC after registration. When the file is absent and nothing is stored yet, the worker seeds a built-in zero-config default (`ShellConfig::seed_default()`, jailed to `/tmp`) instead. | -| `--url ` | `ws://127.0.0.1:49134` | iii engine WebSocket | +| `--url ` | `ws://127.0.0.1:49134` | iii engine WebSocket. Also read from the `III_URL` env var (the flag wins). A pre-connect probe logs one ERROR with a fix hint when the engine is unreachable; the SDK then retries forever with a 2s backoff. | +| `--version` | — | print the worker version | + +Logging is controlled by the `RUST_LOG` env var (tracing `EnvFilter` syntax; default `info`). ## Configuration @@ -45,6 +48,11 @@ The shell worker integrates with the central `configuration` worker rather than ## Full YAML defaults +These are the CODE defaults (`ShellConfig::default()` — fail-closed: `env.inherit +false`, unjailed refused). The shipped seed `config.yaml` / `seed_default()` is +deliberately more permissive for dev use: `env.inherit true`, jailed to `/tmp`, +`max_timeout_ms 120000`, catastrophic-only denylist. + | key | default | enforced where | |-----|---------|----------------| | `max_timeout_ms` | `30000` | foreground `exec` hard cap; per-call `timeout_ms` clamped to this | @@ -52,8 +60,8 @@ The shell worker integrates with the central `configuration` worker rather than | `default_timeout_ms` | `10000` | applied when caller omits `timeout_ms` | | `max_output_bytes` | `1048576` (1 MiB) | stdout/stderr truncated; `*_truncated` flagged | | `working_dir` | `null` | pins cwd for spawned commands when set | -| `inherit_env` | `false` | when `false`, only `allowed_env` keys are forwarded | -| `allowed_env` | `[PATH, HOME, LANG, LC_ALL, TERM]` | env passthrough allowlist | +| `env.inherit` | `false` | forward the worker's FULL env to children; when `false`, only `env.allow` keys are forwarded | +| `env.allow` | `[PATH, HOME, LANG, LC_ALL, TERM]` | dual role: forwarding allowlist when `env.inherit` is false, AND the per-call `env` settable gate (minus the hardcoded dangerous keys, which are never settable) | | `allowlist` | `[]` (open) | command basename allowlist; empty = open | | `denylist_patterns` | `[]` | advisory regex tripwire on `argv.join(" ")` | | `max_concurrent_jobs` | `16` | rejects new `exec_bg` past the cap | diff --git a/shell/CHANGELOG.md b/shell/CHANGELOG.md index bc9afc19a..68e981773 100644 --- a/shell/CHANGELOG.md +++ b/shell/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 0.7.0 + +Environment-variable DX overhaul: one consolidated `env` config block, a +self-documenting config schema, and a discoverable operator surface for the +binary itself. + +### Breaking +- **`inherit_env` and `allowed_env` are replaced by a nested `env` block** + (`env.inherit`, `env.allow`) — no legacy aliases. The old top-level keys are + **rejected at parse** with a migration hint naming the new keys. This is + deliberate fail-closed behavior: serde ignores unknown fields, so accepting + the old shape would silently boot with `env.inherit false` and stop + forwarding the worker's environment to children. + +### Added +- `--version` prints the worker version. +- `--url` is documented in `--help`, including the `III_URL` env var binding. +- Pre-connect reachability probe: when the engine is unreachable at boot, one + ERROR names the URL and the fix (`is the iii engine running? Set --url or + III_URL...`) before the SDK's silent 2s-backoff retry loop takes over. The + worker still never exits. +- Every operator-visible config field (including the nested `env`, `fs`, and + `sandbox` blocks) now carries a schema description, so the console + configuration UI documents each knob inline. Pinned by a unit test. +- A `## Running` README section documents the binary's full operator surface + (`--config`, `--url`/`III_URL`, `--version`, `RUST_LOG`). + +### Migration +```yaml +# 0.6.x # 0.7.0 +inherit_env: true env: +allowed_env: [PATH, HOME, LANG] inherit: true + allow: [PATH, HOME, LANG] +``` +- A stored configuration value (id `shell`) still carrying the old keys makes + the worker fail closed at boot with the hint above. Rewrite it via + `configuration::set` with the nested shape. +- **Order matters**: deploy the 0.7.0 binary FIRST, then rewrite the stored + value. Writing the new shape while 0.6.x is still running makes the old + worker hot-reload it, ignore the unknown `env` block, and silently stop + forwarding env until restart. + ## 0.6.0 The standalone `coder` worker is folded into `shell`. There is now ONE worker, diff --git a/shell/Cargo.lock b/shell/Cargo.lock index 88a7ee8a1..7f2dd1675 100644 --- a/shell/Cargo.lock +++ b/shell/Cargo.lock @@ -1775,7 +1775,7 @@ dependencies = [ [[package]] name = "shell" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "async-trait", @@ -1798,6 +1798,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "uuid", "walkdir", ] diff --git a/shell/Cargo.toml b/shell/Cargo.toml index 8a5215f8d..0b5c6af0c 100644 --- a/shell/Cargo.toml +++ b/shell/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "shell" -version = "0.6.1" +version = "0.7.0" edition = "2021" publish = false @@ -29,6 +29,7 @@ once_cell = "1" regex = "1" shell-words = "1" async-trait = "0.1" +url = "2" base64 = "0.22" walkdir = "2" globset = "0.4" diff --git a/shell/README.md b/shell/README.md index a123ac1fd..14b3833e0 100644 --- a/shell/README.md +++ b/shell/README.md @@ -30,6 +30,23 @@ npx skills add iii-hq/workers --list npx skills add iii-hq/workers --all ``` +## Running + +The binary needs no required environment variables — it boots against a local +engine with pure defaults. The full operator surface: + +| Knob | Default | What it does | +|---|---|---| +| `--url ` / `III_URL` env var | `ws://127.0.0.1:49134` | WebSocket URL of the iii engine. The CLI flag wins over the env var. | +| `--config ` | `./config.yaml` | Seed config sent as `initial_value` on FIRST registration only; the stored value wins afterwards (see [Configure](#configure)). | +| `--version` | — | Print the worker version (also registered with the engine as worker metadata). | +| `RUST_LOG` env var | `info` | Log filter (tracing `EnvFilter` syntax, e.g. `RUST_LOG=shell=debug,info`). | + +If the engine is unreachable at boot, a pre-connect probe logs one ERROR +("engine unreachable at — is the iii engine running? Set --url or the +III_URL env var...") and the worker keeps retrying in the background every 2s — +it never exits, so supervised deployments recover as soon as the engine is up. + ## Configure Settings are managed through the central `configuration` worker. On boot, the shell worker registers its schema (id `shell`) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional `--config ` flag (default `./config.yaml`) provides the `initial_value` sent on first registration only; once registered, subsequent boots pull the stored value from the `configuration` worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see [Hot-reload](#hot-reload)). @@ -43,8 +60,9 @@ max_timeout_ms: 120000 # foreground exec hard cap; per-call timeout_ms is max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) default_timeout_ms: 10000 # applied when the caller omits timeout_ms max_output_bytes: 1048576 # 1 MiB; stdout/stderr past this set *_truncated -inherit_env: true # forward the worker's env to children; per-call dangerous keys still blocked -allowed_env: [PATH, HOME, LANG, LC_ALL, TERM] # gates per-call `env` (dangerous keys never settable) +env: + inherit: true # forward the worker's env to children; per-call dangerous keys still blocked + allow: [PATH, HOME, LANG, LC_ALL, TERM] # forwarded when inherit is false; gates per-call `env` (dangerous keys never settable) # exec gate. argv[0] is matched by basename or exact path; an empty # allowlist means OPEN — the shipped default, so any command runs. @@ -82,7 +100,7 @@ Host `shell::exec` is not a security boundary: any allowlisted interpreter (`sh` `shell::exec` and `shell::exec_bg` each accept optional fields so an agent can scope a single command to a directory, set specific env values, and feed it standard input without wrapping everything in `sh -lc` (which would defeat the argv allowlist): - **`cwd`** (string): the working directory for this one call. It is confined to the fs jail **exactly** like `shell::fs::*` paths — jail-relative when `fs.host_root` is set (else absolute), canonicalized, and required to resolve inside `host_root` and miss `denylist_paths`. A `cwd` that escapes the jail returns `S215`; one that doesn't exist or isn't a directory returns `S211`/`S210`. Omit it to use the configured `working_dir` (unchanged default). -- **`env`** (object of string→string): per-call environment values. A key may be set **only** if the operator already listed it in `allowed_env`, and **never** for an exec-hijacking key — `PATH`, `IFS`, `HOME`, every `LD_*`/`DYLD_*` variant, and other loader/lookup-path and interpreter startup-file keys (`GCONV_PATH`, `BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`, …) are on a hardcoded denylist that **wins over** `allowed_env`. Note that `HOME` ships in the default `allowed_env` for the worker's own forwarded env but is **not** settable per-call. Supplying a key that is not in `allowed_env`, or any dangerous key, rejects the **whole call** with `S210` (the offending key is named and the permitted keys are listed); the env is never silently dropped. A permitted per-call value overrides the value that would otherwise be forwarded for that key. So an agent can do `NODE_ENV=test` only if the operator put `NODE_ENV` in `allowed_env`, and can never inject `PATH`, `HOME`, or `LD_PRELOAD`. +- **`env`** (object of string→string): per-call environment values. A key may be set **only** if the operator already listed it in `env.allow`, and **never** for an exec-hijacking key — `PATH`, `IFS`, `HOME`, every `LD_*`/`DYLD_*` variant, and other loader/lookup-path and interpreter startup-file keys (`GCONV_PATH`, `BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`, …) are on a hardcoded denylist that **wins over** `env.allow`. Note that `HOME` ships in the default `env.allow` for the worker's own forwarded env but is **not** settable per-call. Supplying a key that is not in `env.allow`, or any dangerous key, rejects the **whole call** with `S210` (the offending key is named and the permitted keys are listed); the env is never silently dropped. A permitted per-call value overrides the value that would otherwise be forwarded for that key. So an agent can do `NODE_ENV=test` only if the operator put `NODE_ENV` in `env.allow`, and can never inject `PATH`, `HOME`, or `LD_PRELOAD`. - **`stdin`** (string): written to the program's standard input, which is then closed (EOF). Use it to feed `tee`, `patch`, `cat`, or any stdin filter instead of a shell heredoc. Omit it and stdin is `/dev/null`. All three fields are **host-only**. The `sandbox::exec` protocol does not forward `cwd`/`env`/`stdin`, so a sandbox-targeted call that supplies any of them is rejected with `S210` rather than silently ignoring it. Omit them and behaviour is identical to prior versions. @@ -108,7 +126,7 @@ The example runs on the host. The same payload retargets at a microVM with `targ | Function | Purpose | |---|---| -| `shell::exec` | Run an allowlisted command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. Accepts optional host-only `cwd` (jail-confined), `env` (gated by `allowed_env` + a dangerous-key denylist), and `stdin` (string piped to the program's stdin, then EOF) — see [Per-call `cwd`, `env`, and `stdin`](#per-call-cwd-env-and-stdin-host-target). | +| `shell::exec` | Run an allowlisted command in the foreground; returns stdout, stderr, exit code, and timing. Blocks until exit or timeout. Accepts optional host-only `cwd` (jail-confined), `env` (gated by `env.allow` + a dangerous-key denylist), and `stdin` (string piped to the program's stdin, then EOF) — see [Per-call `cwd`, `env`, and `stdin`](#per-call-cwd-env-and-stdin-host-target). | | `shell::exec_bg` | Spawn an allowlisted command as a background job; returns `{ job_id, argv }` immediately. Host-targeted jobs run until they exit or `shell::kill` terminates them — unbounded by default, and capped only when the operator sets a positive `max_bg_timeout_ms` (default `0` = unbounded), after which a runaway job is killed and its status becomes `killed`. Sandbox jobs honor `timeout_ms`. Same optional host-only `cwd`/`env`/`stdin` as `shell::exec`. | | `shell::status` | Fetch one job's full record: state, exit code, and captured stdout/stderr. A missing id — one that never existed or aged out past `job_retention_secs` — returns an `S211` ("no such job") error. | | `shell::list` | Enumerate current jobs as lightweight summaries; argv, stdout, and stderr are redacted. | @@ -165,7 +183,7 @@ Returned error bodies carry a stable `code` field. Allowlist and denylist reject | Code | Meaning | |---|---| | `S200` | In-VM execution failure on a sandbox target. | -| `S210` | Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, `sandbox.enabled: false` on a sandbox-targeted call, a `cwd` that is not a directory, an `env` key outside `allowed_env` or in the dangerous-key denylist, `cwd`/`env`/`stdin` supplied on a sandbox target (host-only), an inline string `content` on a sandbox-targeted `shell::fs::write`, or both single `path`/`content` and `files` on `shell::fs::write`. | +| `S210` | Invalid request: non-absolute path, empty command or pattern, bad octal mode, malformed payload, `sandbox.enabled: false` on a sandbox-targeted call, a `cwd` that is not a directory, an `env` key outside `env.allow` or in the dangerous-key denylist, `cwd`/`env`/`stdin` supplied on a sandbox target (host-only), an inline string `content` on a sandbox-targeted `shell::fs::write`, or both single `path`/`content` and `files` on `shell::fs::write`. | | `S211` | Path not found (including a `cwd` that does not exist). | | `S212` | Wrong file type for the operation (for example, a file where a directory was expected). | | `S213` | Path already exists. | @@ -178,6 +196,34 @@ Returned error bodies carry a stable `code` field. Allowlist and denylist reject Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim instead of collapsing to `S216`: `S001`–`S004` (sandbox lifecycle), `S100`–`S102` (image/VM/resource), `S300`, and `S400`. Branch on the specific code where relevant; only an unrecognized engine code falls back to `S216`. +## Upgrading to 0.7.0 + +- **BREAKING: env config keys renamed and nested.** The top-level `inherit_env` + and `allowed_env` keys are replaced by one `env` block — no legacy aliases: + + ```yaml + # 0.6.x # 0.7.0 + inherit_env: true env: + allowed_env: [PATH, HOME, LANG] inherit: true + allow: [PATH, HOME, LANG] + ``` + + The old keys are **rejected at parse** with a migration hint ("config keys + removed in 0.7.0: `inherit_env` -> `env.inherit` ..."), because serde would + otherwise ignore them silently and boot with env forwarding OFF. A stored + configuration value still carrying the old keys makes the worker fail closed + at boot; rewrite it via `configuration::set` (id `shell`) with the nested + shape. **Sequencing matters**: update the binary FIRST, then the stored + value — writing the new shape while 0.6.x is still running makes the old + worker hot-reload it, ignore the unknown `env` block, and silently stop + forwarding env until restart. +- **`--version` added**, and `--url`/`III_URL` and `RUST_LOG` are now + documented (see [Running](#running)). +- **Unreachable-engine boot is loud**: one ERROR with the URL and the fix + hint, instead of only the SDK's silent retry WARNs. +- **Every config field now carries a schema description**, so the console + configuration UI documents each knob inline. + ## Upgrading to 0.4.0 0.4.0 is a breaking release. Migrating from 0.3.x: @@ -198,6 +244,7 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i - **`S215 path escapes host_root` on a path inside the jail**: a symlink in the path resolves outside the jail. Resolve it yourself, or move the target inside `host_root`. - **`S300` on a sandbox target**: the host cannot boot microVMs. Sandbox execution requires Apple Silicon or `/dev/kvm`. - **Worker never connects**: the engine is not running or not bound on the configured `--url`. Start the engine first; the default WebSocket port is 49134. +- **`config keys removed in 0.7.0: ...` at boot or on reload**: the seed file or the stored configuration value still uses the 0.6.x `inherit_env`/`allowed_env` keys. Nest them under `env:` (`inherit`/`allow`) — see [Upgrading to 0.7.0](#upgrading-to-070). For the threat model, streaming wire shapes, and contributor build steps, see [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/shell/config.yaml b/shell/config.yaml index d198c4117..58ae38ba1 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -6,19 +6,24 @@ max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground us default_timeout_ms: 10000 max_output_bytes: 1048576 working_dir: null -# Forward the worker's full environment to children so toolchains (cargo, -# rustup, git, node) find their PATH/HOME/CARGO_HOME/etc. Per-call `env` -# overrides are still gated: PATH/HOME/LD_*/DYLD_* and interpreter startup -# keys can never be set per call (see exec/policy.rs DANGEROUS_ENV_KEYS). -# NOTE: this also forwards any secrets in the worker's env to every command; -# run the worker with a clean environment if that matters to you. -inherit_env: true -allowed_env: - - PATH - - HOME - - LANG - - LC_ALL - - TERM +# Environment policy for spawned commands. +env: + # Forward the worker's full environment to children so toolchains (cargo, + # rustup, git, node) find their PATH/HOME/CARGO_HOME/etc. Per-call `env` + # overrides are still gated: PATH/HOME/LD_*/DYLD_* and interpreter startup + # keys can never be set per call (see exec/policy.rs DANGEROUS_ENV_KEYS). + # NOTE: this also forwards any secrets in the worker's env to every command; + # run the worker with a clean environment if that matters to you. + inherit: true + # Dual role: (1) when `inherit` is false, ONLY these keys are forwarded to + # children; (2) per-call `env` may set a key only if it is listed here — + # minus the hardcoded dangerous keys above, which are never settable. + allow: + - PATH + - HOME + - LANG + - LC_ALL + - TERM # PERMISSIVE DEFAULT: an EMPTY allowlist means every command is allowed # (cargo, git, bash, make, node, python3, …). This is deliberate — a coding # agent needs arbitrary build/test/VCS tooling, and a half-open list is diff --git a/shell/src/config.rs b/shell/src/config.rs index 16d356176..1e1e0bb19 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -4,8 +4,16 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Root configuration for the shell worker: exec policy (timeouts, output +/// caps, allow/denylist, env forwarding), the fs jail, the sandbox toggle, +/// and the folded `coder::*` code surface. Stored in the `configuration` +/// worker under id `shell` and hot-reloaded on change. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ShellConfig { + /// Hard cap, in milliseconds, on a foreground `shell::exec` call. A + /// per-call `timeout_ms` above this is clamped down to it. Default 30000 + /// (30s); the shipped dev seed raises it to 120000 so real builds/tests + /// are not reaped. #[serde(default = "default_max_timeout_ms")] pub max_timeout_ms: u64, @@ -19,21 +27,35 @@ pub struct ShellConfig { #[serde(default = "default_max_bg_timeout_ms")] pub max_bg_timeout_ms: u64, + /// Timeout, in milliseconds, applied to a foreground `shell::exec` call + /// when the caller omits `timeout_ms`. Always clamped to `max_timeout_ms`. + /// Default 10000 (10s). #[serde(default = "default_default_timeout_ms")] pub default_timeout_ms: u64, + /// Per-stream cap, in bytes, on captured stdout and stderr. Output beyond + /// the cap is dropped and the response flags `stdout_truncated` / + /// `stderr_truncated`. Default 1048576 (1 MiB). #[serde(default = "default_max_output_bytes")] pub max_output_bytes: usize, + /// Default working directory for spawned commands. `null` (the default) + /// runs children in the worker's own cwd. A per-call `cwd` or a + /// harness-stamped session `base_dir` overrides it for that one call. #[serde(default)] pub working_dir: Option, + /// Environment policy for spawned commands (host target): whether the + /// worker's env is forwarded to children, and which keys are + /// forwardable/settable. See the field docs on `EnvConfig`. #[serde(default)] - pub inherit_env: bool, - - #[serde(default = "default_allowed_env")] - pub allowed_env: Vec, + pub env: EnvConfig, + /// Command allowlist by argv[0] basename. EMPTY (the default) means every + /// command is allowed — deliberate for coding agents that need arbitrary + /// build/test/VCS tooling; the security boundary is the fs jail and the + /// sandbox backend, not this list. A non-empty list flips exec to + /// deny-by-default: only the listed basenames run. #[serde(default)] pub allowlist: Vec, @@ -45,15 +67,23 @@ pub struct ShellConfig { #[serde(default)] pub denylist_patterns: Vec, + /// Maximum number of live background jobs (`shell::exec_bg`). A spawn past + /// the cap is rejected until a job finishes or is killed. Default 16. #[serde(default = "default_max_concurrent_jobs")] pub max_concurrent_jobs: usize, + /// How long, in seconds, a FINISHED job record (status, exit code, + /// captured output) stays queryable via `shell::status` before a + /// background reaper evicts it. Default 3600 (1h). #[serde(default = "default_job_retention_secs")] pub job_retention_secs: u64, + /// The filesystem jail shared by `shell::fs::*`, `coder::*`, and per-call + /// exec `cwd` confinement. See the field docs on `FsConfig`. #[serde(default)] pub fs: FsConfig, + /// The `iii-sandbox` microVM backend toggle for sandbox-targeted calls. #[serde(default)] pub sandbox: SandboxConfig, @@ -94,12 +124,6 @@ fn default_default_timeout_ms() -> u64 { fn default_max_output_bytes() -> usize { 1_048_576 } -fn default_allowed_env() -> Vec { - vec!["PATH", "HOME", "LANG", "LC_ALL", "TERM"] - .into_iter() - .map(String::from) - .collect() -} fn default_max_concurrent_jobs() -> usize { 16 } @@ -107,6 +131,76 @@ fn default_job_retention_secs() -> u64 { 3600 } +/// Top-level keys removed in 0.7.0 and where they moved. serde ignores +/// unknown fields, so a 0.6.x config carrying `inherit_env: true` would +/// otherwise parse into `env.inherit = false` — silently disabling env +/// forwarding. Fail closed with a migration hint instead. +const REMOVED_TOP_LEVEL_KEYS: &[(&str, &str)] = + &[("inherit_env", "env.inherit"), ("allowed_env", "env.allow")]; + +fn check_removed_keys<'a>(keys: impl Iterator) -> Result<(), String> { + let hits: Vec = keys + .filter_map(|k| { + REMOVED_TOP_LEVEL_KEYS + .iter() + .find(|(old, _)| *old == k) + .map(|(old, new)| format!("`{old}` -> `{new}`")) + }) + .collect(); + if hits.is_empty() { + return Ok(()); + } + Err(format!( + "config keys removed in 0.7.0: {}. Nest them under `env:` (e.g. env: {{ inherit: true, \ + allow: [PATH, HOME] }}). If this is the stored value, rewrite it via \ + configuration::set (id: shell).", + hits.join(", ") + )) +} + +/// Environment policy for spawned commands (host target). Replaces the +/// 0.6.x top-level `inherit_env` / `allowed_env` keys (renamed in 0.7.0; +/// the old keys are rejected at parse with a migration hint). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct EnvConfig { + /// Forward the worker's ENTIRE environment to child processes. Toolchains + /// (cargo, rustup, git, node) need this to find PATH/HOME/CARGO_HOME. + /// WARNING: it also forwards any secrets in the worker's env to every + /// command — run the worker with a clean environment if that matters. + /// When false (the default), children start from a clean env containing + /// only the keys listed in `allow`. + #[serde(default)] + pub inherit: bool, + /// Env keys with a dual role. (1) Forwarding allowlist: when `inherit` is + /// false, ONLY these keys are copied from the worker's env into the child. + /// (2) Per-call gate: a `shell::exec`/`shell::exec_bg` request may set an + /// `env` value only for a key listed here — MINUS the hardcoded dangerous + /// keys (PATH, IFS, HOME, LD_*/DYLD_*, GCONV_PATH, BASH_ENV, + /// PYTHONSTARTUP, NODE_OPTIONS, ...), which are never settable per call + /// even if listed. Default: [PATH, HOME, LANG, LC_ALL, TERM]. + #[serde(default = "default_env_allow")] + pub allow: Vec, +} + +fn default_env_allow() -> Vec { + vec!["PATH", "HOME", "LANG", "LC_ALL", "TERM"] + .into_iter() + .map(String::from) + .collect() +} + +impl Default for EnvConfig { + fn default() -> Self { + Self { + inherit: false, + allow: default_env_allow(), + } + } +} + +/// The filesystem jail: which host roots are reachable through +/// `shell::fs::*`, `coder::*`, and per-call exec `cwd`, plus read/write +/// budgets and hard-denied paths. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FsConfig { /// Legacy single jail root. Honored as a one-entry `host_roots` list. @@ -129,10 +223,17 @@ pub struct FsConfig { /// harnesses, sandbox-only deployments). #[serde(default)] pub allow_unjailed: bool, + /// Cap, in bytes, on a single `shell::fs::read`. `0` (the default) means + /// unlimited. The shipped seed sets 16777216 (16 MiB). #[serde(default = "default_max_read_bytes")] pub max_read_bytes: usize, + /// Cap, in bytes, on a single `shell::fs::write`. `0` (the default) means + /// unlimited. The shipped seed sets 16777216 (16 MiB). #[serde(default = "default_max_write_bytes")] pub max_write_bytes: usize, + /// Absolute path prefixes that are hard-rejected (S215) by every fs + /// operation and per-call exec `cwd`, even inside a jail root. A separate + /// layer from `code.non_accessible_globs` (glob-based, show-but-lock). #[serde(default)] pub denylist_paths: Vec, /// Permit setuid/setgid/sticky bits (the top octal digit, `mode & 0o7000`) @@ -144,8 +245,12 @@ pub struct FsConfig { pub allow_special_bits: bool, } +/// Toggle for the `iii-sandbox` microVM exec backend. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SandboxConfig { + /// Accept `target: { kind: "sandbox", sandbox_id }` calls and forward + /// them to the `iii-sandbox` worker. When false, sandbox-targeted calls + /// are rejected with S210. Default true. #[serde(default = "default_sandbox_enabled")] pub enabled: bool, } @@ -211,8 +316,7 @@ impl Default for ShellConfig { default_timeout_ms: default_default_timeout_ms(), max_output_bytes: default_max_output_bytes(), working_dir: None, - inherit_env: false, - allowed_env: default_allowed_env(), + env: EnvConfig::default(), allowlist: Vec::new(), denylist_patterns: Vec::new(), max_concurrent_jobs: default_max_concurrent_jobs(), @@ -238,7 +342,10 @@ impl ShellConfig { pub fn seed_default() -> Self { Self { max_timeout_ms: 120_000, - inherit_env: true, + env: EnvConfig { + inherit: true, + ..EnvConfig::default() + }, denylist_patterns: vec![ r"rm\s+-rf\s+/".into(), r":\(\)\s*\{\s*:\|".into(), @@ -407,7 +514,18 @@ impl ShellConfig { /// Parse a YAML seed (no denylist compile, no jail validation — those run /// in `configuration::build_runtime`). + /// + /// The removed-key check parses to a `Value` first, but the config itself + /// deserializes from the TEXT again: `serde_yaml::from_value` self-tags + /// plain scalars (an unquoted `false` in `allowlist` becomes `Bool` and can + /// no longer deserialize into `String`), while `from_str` drives parsing by + /// the target type. Double-parsing a config-sized string is free. pub fn from_yaml(yaml: &str) -> Result { + let raw: serde_yaml::Value = + serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}"))?; + if let Some(map) = raw.as_mapping() { + check_removed_keys(map.keys().filter_map(|k| k.as_str()))?; + } serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}")) } @@ -419,6 +537,9 @@ impl ShellConfig { /// Deserialize the live value fetched from the configuration worker. pub fn from_json(value: &serde_json::Value) -> Result { + if let Some(obj) = value.as_object() { + check_removed_keys(obj.keys().map(String::as_str))?; + } serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) } @@ -454,7 +575,8 @@ mod tests { let c = ShellConfig::default(); assert_eq!(c.max_timeout_ms, 30_000); assert_eq!(c.default_timeout_ms, 10_000); - assert!(!c.inherit_env); + assert!(!c.env.inherit); + assert_eq!(c.env.allow, default_env_allow()); assert_eq!(c.max_concurrent_jobs, 16); } @@ -581,11 +703,13 @@ mod tests { /// `seed_default()` is the in-code twin of the shipped `config.yaml` — the /// file the registry publishes and that `cargo run` loads. If they drift, a /// zero-config boot and a `config.yaml` boot would diverge silently. + /// Routed through `from_yaml` so the shipped seed also passes the + /// removed-key check production uses. #[test] fn seed_default_matches_shipped_config_yaml() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"); let content = std::fs::read_to_string(path).expect("read config.yaml"); - let from_file: ShellConfig = serde_yaml::from_str(&content).expect("config.yaml parses"); + let from_file = ShellConfig::from_yaml(&content).expect("config.yaml parses"); assert_eq!( from_file.to_json(), ShellConfig::seed_default().to_json(), @@ -593,6 +717,108 @@ mod tests { ); } + /// A 0.6.x seed carrying the removed top-level `inherit_env` must be + /// rejected with a hint naming the new key — serde would otherwise ignore + /// it and silently boot with env forwarding OFF. + #[test] + fn from_yaml_rejects_removed_inherit_env_with_hint() { + let err = ShellConfig::from_yaml("inherit_env: true\n").expect_err("removed key rejects"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("`inherit_env` -> `env.inherit`"), "{err}"); + } + + /// Same for `allowed_env` through the live-value (JSON) funnel, which is + /// what an un-migrated stored configuration hits at boot and hot-reload. + #[test] + fn from_json_rejects_removed_allowed_env_with_hint() { + let v = serde_json::json!({"allowed_env": ["PATH"], "fs": {"allow_unjailed": true}}); + let err = ShellConfig::from_json(&v).expect_err("removed key rejects"); + assert!(err.contains("`allowed_env` -> `env.allow`"), "{err}"); + assert!(err.contains("configuration::set"), "{err}"); + } + + /// Both removed keys present → both mappings named, so an operator fixes + /// the config in one pass instead of playing whack-a-mole. + #[test] + fn removed_keys_error_names_both_mappings() { + let err = ShellConfig::from_yaml("inherit_env: true\nallowed_env: [PATH]\n") + .expect_err("removed keys reject"); + assert!(err.contains("`inherit_env` -> `env.inherit`"), "{err}"); + assert!(err.contains("`allowed_env` -> `env.allow`"), "{err}"); + } + + /// Round-trip realism: exactly what a live 0.6.x STORED value looks like — + /// the old seed serialized with top-level `inherit_env`/`allowed_env` and + /// no `env` block — must fail closed through `from_json`. + #[test] + fn stored_060_shape_fails_closed_with_hint() { + let mut v = ShellConfig::seed_default().to_json(); + let obj = v.as_object_mut().unwrap(); + obj.remove("env"); + obj.insert("inherit_env".into(), serde_json::Value::Bool(true)); + obj.insert("allowed_env".into(), serde_json::json!(["PATH", "HOME"])); + let err = ShellConfig::from_json(&v).expect_err("0.6.x shape fails closed"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + } + + /// Regression: the removed-key pre-parse must NOT change how scalars + /// deserialize. An unquoted `false` in a string list (the e2e fixture + /// allowlists the `false` binary) self-tags as Bool through + /// `serde_yaml::from_value`, so `from_yaml` must re-deserialize from the + /// text, where the target type drives parsing. + #[test] + fn from_yaml_keeps_unquoted_boolean_like_strings() { + let c = ShellConfig::from_yaml("allowlist: [echo, false, \"true\"]\n") + .expect("boolean-looking allowlist entries parse as strings"); + assert_eq!(c.allowlist, vec!["echo", "false", "true"]); + } + + /// The nested block parses, and every omitted field takes the EnvConfig + /// default (inherit false, standard allow list). + #[test] + fn env_block_parses_and_defaults() { + let c = ShellConfig::from_yaml("env:\n inherit: true\n").expect("nested env parses"); + assert!(c.env.inherit); + assert_eq!(c.env.allow, default_env_allow()); + + let d = ShellConfig::from_yaml("{}").expect("empty config parses"); + assert!(!d.env.inherit); + assert_eq!(d.env.allow, default_env_allow()); + } + + /// Every operator-visible config field must carry a schema description — + /// the console configuration UI renders them, and a bare field name is + /// exactly the DX gap this schema exists to close. Also pins that the + /// nested `EnvConfig` definition documents the dual role of `allow`. + #[test] + fn json_schema_every_field_has_description() { + let schema = ShellConfig::json_schema(); + let props = schema["properties"].as_object().expect("top-level properties"); + assert!(!props.is_empty()); + for (name, prop) in props { + assert!( + prop.get("description") + .and_then(|d| d.as_str()) + .is_some_and(|s| !s.is_empty()), + "config field `{name}` has no schema description (console UI shows it bare)" + ); + } + let env_props = &schema["definitions"]["EnvConfig"]["properties"]; + for key in ["inherit", "allow"] { + assert!( + env_props[key]["description"] + .as_str() + .is_some_and(|s| !s.is_empty()), + "EnvConfig.{key} has no schema description" + ); + } + let allow_desc = env_props["allow"]["description"].as_str().unwrap(); + assert!( + allow_desc.contains("Forwarding") && allow_desc.contains("Per-call"), + "env.allow description must explain both roles: {allow_desc}" + ); + } + #[test] fn exec_command_path_inside_jail_is_rejected() { // An agent can plant `/ls` (0755) via shell::fs::write; the diff --git a/shell/src/exec/host.rs b/shell/src/exec/host.rs index a9444bd8b..8598386e4 100644 --- a/shell/src/exec/host.rs +++ b/shell/src/exec/host.rs @@ -40,9 +40,9 @@ pub fn build_command( if argv.len() > 1 { cmd.args(&argv[1..]); } - if !cfg.inherit_env { + if !cfg.env.inherit { cmd.env_clear(); - for k in &cfg.allowed_env { + for k in &cfg.env.allow { if let Ok(v) = std::env::var(k) { cmd.env(k, v); } @@ -50,8 +50,8 @@ pub fn build_command( } // Per-call env overrides are applied LAST so a permitted key's per-call // value wins over the config-forwarded value. Keys were already gated - // against allowed_env + DANGEROUS_ENV_KEYS in the handler, so this loop - // trusts the validated map. Note: when inherit_env is true the child + // against env.allow + DANGEROUS_ENV_KEYS in the handler, so this loop + // trusts the validated map. Note: when env.inherit is true the child // already inherits the worker's full env; the override still sets these // keys explicitly on top. if let Some(env) = &overrides.env { @@ -252,7 +252,10 @@ mod tests { fn test_cfg() -> ShellConfig { let mut c = ShellConfig { - inherit_env: true, + env: crate::config::EnvConfig { + inherit: true, + ..Default::default() + }, max_output_bytes: 4096, ..Default::default() }; @@ -401,13 +404,49 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + /// With `env.inherit: false`, the child env is scrubbed to exactly the + /// `env.allow` keys: an allowed worker var round-trips, a non-allowed one + /// never reaches the child. (Unit twin of the e2e scrub/passthrough cases.) + #[tokio::test] + async fn inherit_false_forwards_only_allow_keys() { + // Unique names so parallel tests can't collide on process env state. + std::env::set_var("SHELL_DX_ALLOWED_9F3A", "allowed-value"); + std::env::set_var("SHELL_DX_BLOCKED_9F3A", "blocked-value"); + + let mut cfg = test_cfg(); + cfg.env.inherit = false; + cfg.env.allow = vec!["PATH".into(), "SHELL_DX_ALLOWED_9F3A".into()]; + + let out = run_to_completion( + &["env".into()], + &cfg, + 5000, + &crate::exec::policy::ExecOverrides::default(), + ) + .await + .unwrap(); + assert!( + out.stdout.contains("SHELL_DX_ALLOWED_9F3A=allowed-value"), + "allowed key forwarded: {}", + out.stdout + ); + assert!( + !out.stdout.contains("SHELL_DX_BLOCKED_9F3A"), + "non-allowed key scrubbed: {}", + out.stdout + ); + + std::env::remove_var("SHELL_DX_ALLOWED_9F3A"); + std::env::remove_var("SHELL_DX_BLOCKED_9F3A"); + } + /// A permitted `env` key is visible to the child process. We forward - /// `printenv NODE_ENV`; with NODE_ENV in allowed_env and a per-call value, + /// `printenv NODE_ENV`; with NODE_ENV in env.allow and a per-call value, /// the child sees it. #[tokio::test] async fn env_override_is_visible_to_child() { let mut cfg = test_cfg(); - cfg.allowed_env = vec!["NODE_ENV".into()]; + cfg.env.allow = vec!["NODE_ENV".into()]; let mut env = std::collections::BTreeMap::new(); env.insert("NODE_ENV".to_string(), "from-override".to_string()); @@ -425,12 +464,12 @@ mod tests { assert_eq!(out.stdout.trim(), "from-override"); } - /// An env key NOT in allowed_env is rejected (S210) before any spawn, + /// An env key NOT in env.allow is rejected (S210) before any spawn, /// naming the offending key — the call never reaches the child. #[tokio::test] - async fn env_key_outside_allowed_env_is_rejected_s210() { + async fn env_key_outside_allow_list_is_rejected_s210() { let mut cfg = test_cfg(); - cfg.allowed_env = vec!["NODE_ENV".into()]; + cfg.env.allow = vec!["NODE_ENV".into()]; let mut env = std::collections::BTreeMap::new(); env.insert("SECRET_TOKEN".to_string(), "x".to_string()); let err = crate::exec::policy::build_overrides(None, Some(&env), None, &cfg) @@ -444,12 +483,12 @@ mod tests { } /// LD_PRELOAD is rejected (S210) even when the test also adds it to - /// allowed_env — proof that the dangerous-key denylist wins over the + /// env.allow — proof that the dangerous-key denylist wins over the /// operator's allowlist. #[tokio::test] async fn dangerous_env_key_rejected_even_if_allowlisted_on_host_path() { let mut cfg = test_cfg(); - cfg.allowed_env = vec!["LD_PRELOAD".into(), "NODE_ENV".into()]; + cfg.env.allow = vec!["LD_PRELOAD".into(), "NODE_ENV".into()]; let mut env = std::collections::BTreeMap::new(); env.insert("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()); let err = crate::exec::policy::build_overrides(None, Some(&env), None, &cfg) diff --git a/shell/src/exec/policy.rs b/shell/src/exec/policy.rs index cf1237a83..235a3bc31 100644 --- a/shell/src/exec/policy.rs +++ b/shell/src/exec/policy.rs @@ -14,9 +14,9 @@ //! `allow_unjailed`), the same code path runs with no root to confine to, //! matching the fs backend's unjailed behaviour. //! - `env` may set a VALUE only for a key the operator already put in -//! `cfg.allowed_env`, and NEVER for an exec-hijacking key (see +//! `cfg.env.allow`, and NEVER for an exec-hijacking key (see //! [`DANGEROUS_ENV_KEYS`]) — those are rejected even if an operator -//! mistakenly lists them in `allowed_env`. Any offending key rejects the +//! mistakenly lists them in `env.allow`. Any offending key rejects the //! WHOLE call `S210` (we never silently drop a key — the agent must learn its //! env was not applied), naming the offending key and listing the permitted //! ones so the agent can self-correct. @@ -29,10 +29,10 @@ use crate::exec::error::ExecError; use crate::target::Target; /// Environment keys that an agent may NEVER set per-call, regardless of -/// `allowed_env`. Setting any of these can hijack which binary the child +/// `env.allow`. Setting any of these can hijack which binary the child /// actually executes or which shared libraries it loads — turning a benign /// allowlisted `command` into arbitrary code execution. The denylist is a -/// HARD boundary: it wins over `allowed_env` so an operator's typo can't open +/// HARD boundary: it wins over `env.allow` so an operator's typo can't open /// a privesc hole. /// /// - `PATH` / `IFS`: change which binary an allowlisted name resolves to / how @@ -86,7 +86,7 @@ pub const DANGEROUS_ENV_KEYS: &[&str] = &[ /// the dynamic-loader variables (`LD_*` on glibc, `DYLD_*` on macOS) — the set /// of these is open-ended across libc/OS versions, so an exact-name list would /// silently let a future `LD_SOMETHING` through if an operator widened -/// `allowed_env`; (2) the explicit [`DANGEROUS_ENV_KEYS`] denylist for the +/// `env.allow`; (2) the explicit [`DANGEROUS_ENV_KEYS`] denylist for the /// non-family names (PATH/IFS/HOME, glibc lookup paths, interpreter startup /// keys). Case-sensitive: env var names are case-sensitive on Unix and the /// dangerous names are upper-case. @@ -102,7 +102,7 @@ pub struct ExecOverrides { /// Canonical, jail-confined working directory. `None` falls back to /// `cfg.working_dir` in `build_command`. pub cwd: Option, - /// Per-call env values, already gated against `allowed_env` + + /// Per-call env values, already gated against `env.allow` + /// [`DANGEROUS_ENV_KEYS`]. Applied on top of the config-forwarded env. pub env: Option>, /// Bytes fed to the child's stdin (then EOF). `None` leaves stdin closed @@ -249,7 +249,7 @@ fn static_code(code: &str) -> &'static str { } /// Validate a per-call `env` map: every key must be present in -/// `cfg.allowed_env` AND absent from [`DANGEROUS_ENV_KEYS`]. The dangerous +/// `cfg.env.allow` AND absent from [`DANGEROUS_ENV_KEYS`]. The dangerous /// check runs FIRST so a key that is both dangerous and (mistakenly) /// allowlisted is still rejected. On any violation the WHOLE call fails S210 — /// we never partially apply env, so the agent always knows whether its env took @@ -258,12 +258,13 @@ fn validate_env( env: &BTreeMap, cfg: &ShellConfig, ) -> Result, ExecError> { - // The keys an agent can actually set per call: in allowed_env AND not in the - // dangerous denylist. Listing the raw allowed_env would name HOME/PATH (both + // The keys an agent can actually set per call: in env.allow AND not in the + // dangerous denylist. Listing the raw env.allow would name HOME/PATH (both // default-allowed but always-rejected) as "settable", contradicting the very // error that rejected them and sending the agent into a retry loop. let settable = cfg - .allowed_env + .env + .allow .iter() .filter(|k| !is_dangerous_env_key(k)) .cloned() @@ -275,16 +276,16 @@ fn validate_env( "S210", format!( "env key '{key}' is never settable per-call (exec-hijacking key); \ - remove it. Settable keys (in allowed_env, minus exec-hijacking keys): [{settable}]" + remove it. Settable keys (env.allow minus exec-hijacking keys): [{settable}]" ), )); } - if !cfg.allowed_env.iter().any(|a| a == key) { + if !cfg.env.allow.iter().any(|a| a == key) { return Err(ExecError::new( "S210", format!( - "env key '{key}' is not in allowed_env; the operator must permit it. \ - Settable keys: [{settable}]" + "env key '{key}' is not in the operator's env.allow list; the operator must \ + permit it. Settable keys: [{settable}]" ), )); } @@ -367,7 +368,10 @@ mod tests { fn cfg_jailed(root: &std::path::Path) -> ShellConfig { let mut c = ShellConfig { - allowed_env: vec!["NODE_ENV".into(), "MY_VAR".into()], + env: crate::config::EnvConfig { + allow: vec!["NODE_ENV".into(), "MY_VAR".into()], + ..Default::default() + }, ..Default::default() }; c.fs.host_root = Some(root.to_path_buf()); @@ -422,7 +426,10 @@ mod tests { #[test] fn env_in_allowed_is_accepted() { let c = ShellConfig { - allowed_env: vec!["NODE_ENV".into()], + env: crate::config::EnvConfig { + allow: vec!["NODE_ENV".into()], + ..Default::default() + }, ..Default::default() }; let mut env = BTreeMap::new(); @@ -434,7 +441,10 @@ mod tests { #[test] fn env_not_in_allowed_is_rejected_naming_key_and_listing_permitted() { let c = ShellConfig { - allowed_env: vec!["NODE_ENV".into(), "MY_VAR".into()], + env: crate::config::EnvConfig { + allow: vec!["NODE_ENV".into(), "MY_VAR".into()], + ..Default::default() + }, ..Default::default() }; let mut env = BTreeMap::new(); @@ -460,10 +470,13 @@ mod tests { #[test] fn dangerous_key_rejected_even_when_allowlisted() { - // The denylist must WIN over allowed_env: an operator typo listing + // The denylist must WIN over env.allow: an operator typo listing // LD_PRELOAD must not open a code-injection hole. let c = ShellConfig { - allowed_env: vec!["LD_PRELOAD".into()], + env: crate::config::EnvConfig { + allow: vec!["LD_PRELOAD".into()], + ..Default::default() + }, ..Default::default() }; let mut env = BTreeMap::new(); diff --git a/shell/src/functions/exec.rs b/shell/src/functions/exec.rs index 01810b203..be7cf0b00 100644 --- a/shell/src/functions/exec.rs +++ b/shell/src/functions/exec.rs @@ -31,7 +31,7 @@ pub async fn handle( cfg.is_command_allowed(&argv)?; // Gate the per-call cwd/env BEFORE picking a backend. A jail-escaping cwd - // (S215) or an env key outside allowed_env / in DANGEROUS_ENV_KEYS (S210) + // (S215) or an env key outside env.allow / in DANGEROUS_ENV_KEYS (S210) // rejects here, carrying the S-code to the wire via From. The // sandbox backend additionally rejects any populated override (host-only). // `base_dir` only scopes the host working directory: drop it for a sandbox diff --git a/shell/src/functions/exec_bg.rs b/shell/src/functions/exec_bg.rs index 0470061d5..d12e5a1f2 100644 --- a/shell/src/functions/exec_bg.rs +++ b/shell/src/functions/exec_bg.rs @@ -40,7 +40,7 @@ pub async fn handle( cfg.is_command_allowed(&argv)?; // Gate the per-call cwd/env up front, BEFORE branching on target. Same - // rules as shell::exec (jail-confined cwd, allowed_env + dangerous-key env + // rules as shell::exec (jail-confined cwd, env.allow + dangerous-key env // gating). exec_bg returns its spawn-time failures as plain strings (its // documented contract), so we stringify the S-code into the message — the // agent still sees the code (e.g. "S215") and the self-correcting text. @@ -502,7 +502,10 @@ mod host_path_tests { // (0 → unbounded bg job.) fn cfg(bg_cap_ms: u64, max_concurrent_jobs: usize) -> Arc { let mut c = ShellConfig { - inherit_env: true, + env: crate::config::EnvConfig { + inherit: true, + ..Default::default() + }, max_output_bytes: 4096, max_timeout_ms: bg_cap_ms, max_bg_timeout_ms: bg_cap_ms, @@ -790,7 +793,10 @@ mod sandbox_path_tests { fn cfg_open() -> Arc { let mut c = ShellConfig { - inherit_env: true, + env: crate::config::EnvConfig { + inherit: true, + ..Default::default() + }, max_output_bytes: 4096, ..Default::default() }; diff --git a/shell/src/functions/kill.rs b/shell/src/functions/kill.rs index af289a991..be0ffe97d 100644 --- a/shell/src/functions/kill.rs +++ b/shell/src/functions/kill.rs @@ -189,7 +189,10 @@ mod host_kill_tests { fn open_cfg() -> ShellConfig { let mut c = ShellConfig { - inherit_env: true, + env: crate::config::EnvConfig { + inherit: true, + ..Default::default() + }, max_output_bytes: 4096, ..Default::default() }; diff --git a/shell/src/functions/types.rs b/shell/src/functions/types.rs index 5bddcefa5..1f975d711 100644 --- a/shell/src/functions/types.rs +++ b/shell/src/functions/types.rs @@ -118,10 +118,10 @@ pub struct ExecRequest { #[schemars(skip)] pub base_dir: Option, /// Optional per-call environment values (host target only). A key may be - /// set ONLY if the operator listed it in `allowed_env`, and NEVER for an + /// set ONLY if the operator listed it in `env.allow`, and NEVER for an /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup /// and interpreter-startup keys — see DANGEROUS_ENV_KEYS) — those are - /// rejected even if allowlisted. Supplying a key that is not in `allowed_env`, + /// rejected even if allowlisted. Supplying a key that is not in `env.allow`, /// or any dangerous key, rejects the WHOLE call (S210) naming the offending /// key; the env is never silently dropped. Permitted values override what /// would otherwise be forwarded for that key. Rejected (S210) on a sandbox target. @@ -168,7 +168,7 @@ pub struct ExecBgRequest { #[schemars(skip)] pub base_dir: Option, /// Optional per-call environment values (host target only). Same gating as - /// [`ExecRequest::env`]: a key must be in `allowed_env` and must not be an + /// [`ExecRequest::env`]: a key must be in `env.allow` and must not be an /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup /// and interpreter-startup keys — see DANGEROUS_ENV_KEYS); any violation /// rejects the whole call (S210). Rejected (S210) on a sandbox target. diff --git a/shell/src/main.rs b/shell/src/main.rs index 119d86896..36d88ecb6 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -24,7 +24,11 @@ use configuration::AppState; use functions::types::{KillRequest, StatusRequest}; #[derive(Parser, Debug)] -#[command(name = "shell", about = "Unix shell execution worker for iii agents")] +#[command( + name = "shell", + version, + about = "Unix shell execution worker for iii agents" +)] struct Cli { /// Seed config registered as `initial_value` with the `configuration` worker /// on first registration. Defaults to ./config.yaml. The live value from the @@ -32,10 +36,58 @@ struct Cli { #[arg(long, default_value = "./config.yaml")] config: String, + /// WebSocket URL of the iii engine. Also read from the III_URL env var. + /// The worker retries the connection forever (2s backoff); when the engine + /// is unreachable at boot, a single loud error from the pre-connect probe + /// says so. #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] url: String, } +/// Host/port of a ws(s):// engine URL, for the pre-connect probe. `None` when +/// the URL does not parse or has no host; `port_or_known_default` maps +/// ws→80 / wss→443 when no explicit port is given. +fn ws_host_port(url_str: &str) -> Option<(String, u16)> { + let u = url::Url::parse(url_str).ok()?; + // host_str keeps IPv6 brackets ("[::1]"), which ToSocketAddrs rejects. + let host = u.host_str()?.trim_matches(['[', ']']).to_string(); + let port = u.port_or_known_default()?; + Some((host, port)) +} + +/// One loud, actionable ERROR when the engine is unreachable, BEFORE handing +/// off to the SDK's silent infinite 2s-backoff reconnect loop (which only +/// WARNs). Never fails fast — supervised deployments rely on the SDK retry — +/// and never blocks boot for more than ~4s (2s connect timeout, at most two +/// resolved addresses tried). Parse/resolve failures just skip the probe: the +/// SDK is the authority on what URLs it accepts. +fn probe_engine_reachable(url_str: &str) { + use std::net::{TcpStream, ToSocketAddrs}; + let Some((host, port)) = ws_host_port(url_str) else { + tracing::warn!(url = %url_str, "could not parse engine URL; skipping reachability probe"); + return; + }; + let addrs = match (host.as_str(), port).to_socket_addrs() { + Ok(a) => a.collect::>(), + Err(e) => { + tracing::warn!(url = %url_str, error = %e, "engine host did not resolve"); + return; + } + }; + let reachable = addrs + .iter() + .take(2) + .any(|a| TcpStream::connect_timeout(a, std::time::Duration::from_secs(2)).is_ok()); + if !reachable { + tracing::error!( + url = %url_str, + "engine unreachable at {url_str} — is the iii engine running? Set --url or the \ + III_URL env var if it listens elsewhere. Continuing to retry in the background \ + every 2s." + ); + } +} + /// Identify this worker to the engine as `shell` (name, runtime, version, pid) /// so it appears as `shell` in `engine::workers::list` and the `worker` /// lifecycle stream — not the default `Host:` identity. Console surfaces @@ -71,6 +123,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); tracing::info!(url = %cli.url, seed_config = %cli.config, "connecting to IIIClient engine"); + probe_engine_reachable(&cli.url); let iii = register_worker( &cli.url, @@ -210,7 +263,7 @@ async fn main() -> Result<()> { defaults to the host; pass { kind: \"sandbox\", sandbox_id } to run in a microVM. \ Optional host-only `cwd` scopes this call to a directory (jail-confined exactly \ like shell::fs::* paths; escaping it is S215), optional `env` (object) sets \ - per-call values — but only for keys already in allowed_env and never for \ + per-call values — but only for keys already in the operator's env.allow list and never for \ PATH/IFS/HOME/LD_*/DYLD_* or other loader/lookup and interpreter-startup keys \ (those reject S210) — and optional host-only `stdin` (string) is written to the \ program's standard input (use it for `tee`, `patch`, or any stdin filter instead \ @@ -238,7 +291,7 @@ async fn main() -> Result<()> { "Spawn an allowlisted command as a background job; returns { job_id, argv } \ immediately. Same payload as shell::exec (command + args, do NOT pass argv as an \ array), including the optional host-only `cwd` (jail-confined; escape is S215), \ - `env` (only allowed_env keys, never PATH/IFS/HOME/LD_*/DYLD_* or other loader/lookup \ + `env` (only keys in the operator's env.allow list, never PATH/IFS/HOME/LD_*/DYLD_* or other loader/lookup \ and interpreter-startup keys), and `stdin` (string written to the job's stdin); \ violations and cwd/env/stdin on a sandbox target reject with an S210 message. Poll \ with shell::status, terminate with shell::kill, list with shell::list. \ @@ -574,7 +627,7 @@ async fn wait_for_shutdown_signal() -> std::io::Result<()> { #[cfg(test)] mod tests { - use super::Cli; + use super::{ws_host_port, Cli}; use clap::Parser; #[test] @@ -582,4 +635,64 @@ mod tests { let cli = Cli::parse_from(["shell"]); assert_eq!(cli.config, "./config.yaml"); } + + /// `--version` must exist and report the crate version — operators use it + /// to check what a deployed binary actually is. + #[test] + fn version_flag_reports_crate_version() { + let err = Cli::try_parse_from(["shell", "--version"]) + .expect_err("--version short-circuits parsing"); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion); + assert!( + err.to_string().contains(env!("CARGO_PKG_VERSION")), + "renders the crate version: {err}" + ); + } + + /// The long help must surface the III_URL env var and the default engine + /// URL — this is the only self-documenting place for the binary's env vars. + #[test] + fn help_documents_url_env_and_default() { + use clap::CommandFactory; + let help = Cli::command().render_long_help().to_string(); + assert!(help.contains("III_URL"), "help names III_URL: {help}"); + assert!( + help.contains("ws://127.0.0.1:49134"), + "help shows the default URL: {help}" + ); + assert!( + help.contains("iii engine"), + "help describes what the URL points at: {help}" + ); + } + + #[test] + fn ws_host_port_parses_explicit_port() { + assert_eq!( + ws_host_port("ws://127.0.0.1:49134"), + Some(("127.0.0.1".to_string(), 49134)) + ); + } + + #[test] + fn ws_host_port_parses_ipv6_without_brackets() { + assert_eq!( + ws_host_port("ws://[::1]:1234"), + Some(("::1".to_string(), 1234)) + ); + } + + #[test] + fn ws_host_port_uses_known_default_ports() { + assert_eq!(ws_host_port("ws://localhost"), Some(("localhost".to_string(), 80))); + assert_eq!( + ws_host_port("wss://engine.example"), + Some(("engine.example".to_string(), 443)) + ); + } + + #[test] + fn ws_host_port_rejects_garbage() { + assert_eq!(ws_host_port("not a url"), None); + } } diff --git a/shell/tests/e2e/.gitignore b/shell/tests/e2e/.gitignore index 468b1078d..44fd5fa42 100644 --- a/shell/tests/e2e/.gitignore +++ b/shell/tests/e2e/.gitignore @@ -2,6 +2,11 @@ data/* !data/.gitkeep reports/* !reports/.gitkeep +# Engine-externalized worker config (runtime state): newer engines move the +# `workers[].config` blocks from config.yaml into ./config/*.yaml on boot and +# stamp the block with a pointer comment. The tracked config.yaml stays +# self-contained; delete ./config/ to re-derive from it. +config/ node_modules/ dist/ *.log diff --git a/shell/tests/e2e/config-jailed.yaml b/shell/tests/e2e/config-jailed.yaml index c0a970096..309f8b623 100644 --- a/shell/tests/e2e/config-jailed.yaml +++ b/shell/tests/e2e/config-jailed.yaml @@ -31,8 +31,9 @@ workers: default_timeout_ms: 1500 max_output_bytes: 4096 working_dir: ./data - inherit_env: false - allowed_env: [PATH, HOME, LANG, HARNESS_TEST_VAR] + env: + inherit: false + allow: [PATH, HOME, LANG, HARNESS_TEST_VAR] allowlist: [echo, ls, cat, sleep, pwd, printf, sh, false, "true", env] denylist_patterns: - "rm\\s+-rf\\s+/" diff --git a/shell/tests/e2e/config.yaml b/shell/tests/e2e/config.yaml index 7749136c3..5e65e6436 100644 --- a/shell/tests/e2e/config.yaml +++ b/shell/tests/e2e/config.yaml @@ -45,8 +45,9 @@ workers: default_timeout_ms: 1500 max_output_bytes: 4096 working_dir: ./data - inherit_env: false - allowed_env: [PATH, HOME, LANG, HARNESS_TEST_VAR] + env: + inherit: false + allow: [PATH, HOME, LANG, HARNESS_TEST_VAR] allowlist: [echo, ls, cat, sleep, pwd, printf, sh, false, "true", env] denylist_patterns: - "rm\\s+-rf\\s+/" diff --git a/shell/tests/e2e/run-tests.sh b/shell/tests/e2e/run-tests.sh index 0672e7a61..a420021ff 100755 --- a/shell/tests/e2e/run-tests.sh +++ b/shell/tests/e2e/run-tests.sh @@ -131,7 +131,7 @@ if [[ ! -d "$ROOT_DIR/workers/harness/node_modules" ]]; then fi # 5. Set env vars used by the env-scrubbing/passthrough cases. HARNESS_TEST_VAR -# is in allowed_env — should round-trip. HARNESS_NOT_ALLOWED is not — should be +# is in env.allow — should round-trip. HARNESS_NOT_ALLOWED is not — should be # scrubbed before reaching the spawned `env` command. export HARNESS_TEST_VAR="harness-allowed-value" export HARNESS_NOT_ALLOWED="harness-blocked-value" From 74ae382500c9f1d2bcf2d2d9c5410ab2db73d2fd Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 08:50:22 -0300 Subject: [PATCH 05/12] =?UTF-8?q?fix(shell):=20config=20review=20=E2=80=94?= =?UTF-8?q?=20anchor=20command-shaped=20denylist=20patterns,=20prefer=20ho?= =?UTF-8?q?st=5Froots,=20describe=20every=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the config.yaml review findings: - Anchor mkfs/dd/shutdown/reboot denylist patterns to argv[0] (^(\S*/)?name) so they fire when the tool IS the command, not when the word appears in an argument — 'grep -rn shutdown src/' no longer rejected. rm -rf /, the fork bomb, and /etc/shadow stay unanchored (argument-shaped by nature). Pinned by new allow/deny test cases. - Reword the denylist rejection to say it is an advisory tripwire and to rephrase, so agents stop retrying verbatim. - Seed uses the preferred fs.host_roots: [/tmp] (legacy host_root form dropped from the shipped example). - Seed default_timeout_ms 10s -> 30s (code default unchanged): the seed already raises max_timeout_ms to 120s for real builds; callers omitting timeout_ms shouldn't be reaped at 10s on the same workload. - fs.max_read/write_bytes schema descriptions explain why the code default is unlimited (streaming; the cap bounds caller cost, not worker memory); seed comments mark fs.denylist_paths as defense in depth and explain the passwd/shadow exec-side asymmetry. - Doc-comment every remaining CoderConfig budget field; the schema description test now sweeps ALL nested definitions. --- shell/CHANGELOG.md | 22 +++++++++ shell/README.md | 20 ++++---- shell/config.yaml | 35 ++++++++++---- shell/src/code/config.rs | 21 +++++++++ shell/src/config.rs | 98 ++++++++++++++++++++++++++++++++-------- 5 files changed, 159 insertions(+), 37 deletions(-) diff --git a/shell/CHANGELOG.md b/shell/CHANGELOG.md index 68e981773..aeb9f032b 100644 --- a/shell/CHANGELOG.md +++ b/shell/CHANGELOG.md @@ -27,6 +27,28 @@ binary itself. - A `## Running` README section documents the binary's full operator surface (`--config`, `--url`/`III_URL`, `--version`, `RUST_LOG`). +### Changed (shipped seed / defaults review) +- **Command-shaped denylist patterns are anchored to argv[0]** + (`^(\S*/)?mkfs|dd|shutdown|reboot`): they fire when the tool IS the command, + not when the word appears in an argument — `grep -rn shutdown src/` and + `rg "dd if=" docs/` are no longer rejected. Argument-shaped patterns + (`rm -rf /`, the fork bomb, `/etc/shadow`) stay unanchored. Stacks that + rewrite their stored value by hand should adopt the anchored forms too. +- The denylist rejection message now says it is an advisory tripwire and to + rephrase the command, so agents stop retrying verbatim. +- The seed uses the preferred multi-root jail form (`fs.host_roots: [/tmp]`) + instead of the legacy `fs.host_root`. +- Seed `default_timeout_ms` raised 10s → 30s: the seed raises + `max_timeout_ms` to 120s so real builds survive; callers omitting + `timeout_ms` shouldn't be reaped at 10s on the same workload. The CODE + default is unchanged (10s). +- `fs.max_read_bytes`/`fs.max_write_bytes` descriptions now explain why the + code default is unlimited (reads/writes stream in chunks; the cap bounds + caller cost, not worker memory), and the seed comments say + `fs.denylist_paths` is defense in depth (unreachable anyway while jailed). +- Every `code.*` (CoderConfig) budget field now carries a schema description; + the schema test covers all nested definitions, not just the top level. + ### Migration ```yaml # 0.6.x # 0.7.0 diff --git a/shell/README.md b/shell/README.md index 14b3833e0..e63cf8a50 100644 --- a/shell/README.md +++ b/shell/README.md @@ -51,14 +51,14 @@ it never exits, so supervised deployments recover as soon as the engine is up. Settings are managed through the central `configuration` worker. On boot, the shell worker registers its schema (id `shell`) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional `--config ` flag (default `./config.yaml`) provides the `initial_value` sent on first registration only; once registered, subsequent boots pull the stored value from the `configuration` worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see [Hot-reload](#hot-reload)). -The worker refuses to start unless `fs.host_root` is set, or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. +The worker refuses to start unless `fs.host_roots` is set (or the legacy one-entry `fs.host_root`), or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. By default, `mkdir`/`chmod`/`write` reject modes carrying setuid/setgid/sticky bits (the top octal digit, e.g. `4755`) with `S210`, since they are a privilege-escalation primitive when the worker runs as root inside the jail. Set `fs.allow_special_bits: true` only if your workload genuinely needs them. ```yaml max_timeout_ms: 120000 # foreground exec hard cap; per-call timeout_ms is clamped to this max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) -default_timeout_ms: 10000 # applied when the caller omits timeout_ms +default_timeout_ms: 30000 # applied when the caller omits timeout_ms (code default 10000) max_output_bytes: 1048576 # 1 MiB; stdout/stderr past this set *_truncated env: inherit: true # forward the worker's env to children; per-call dangerous keys still blocked @@ -67,22 +67,24 @@ env: # exec gate. argv[0] is matched by basename or exact path; an empty # allowlist means OPEN — the shipped default, so any command runs. # denylist_patterns are advisory regex over argv.join(" "), a tripwire for -# catastrophic mistakes only, NOT a security boundary. +# catastrophic mistakes only, NOT a security boundary. Command-shaped +# patterns are anchored to argv[0] so `grep -rn shutdown src/` is not +# rejected; argument-shaped ones (rm -rf /) stay unanchored. allowlist: [] denylist_patterns: - "rm\\s+-rf\\s+/" - - "mkfs" - - "dd\\s+if=" + - "^(\\S*/)?mkfs" + - "^(\\S*/)?dd\\s+if=" max_concurrent_jobs: 16 # exec_bg past this is rejected job_retention_secs: 3600 # finished jobs pruned after this fs: - host_root: /tmp # jail root for shell::fs::*; required (see above) - allow_unjailed: false # opt-in to running with host_root unset - max_read_bytes: 16777216 # 0 = unlimited + host_roots: [/tmp] # jail roots for shell::fs::*; first = primary (legacy alias: host_root) + allow_unjailed: false # opt-in to running with no jail root + max_read_bytes: 16777216 # 0 = unlimited (reads stream; cap bounds caller cost) max_write_bytes: 16777216 # 0 = unlimited - denylist_paths: [/etc/passwd, /etc/shadow] + denylist_paths: [/etc/passwd, /etc/shadow] # defense in depth; unreachable anyway while jailed allow_special_bits: false # permit setuid/setgid/sticky bits in mode (default false) sandbox: diff --git a/shell/config.yaml b/shell/config.yaml index 58ae38ba1..b31c27d84 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -3,7 +3,10 @@ # still go through shell::exec_bg (unbounded by default). max_timeout_ms: 120000 max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) -default_timeout_ms: 10000 +# 30s (not the 10s code default): callers that omit timeout_ms shouldn't have +# their first `cargo build` reaped while max_timeout_ms was raised for exactly +# that workload. +default_timeout_ms: 30000 max_output_bytes: 1048576 working_dir: null # Environment policy for spawned commands. @@ -37,30 +40,44 @@ allowlist: [] # sub-execution escapes (find -exec, sed -i, node/python -c, npm run, env ) # are pure dev friction with no security value, so they were dropped; only # catastrophic, host-wrecking patterns are kept to catch honest mistakes. +# +# Command-SHAPED patterns (mkfs/dd/shutdown/reboot) are anchored to argv[0] +# (`^(\S*/)?name`) so they fire only when the tool IS the command — a coding +# agent running `grep -rn shutdown src/` or `rg "dd if=" docs/` is not +# rejected. Argument-shaped patterns (rm -rf /, the fork bomb, /etc/shadow) +# stay unanchored: their dangerous form lives in the arguments. denylist_patterns: - "rm\\s+-rf\\s+/" - ":\\(\\)\\s*\\{\\s*:\\|" # fork bomb - - "mkfs" - - "dd\\s+if=" - - "shutdown" - - "reboot" + - "^(\\S*/)?mkfs" + - "^(\\S*/)?dd\\s+if=" + - "^(\\S*/)?shutdown\\b" + - "^(\\S*/)?reboot\\b" - "/etc/shadow" max_concurrent_jobs: 16 job_retention_secs: 3600 fs: - # SET host_root to a directory you intend to expose to shell::fs::*. - # When unset, the worker refuses to start unless allow_unjailed is true + # SET host_roots to the directories you intend to expose to shell::fs::*. + # The FIRST entry is the primary root (relative paths resolve against it). + # When empty, the worker refuses to start unless allow_unjailed is true # (because the alternative is "the entire filesystem is reachable # behind only the advisory denylist", which is rarely intended). + # `host_root` (singular) is a legacy one-entry alias; prefer this list. # # Default is /tmp: exists on every Unix host, is writable, and contains - # only ephemeral data. Operators should point this at the workspace + # only ephemeral data. Operators should point this at the workspace(s) # they intend the shell worker to manage. - host_root: /tmp + host_roots: [/tmp] allow_unjailed: false max_read_bytes: 16777216 max_write_bytes: 16777216 + # Defense in depth: while the jail is /tmp these paths are unreachable + # through shell::fs::* anyway (the jail rejects them first, S215); they + # exist so widening the jail — or going unjailed — never exposes them. + # The exec side can't be fs-jailed (children read the host freely), which + # is why /etc/shadow ALSO appears in denylist_patterns above; /etc/passwd + # is world-readable by design and gets no exec tripwire. denylist_paths: - /etc/passwd - /etc/shadow diff --git a/shell/src/code/config.rs b/shell/src/code/config.rs index 28c297724..4838365f4 100644 --- a/shell/src/code/config.rs +++ b/shell/src/code/config.rs @@ -10,6 +10,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Configuration for the folded `coder::*` code surface: protected/noise +/// globs plus per-call read/search/tree budgets. Roots are NOT taken from +/// here at runtime — the resolver uses `fs.host_roots` (one jail config). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CoderConfig { /// Legacy single-root form. Honored as a one-entry `base_paths` list. @@ -42,27 +45,45 @@ pub struct CoderConfig { #[serde(default = "default_default_exclude_globs")] pub default_exclude_globs: Vec, + /// Per-file IO ceiling, in bytes, for `coder::read-file` in every mode + /// (full, windowed, batch). A larger file fails with C213 naming the + /// size. Default 10485760 (10 MiB). #[serde(default = "default_max_read_bytes")] pub max_read_bytes: u64, + /// Cap, in bytes, on the content of a single `coder::create-file` / + /// `coder::update-file` call (C213 when exceeded). Default 10485760 + /// (10 MiB). #[serde(default = "default_max_write_bytes")] pub max_write_bytes: u64, + /// Directory depth `coder::tree` descends when the caller omits `depth`. + /// Default 4. #[serde(default = "default_tree_default_depth")] pub tree_default_depth: u32, + /// Maximum entries `coder::tree` lists per folder before eliding the + /// rest (flagged in the response). Default 50. #[serde(default = "default_tree_per_folder_limit")] pub tree_per_folder_limit: u32, + /// Page size `coder::list-folder` uses when the caller omits one. + /// Default 100. #[serde(default = "default_list_default_page_size")] pub list_default_page_size: u32, + /// Hard cap on a `coder::list-folder` page; a larger requested page size + /// is clamped to this. Default 1000. #[serde(default = "default_list_max_page_size")] pub list_max_page_size: u32, + /// Maximum matches one `coder::search` call returns when the caller + /// omits `max_matches`. Default 1000. #[serde(default = "default_search_max_matches")] pub search_default_max_matches: u32, + /// Per-line byte cap for `coder::search` results; longer matched lines + /// are truncated for the response. Default 4096. #[serde(default = "default_search_max_line_bytes")] pub search_default_max_line_bytes: u32, diff --git a/shell/src/config.rs b/shell/src/config.rs index 1e1e0bb19..9cde4e0ac 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -223,12 +223,17 @@ pub struct FsConfig { /// harnesses, sandbox-only deployments). #[serde(default)] pub allow_unjailed: bool, - /// Cap, in bytes, on a single `shell::fs::read`. `0` (the default) means - /// unlimited. The shipped seed sets 16777216 (16 MiB). + /// Cap, in bytes, on a single `shell::fs::read` (S218 when exceeded). + /// `0` (the default) means unlimited — safe because reads stream over a + /// channel in 64 KiB chunks rather than buffering the file in memory; the + /// cap exists to bound CALLER cost, not worker memory. The shipped seed + /// sets 16777216 (16 MiB). #[serde(default = "default_max_read_bytes")] pub max_read_bytes: usize, - /// Cap, in bytes, on a single `shell::fs::write`. `0` (the default) means - /// unlimited. The shipped seed sets 16777216 (16 MiB). + /// Cap, in bytes, on a single `shell::fs::write` (S218 mid-stream when + /// exceeded). `0` (the default) means unlimited — writes stream like + /// reads, so the cap bounds caller cost, not worker memory. The shipped + /// seed sets 16777216 (16 MiB). #[serde(default = "default_max_write_bytes")] pub max_write_bytes: usize, /// Absolute path prefixes that are hard-rejected (S215) by every fs @@ -342,21 +347,31 @@ impl ShellConfig { pub fn seed_default() -> Self { Self { max_timeout_ms: 120_000, + // 30s (not the 10s code default): the dev seed raises max_timeout_ms + // to 120s so real builds survive — a 10s default for callers that + // omit timeout_ms would undercut that on the first `cargo build`. + default_timeout_ms: 30_000, env: EnvConfig { inherit: true, ..EnvConfig::default() }, + // Command-SHAPED patterns (mkfs/shutdown/reboot/dd) are anchored to + // argv[0] — `^(\S*/)?name` fires when the tool IS the command, not + // when the word appears in an argument, so `grep -rn shutdown src/` + // or `rg "dd if=" docs/` are not rejected. Argument-shaped patterns + // (rm -rf /, the fork bomb, /etc/shadow) stay full-line: their + // dangerous form lives in the arguments. denylist_patterns: vec![ r"rm\s+-rf\s+/".into(), r":\(\)\s*\{\s*:\|".into(), - "mkfs".into(), - r"dd\s+if=".into(), - "shutdown".into(), - "reboot".into(), + r"^(\S*/)?mkfs".into(), + r"^(\S*/)?dd\s+if=".into(), + r"^(\S*/)?shutdown\b".into(), + r"^(\S*/)?reboot\b".into(), "/etc/shadow".into(), ], fs: FsConfig { - host_root: Some(PathBuf::from("/tmp")), + host_roots: vec![PathBuf::from("/tmp")], max_read_bytes: 16_777_216, max_write_bytes: 16_777_216, denylist_paths: vec![PathBuf::from("/etc/passwd"), PathBuf::from("/etc/shadow")], @@ -449,7 +464,12 @@ impl ShellConfig { let joined = argv.join(" "); for re in &self.compiled_denylist { if re.is_match(&joined) { - return Err(format!("command matches denylist: {}", re.as_str())); + return Err(format!( + "command matches denylist pattern '{}' — an advisory tripwire for \ + catastrophic mistakes, not a security boundary; rephrase the command \ + to avoid the pattern", + re.as_str() + )); } } @@ -698,6 +718,34 @@ mod tests { .is_command_allowed(&["rm".into(), "-rf".into(), "/".into()]) .expect_err("rm -rf / must still trip the denylist"); assert!(err.contains("denylist"), "got: {err}"); + + // Command-shaped patterns are anchored to argv[0]: they fire when the + // tool IS the command (bare or path-qualified)... + for argv in [ + vec!["shutdown".to_string(), "-h".into(), "now".into()], + vec!["/sbin/shutdown".to_string(), "-r".into()], + vec!["reboot".to_string()], + vec!["mkfs.ext4".to_string(), "/dev/sda1".into()], + vec!["dd".to_string(), "if=/dev/zero".into(), "of=/dev/sda".into()], + ] { + assert!( + c.is_command_allowed(&argv).is_err(), + "{argv:?} must trip the anchored denylist" + ); + } + // ...but NOT when the word merely appears in an argument — a coding + // agent grepping a codebase for "shutdown" is not a mistake. + for argv in [ + vec!["grep".to_string(), "-rn".into(), "shutdown".into(), "src/".into()], + vec!["cargo".to_string(), "test".into(), "reboot".into()], + vec!["rg".to_string(), "dd if=".into(), "docs/".into()], + vec!["git".to_string(), "log".into(), "--grep".into(), "mkfs".into()], + ] { + assert!( + c.is_command_allowed(&argv).is_ok(), + "{argv:?} must NOT trip the denylist (argument, not command)" + ); + } } /// `seed_default()` is the in-code twin of the shipped `config.yaml` — the @@ -803,16 +851,28 @@ mod tests { "config field `{name}` has no schema description (console UI shows it bare)" ); } - let env_props = &schema["definitions"]["EnvConfig"]["properties"]; - for key in ["inherit", "allow"] { - assert!( - env_props[key]["description"] - .as_str() - .is_some_and(|s| !s.is_empty()), - "EnvConfig.{key} has no schema description" - ); + // Same rule for every nested definition (EnvConfig, FsConfig, + // SandboxConfig, CoderConfig, and anything added later): the console + // renders their fields too. + let defs = schema["definitions"] + .as_object() + .expect("nested definitions"); + for (def_name, def) in defs { + let Some(props) = def["properties"].as_object() else { + continue; // non-object definitions (enums etc.) have no fields + }; + for (name, prop) in props { + assert!( + prop.get("description") + .and_then(|d| d.as_str()) + .is_some_and(|s| !s.is_empty()), + "{def_name}.{name} has no schema description (console UI shows it bare)" + ); + } } - let allow_desc = env_props["allow"]["description"].as_str().unwrap(); + let allow_desc = schema["definitions"]["EnvConfig"]["properties"]["allow"]["description"] + .as_str() + .expect("env.allow described"); assert!( allow_desc.contains("Forwarding") && allow_desc.contains("Per-call"), "env.allow description must explain both roles: {allow_desc}" From 75bd2fd690601a83579cc07d95adac1e3fdf14f5 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 09:23:27 -0300 Subject: [PATCH 06/12] =?UTF-8?q?refactor(shell)!:=20drop=20legacy=20confi?= =?UTF-8?q?g=20carry-over=20=E2=80=94=20fs.host=5Froot,=20code.base=5Fpath?= =?UTF-8?q?(s),=20coder=20migration=20fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rides the 0.7.0 breaking window (stored values already need a rewrite): - fs.host_root (0.6.x single-root alias) removed. Rejected at parse with a migration hint (fs.host_root -> fs.host_roots, one-entry list), same fail-closed rationale as the env rename: serde would silently ignore the stale key and the worker would see no jail configured. FsConfig::roots() and is_jailed() lose the legacy branch; the both-keys-set config error disappears with the alias. - code.base_path removed and code.base_paths taken off the wire (serde+schemars skip). They were inert: the code resolver has always taken its roots from fs.host_roots via code_resolver_config, so stored values still carrying them are silently ignored — no reject, they never had an effect. - The one-shot coder->shell config migration is retired (migrate_legacy_coder + hidden migrated_from_coder marker). Boot no longer probes configuration::get for 'coder', which also removes the "configuration 'coder' not found" WARN retries from every boot. Stacks that still need the fold should boot 0.6.x once before upgrading. Tests: legacy-alias boot test inverted into a rejection test; new yaml/json fs.host_root rejection tests; golden C210 case regenerated (no-reachable-roots replaces both-root-forms). 1270 unit tests, e2e 171/171 + jailed 2/2. --- shell/ARCHITECTURE.md | 16 +- shell/CHANGELOG.md | 17 + shell/README.md | 44 ++- shell/config.collect.yaml | 2 +- shell/config.yaml | 4 +- shell/skills/SKILL.md | 2 +- shell/src/code/config.rs | 93 ++--- shell/src/code/path.rs | 77 ++-- shell/src/config.rs | 189 +++++----- shell/src/configuration.rs | 347 +++--------------- shell/src/exec/host.rs | 6 +- shell/src/exec/policy.rs | 50 +-- shell/src/fs/host.rs | 101 +++-- shell/src/fs/mod.rs | 22 +- shell/src/functions/types.rs | 6 +- shell/src/functions/workspace.rs | 2 +- shell/src/main.rs | 14 +- shell/src/path/mod.rs | 6 +- shell/tests/code_golden_errors.rs | 12 +- shell/tests/e2e/README.md | 6 +- shell/tests/e2e/config-jailed.yaml | 4 +- shell/tests/e2e/config.yaml | 6 +- shell/tests/e2e/run-tests-jailed.sh | 4 +- .../e2e/workers/harness/src/cases-safety.ts | 4 +- .../harness/src/cases-vuln-repro-jailed.ts | 8 +- .../workers/harness/src/cases-vuln-repro.ts | 6 +- shell/tests/e2e/workers/harness/src/runner.ts | 2 +- shell/tests/golden/errors.json | 4 +- 28 files changed, 395 insertions(+), 659 deletions(-) diff --git a/shell/ARCHITECTURE.md b/shell/ARCHITECTURE.md index 5bc65e70d..451c327e6 100644 --- a/shell/ARCHITECTURE.md +++ b/shell/ARCHITECTURE.md @@ -17,7 +17,7 @@ cargo build --release --bin iii-shell mkdir -p ~/.iii/workers ln -sfn $(pwd)/target/release/iii-shell ~/.iii/workers/shell -# 4. Start the engine (it spawns the worker). Pin a host_root or set +# 4. Start the engine (it spawns the worker). Pin fs.host_roots or set # fs.allow_unjailed: true in config.yaml first — the worker refuses to # start unjailed by default. iii -c ./config.yaml @@ -43,8 +43,8 @@ The shell worker integrates with the central `configuration` worker rather than 2. It immediately fetches the live value over RPC and activates the security policy and fs backend from that response. 3. It then registers the `configuration:updated` trigger and runs a **fail-closed** boot reconcile before exposing any public function. The reconcile re-fetches the authoritative value (closing the race where an update lands between the initial fetch and trigger registration, leaving no listener). If that re-fetch fails the worker aborts startup — it exits rather than serve a possibly stale security policy, and no `shell::*` / `shell::fs::*` function is ever exposed. 4. It subscribes to `configuration:updated` events. When the config for schema id `shell` changes, the worker hot-reloads the security policy and fs backend atomically. -5. If the incoming config is invalid or unsafe (e.g. schema validation passes but the worker cannot build it — bad denylist regex, unreachable `host_root`), the worker keeps the last-good runtime and logs an error — it does **not** crash, and it does **not** retry (re-fetching returns the same bad value, so a retry would storm). The rejection is recorded and surfaced by `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count) so the divergence between the central store and the enforced policy is detectable instead of silent. -6. A reload that widens the jail (clearing `host_root`) succeeds, but is logged as a privilege change. +5. If the incoming config is invalid or unsafe (e.g. schema validation passes but the worker cannot build it — bad denylist regex, unreachable jail root), the worker keeps the last-good runtime and logs an error — it does **not** crash, and it does **not** retry (re-fetching returns the same bad value, so a retry would storm). The rejection is recorded and surfaced by `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count) so the divergence between the central store and the enforced policy is detectable instead of silent. +6. A reload that widens the jail (clearing `host_roots`) succeeds, but is logged as a privilege change. ## Full YAML defaults @@ -66,8 +66,8 @@ deliberately more permissive for dev use: `env.inherit true`, jailed to `/tmp`, | `denylist_patterns` | `[]` | advisory regex tripwire on `argv.join(" ")` | | `max_concurrent_jobs` | `16` | rejects new `exec_bg` past the cap | | `job_retention_secs` | `3600` | finished jobs evicted by a background reaper (interval `min(30s, retention/2)`) — the primary prune path; prune-on-`shell::list` remains as a harmless secondary trigger | -| `fs.host_root` | `null` | jail root; required unless `fs.allow_unjailed: true` | -| `fs.allow_unjailed` | `false` | explicit opt-in to running with `host_root: null` | +| `fs.host_roots` | `[]` | jail roots; first = primary; required non-empty unless `fs.allow_unjailed: true` | +| `fs.allow_unjailed` | `false` | explicit opt-in to running with an empty `host_roots` | | `fs.max_read_bytes` | `0` (unlimited) | pre-flight cap via `fs::metadata` (`S218`) | | `fs.max_write_bytes` | `0` (unlimited) | mid-stream cap during write (`S218`) | | `fs.denylist_paths` | `[]` | absolute-prefix denylist; rejected with `S215` | @@ -76,7 +76,7 @@ deliberately more permissive for dev use: `env.inherit true`, jailed to `/tmp`, ## Threat model -The host backend's path-validation gate is check-then-use: there is a TOCTOU window between validation and the `std::fs::*` call. Validation walks to the longest existing ancestor, canonicalizes that (resolving symlinks in the existing portion), and lexically collapses the non-existent tail before the `starts_with(host_root)` check — so a symlink whose target escapes the jail cannot slip through the lexical fallback. The worker is intended for trusted caller pipelines; for untrusted input, use the sandbox backend. +The host backend's path-validation gate is check-then-use: there is a TOCTOU window between validation and the `std::fs::*` call. Validation walks to the longest existing ancestor, canonicalizes that (resolving symlinks in the existing portion), and lexically collapses the non-existent tail before the jail-root containment check — so a symlink whose target escapes the jail cannot slip through the lexical fallback. The worker is intended for trusted caller pipelines; for untrusted input, use the sandbox backend. Host-targeted calls run with the shell worker's OS permissions. The denylist is regex over `argv.join(" ")` and only catches honest typos — a caller invoking an allowlisted shell or interpreter (`sh`, `node`, `python`, …) can bypass it by construction. The actual security boundary is `target: { kind: "sandbox", sandbox_id }`. @@ -146,11 +146,11 @@ let bytes = reader.read_all().await?; | Symptom | Cause | Fix | |---|---|---| -| `fs.host_root is unset and fs.allow_unjailed is false — refusing to start unjailed` | Default config no longer permits running unjailed. | Set `fs.host_root` to a directory, OR set `fs.allow_unjailed: true`. | +| `fs.host_roots is empty and fs.allow_unjailed is false — refusing to start unjailed` | Default config no longer permits running unjailed. | Set `fs.host_roots` to at least one directory, OR set `fs.allow_unjailed: true`. | | `command 'xyz' not in allowlist` | `allowlist` is non-empty and doesn't include the binary's basename. | Add it to `allowlist`, or empty the list to allow anything. | | Worker never connects to engine | Engine isn't running or isn't bound on the URL the worker is configured for. | Start the engine first; check `--url` matches. The default WS port is 49134. | | Engine started but doesn't see the worker | Binary isn't symlinked at `~/.iii/workers/shell`. | `ln -sfn $(pwd)/target/release/iii-shell ~/.iii/workers/shell` | -| `S215 path escapes host_root` on a path inside the jail | A symlink in the path resolves outside the jail. | Resolve the symlink yourself, or move the target inside `host_root`. | +| `S215 path escapes the fs jail roots` on a path inside the jail | A symlink in the path resolves outside the jail. | Resolve the symlink yourself, or move the target inside a jail root. | ## Tests diff --git a/shell/CHANGELOG.md b/shell/CHANGELOG.md index aeb9f032b..bc3bade31 100644 --- a/shell/CHANGELOG.md +++ b/shell/CHANGELOG.md @@ -13,6 +13,23 @@ binary itself. deliberate fail-closed behavior: serde ignores unknown fields, so accepting the old shape would silently boot with `env.inherit false` and stop forwarding the worker's environment to children. +- **`fs.host_root` (the 0.6.x single-root alias) is removed** — use + `fs.host_roots` (one-entry list). Like the env keys it is **rejected at + parse** with a migration hint (`fs.host_root` -> `fs.host_roots`); serde + would otherwise ignore the stale key and the worker would see no jail + configured at all. +- **`code.base_path` and `code.base_paths` are removed from the schema.** + They were inert: the code resolver has always taken its roots from + `fs.host_roots` (one jail config), so stored values still carrying them are + silently **ignored** (no reject — they never had an effect). +- **The one-shot coder→shell config migration is removed** + (`migrate_legacy_coder` and the hidden `migrated_from_coder` marker field). + 0.7.0 no longer folds a legacy standalone-`coder` configuration entry into + the `shell` value at boot, and boot no longer probes `configuration::get` + for a `coder` entry — which also removes the boot-time + "configuration 'coder' not found" WARN retries. Stored values still + carrying the marker parse fine (it is ignored). Stacks that still need the + fold should boot 0.6.x once before upgrading. ### Added - `--version` prints the worker version. diff --git a/shell/README.md b/shell/README.md index e63cf8a50..203a8d241 100644 --- a/shell/README.md +++ b/shell/README.md @@ -51,7 +51,7 @@ it never exits, so supervised deployments recover as soon as the engine is up. Settings are managed through the central `configuration` worker. On boot, the shell worker registers its schema (id `shell`) and fetches the live value over RPC — that live value is the authoritative config, not a local file. The optional `--config ` flag (default `./config.yaml`) provides the `initial_value` sent on first registration only; once registered, subsequent boots pull the stored value from the `configuration` worker. When the config changes, the worker hot-reloads the security policy and fs backend automatically (see [Hot-reload](#hot-reload)). -The worker refuses to start unless `fs.host_roots` is set (or the legacy one-entry `fs.host_root`), or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. +The worker refuses to start unless `fs.host_roots` is set, or `fs.allow_unjailed: true` is explicitly opted in, because an unset root exposes the whole host filesystem behind only the advisory denylist. By default, `mkdir`/`chmod`/`write` reject modes carrying setuid/setgid/sticky bits (the top octal digit, e.g. `4755`) with `S210`, since they are a privilege-escalation primitive when the worker runs as root inside the jail. Set `fs.allow_special_bits: true` only if your workload genuinely needs them. @@ -80,7 +80,7 @@ max_concurrent_jobs: 16 # exec_bg past this is rejected job_retention_secs: 3600 # finished jobs pruned after this fs: - host_roots: [/tmp] # jail roots for shell::fs::*; first = primary (legacy alias: host_root) + host_roots: [/tmp] # jail roots for shell::fs::*; first = primary allow_unjailed: false # opt-in to running with no jail root max_read_bytes: 16777216 # 0 = unlimited (reads stream; cap bounds caller cost) max_write_bytes: 16777216 # 0 = unlimited @@ -93,7 +93,7 @@ sandbox: ### Zero-config default -With no `--config` file and no value stored in the `configuration` worker, the worker seeds a built-in default on first registration — so it boots with nothing configured (database-style). That built-in default is the shipped [`config.yaml`](config.yaml): jailed to `/tmp`, env forwarded, open exec with a catastrophic-only denylist (kept in sync by a unit test). If the stored value is later nulled, the worker does not silently fall back to this seed: boot fails closed and a hot-reload keeps the last-good config. A config that is *present* but leaves `fs.host_root` unset (without `fs.allow_unjailed: true`) also fails closed. +With no `--config` file and no value stored in the `configuration` worker, the worker seeds a built-in default on first registration — so it boots with nothing configured (database-style). That built-in default is the shipped [`config.yaml`](config.yaml): jailed to `/tmp`, env forwarded, open exec with a catastrophic-only denylist (kept in sync by a unit test). If the stored value is later nulled, the worker does not silently fall back to this seed: boot fails closed and a hot-reload keeps the last-good config. A config that is *present* but leaves `fs.host_roots` unset (without `fs.allow_unjailed: true`) also fails closed. Host `shell::exec` is not a security boundary: any allowlisted interpreter (`sh`, `node`, `python3`) can construct a denylisted token at runtime and bypass the regex. Run untrusted input with `target: { kind: "sandbox", sandbox_id }`, which forwards through the `iii-sandbox` microVM. The allowlist and denylist still apply on top of either backend. @@ -101,7 +101,7 @@ Host `shell::exec` is not a security boundary: any allowlisted interpreter (`sh` `shell::exec` and `shell::exec_bg` each accept optional fields so an agent can scope a single command to a directory, set specific env values, and feed it standard input without wrapping everything in `sh -lc` (which would defeat the argv allowlist): -- **`cwd`** (string): the working directory for this one call. It is confined to the fs jail **exactly** like `shell::fs::*` paths — jail-relative when `fs.host_root` is set (else absolute), canonicalized, and required to resolve inside `host_root` and miss `denylist_paths`. A `cwd` that escapes the jail returns `S215`; one that doesn't exist or isn't a directory returns `S211`/`S210`. Omit it to use the configured `working_dir` (unchanged default). +- **`cwd`** (string): the working directory for this one call. It is confined to the fs jail **exactly** like `shell::fs::*` paths — jail-relative when `fs.host_roots` is set (else absolute), canonicalized, and required to resolve inside a jail root and miss `denylist_paths`. A `cwd` that escapes the jail returns `S215`; one that doesn't exist or isn't a directory returns `S211`/`S210`. Omit it to use the configured `working_dir` (unchanged default). - **`env`** (object of string→string): per-call environment values. A key may be set **only** if the operator already listed it in `env.allow`, and **never** for an exec-hijacking key — `PATH`, `IFS`, `HOME`, every `LD_*`/`DYLD_*` variant, and other loader/lookup-path and interpreter startup-file keys (`GCONV_PATH`, `BASH_ENV`, `ENV`, `PYTHONSTARTUP`, `PERL5OPT`, `RUBYOPT`, `NODE_OPTIONS`, …) are on a hardcoded denylist that **wins over** `env.allow`. Note that `HOME` ships in the default `env.allow` for the worker's own forwarded env but is **not** settable per-call. Supplying a key that is not in `env.allow`, or any dangerous key, rejects the **whole call** with `S210` (the offending key is named and the permitted keys are listed); the env is never silently dropped. A permitted per-call value overrides the value that would otherwise be forwarded for that key. So an agent can do `NODE_ENV=test` only if the operator put `NODE_ENV` in `env.allow`, and can never inject `PATH`, `HOME`, or `LD_PRELOAD`. - **`stdin`** (string): written to the program's standard input, which is then closed (EOF). Use it to feed `tee`, `patch`, `cat`, or any stdin filter instead of a shell heredoc. Omit it and stdin is `/dev/null`. @@ -174,7 +174,7 @@ When the `configuration` worker pushes an updated config, the shell worker swaps - Each call executes against one consistent runtime snapshot; there is no mid-call config change. - Already-running background jobs are **not** retroactively re-checked when the policy tightens — they continue under the policy that was active when they were spawned. -- A reload that widens the jail (for example, clearing `host_root`) succeeds but is logged as a privilege change. +- A reload that widens the jail (for example, clearing `host_roots`) succeeds but is logged as a privilege change. - If the incoming config is invalid or unsafe, the worker keeps the last-good runtime and logs an error. The rejection is also surfaced through `shell::config-status` (a `rejected` outcome with a non-zero `rejected_reloads` count), so the divergence between the central store and the policy shell is actually enforcing is detectable instead of silent. Rejections are kept last-good and not retried (re-fetching returns the same bad value), so they will not retry-storm. - At boot the reconcile against the configuration worker is **fail-closed**: the worker refuses to start (and exposes no functions) if it cannot confirm the authoritative config, so it never serves a possibly stale security policy. @@ -190,7 +190,7 @@ Returned error bodies carry a stable `code` field. Allowlist and denylist reject | `S212` | Wrong file type for the operation (for example, a file where a directory was expected). | | `S213` | Path already exists. | | `S214` | Directory not empty (non-recursive `rm`). | -| `S215` | Path (or a per-call `cwd`) escapes `host_root`, hits `fs.denylist_paths`, or permission denied. | +| `S215` | Path (or a per-call `cwd`) escapes the `fs.host_roots` jail, hits `fs.denylist_paths`, or permission denied. | | `S216` | Generic shell-internal failure: host spawn error, channel error, or a bad engine response. | | `S217` | Invalid regex passed to `grep`/`sed`. | | `S218` | `fs.max_read_bytes` / `fs.max_write_bytes` cap exceeded. | @@ -219,6 +219,32 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i value — writing the new shape while 0.6.x is still running makes the old worker hot-reload it, ignore the unknown `env` block, and silently stop forwarding env until restart. +- **BREAKING: `fs.host_root` (single-root alias) removed.** The 0.6.x + one-entry alias for the jail root is **rejected at parse** with a migration + hint ("config key removed in 0.7.0: `fs.host_root` -> `fs.host_roots` + (one-entry list)"). Replace it with the list form: + + ```yaml + # 0.6.x # 0.7.0 + fs: fs: + host_root: /srv/app host_roots: [/srv/app] + ``` + + Same fail-closed rationale as the env keys: serde would otherwise ignore + the stale key and the worker would see no jail configured at all. +- **BREAKING: `code.base_path`/`code.base_paths` removed from the schema.** + They were inert — the code resolver has taken its roots from + `fs.host_roots` since the coder merge — so stored values still carrying + them are silently **ignored** (no reject; they never had an effect). Set + the jail once via `fs.host_roots`. +- **The one-shot coder→shell config migration is removed.** 0.7.0 no longer + folds a legacy standalone-`coder` configuration entry into the `shell` + value at boot (the `migrated_from_coder` marker field is gone too; stored + values still carrying it parse fine and the marker is ignored). Boot also + no longer probes `configuration::get` for the `coder` entry, so the + "configuration 'coder' not found" WARN retries at startup are gone. If you + are upgrading a pre-0.6 stack that still relies on the fold, boot 0.6.x + once first (it performs the migration), then upgrade to 0.7.0. - **`--version` added**, and `--url`/`III_URL` and `RUST_LOG` are now documented (see [Running](#running)). - **Unreachable-engine boot is loud**: one ERROR with the URL and the fix @@ -241,12 +267,12 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i ## Troubleshooting -- **`fs.host_root is unset ... refusing to start unjailed`**: set `fs.host_root` to a directory, or set `fs.allow_unjailed: true`. +- **`fs.host_roots is empty ... refusing to start unjailed`**: set `fs.host_roots` to at least one directory, or set `fs.allow_unjailed: true`. - **`command '' not in allowlist`**: the basename of `argv[0]` is not in a non-empty `allowlist`. Add it, or empty the list to allow anything. -- **`S215 path escapes host_root` on a path inside the jail**: a symlink in the path resolves outside the jail. Resolve it yourself, or move the target inside `host_root`. +- **`S215 path escapes the fs jail roots` on a path inside the jail**: a symlink in the path resolves outside the jail. Resolve it yourself, or move the target inside a jail root. - **`S300` on a sandbox target**: the host cannot boot microVMs. Sandbox execution requires Apple Silicon or `/dev/kvm`. - **Worker never connects**: the engine is not running or not bound on the configured `--url`. Start the engine first; the default WebSocket port is 49134. -- **`config keys removed in 0.7.0: ...` at boot or on reload**: the seed file or the stored configuration value still uses the 0.6.x `inherit_env`/`allowed_env` keys. Nest them under `env:` (`inherit`/`allow`) — see [Upgrading to 0.7.0](#upgrading-to-070). +- **`config keys removed in 0.7.0: ...` at boot or on reload**: the seed file or the stored configuration value still uses the 0.6.x `inherit_env`/`allowed_env` keys (nest them under `env:` as `inherit`/`allow`) or the single-root `fs.host_root` alias (use `fs.host_roots: []`) — see [Upgrading to 0.7.0](#upgrading-to-070). For the threat model, streaming wire shapes, and contributor build steps, see [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/shell/config.collect.yaml b/shell/config.collect.yaml index e6ddad230..3886ad393 100644 --- a/shell/config.collect.yaml +++ b/shell/config.collect.yaml @@ -4,7 +4,7 @@ # copy of this worker purely to read back the functions it registers with the # engine — the published "interface". The shell worker refuses to start unless # the fs jail is configured. Interface collection runs no fs operations, so we -# boot UNJAILED (allow_unjailed: true, no host_root) — this needs no pre-created +# boot UNJAILED (allow_unjailed: true, no host_roots) — this needs no pre-created # directory, so it works on a clean CI runner with just `--config config.collect.yaml`. max_bg_timeout_ms: 0 # host bg job hard cap in ms; 0 = unbounded (foreground uses max_timeout_ms) fs: diff --git a/shell/config.yaml b/shell/config.yaml index b31c27d84..56b838036 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -31,7 +31,7 @@ env: # (cargo, git, bash, make, node, python3, …). This is deliberate — a coding # agent needs arbitrary build/test/VCS tooling, and a half-open list is # defeated the moment any shell/interpreter is on it. This worker is NOT a -# sandbox: the security boundary is the fs jail (fs.host_root) plus the +# sandbox: the security boundary is the fs jail (fs.host_roots) plus the # optional sandbox backend, NOT this list. To re-scope exec to deny-by-default, # list the permitted argv[0] basenames here (a non-empty list flips the gate). allowlist: [] @@ -63,7 +63,7 @@ fs: # When empty, the worker refuses to start unless allow_unjailed is true # (because the alternative is "the entire filesystem is reachable # behind only the advisory denylist", which is rarely intended). - # `host_root` (singular) is a legacy one-entry alias; prefer this list. + # (the 0.6.x `host_root` single-root form was removed in 0.7.0) # # Default is /tmp: exists on every Unix host, is writable, and contains # only ephemeral data. Operators should point this at the workspace(s) diff --git a/shell/skills/SKILL.md b/shell/skills/SKILL.md index 9469571d2..f87c3ec09 100644 --- a/shell/skills/SKILL.md +++ b/shell/skills/SKILL.md @@ -48,7 +48,7 @@ agents, pair with the `skills` worker. - Host `shell::exec` is not a security sandbox: the denylist is bypassable by any allowlisted interpreter. Run untrusted commands with `target: sandbox` (needs `iii-sandbox`). -- `shell::fs::*` is jailed to `cfg.fs.host_root` and refuses denylisted paths; +- `shell::fs::*` is jailed to `cfg.fs.host_roots` and refuses denylisted paths; paths must be absolute and symlinks are never followed. - Sandbox-backed background jobs cannot be hard-killed: `shell::kill` flips the record but the in-VM process runs until its `timeout_ms` (or `sandbox::stop`). diff --git a/shell/src/code/config.rs b/shell/src/code/config.rs index 4838365f4..160b3e2fd 100644 --- a/shell/src/code/config.rs +++ b/shell/src/code/config.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +#[cfg(test)] use serde_json::Value; /// Configuration for the folded `coder::*` code surface: protected/noise @@ -15,19 +16,18 @@ use serde_json::Value; /// here at runtime — the resolver uses `fs.host_roots` (one jail config). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CoderConfig { - /// Legacy single-root form. Honored as a one-entry `base_paths` list. - /// Setting BOTH `base_path` and `base_paths` is a startup error - /// (checked at `PathResolver` construction). - #[serde(default)] - pub base_path: Option, - - /// Root directories the worker operates inside. The FIRST entry is - /// the primary root: relative wire paths resolve against it. Absolute - /// wire paths are accepted when they canonicalize inside ANY listed - /// root. When neither this nor `base_path` is set, the effective + /// Runtime plumbing, NEVER read from config: the resolver's roots are + /// copied here from `fs.host_roots` by + /// `ShellConfig::code_resolver_config`, so there is a single jail + /// config. The FIRST entry is the primary root: relative wire paths + /// resolve against it; absolute wire paths are accepted when they + /// canonicalize inside ANY listed root. When empty, the effective /// default is `["./", "/tmp"]` (resolved at `PathResolver` - /// construction). - #[serde(default)] + /// construction). The 0.6.x `code.base_path`/`base_paths` config keys + /// were removed from the schema in 0.7.0; stored values still carrying + /// them are silently ignored — deliberate, they never had an effect. + #[serde(skip)] + #[schemars(skip)] pub base_paths: Vec, /// Glob patterns matched against the path *relative to its containing @@ -178,9 +178,6 @@ fn default_search_response_budget_bytes() -> u64 { #[cfg(test)] #[derive(Clone, PartialEq, Eq, Debug)] pub struct JailSignature { - /// Legacy single-root form. A change re-roots the jail — restart-required - /// (`PathResolver` compiles the effective root set from this + `base_paths`). - pub base_path: Option, /// The root directories the jail confines all access to. A change to the /// set (or order — the first entry is the primary root) moves the security /// boundary, so it is restart-required: the `PathResolver` canonicalizes @@ -201,7 +198,6 @@ pub struct JailSignature { impl Default for CoderConfig { fn default() -> Self { Self { - base_path: None, base_paths: Vec::new(), non_accessible_globs: Vec::new(), default_exclude_globs: default_default_exclude_globs(), @@ -222,9 +218,9 @@ impl Default for CoderConfig { impl CoderConfig { /// Test helper: parse a config from a YAML string, expanding `${NAME}` - /// against the process environment first. Production loads the `code` block - /// as JSON via [`from_json`]; this YAML path is exercised only by unit - /// tests. + /// against the process environment first. Production deserializes the + /// `code` block as part of `ShellConfig` (serde derive); this YAML path + /// is exercised only by unit tests. #[cfg(test)] pub fn from_yaml(yaml: &str) -> Result { let expanded = expand_env(yaml); @@ -233,9 +229,12 @@ impl CoderConfig { Ok(cfg) } - /// Parse a config from a JSON value already env-expanded by the - /// configuration worker. Does NOT run `expand_env` — double-expansion would - /// be a bug — and tolerates a zero-field object (serde defaults fill in). + /// Parse a config from a standalone JSON value. Does NOT run `expand_env` + /// — double-expansion would be a bug — and tolerates a zero-field object + /// (serde defaults fill in). Production deserializes the `code` block as + /// part of `ShellConfig`; this was the coder-migration entry point and is + /// now exercised only by unit tests. + #[cfg(test)] pub fn from_json(value: &Value) -> Result { let cfg: CoderConfig = serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}"))?; @@ -247,9 +246,9 @@ impl CoderConfig { serde_json::to_value(self).expect("CoderConfig serializes") } - /// Build the restart-required jail signature. These four fields are - /// EVERYTHING the `PathResolver` compiles: the root set (`base_path` + - /// `base_paths`) that bounds the security jail, the access-deny globs + /// Build the restart-required jail signature. These three fields are + /// EVERYTHING the `PathResolver` compiles: the root set (`base_paths`) + /// that bounds the security jail, the access-deny globs /// (`non_accessible_globs`), and the resolver-compiled noise filter /// (`default_exclude_globs`). A live config update that changes ANY of them /// is refused on hot-reload (logged "restart coder to apply", previous @@ -259,7 +258,6 @@ impl CoderConfig { #[cfg(test)] pub fn jail_signature(&self) -> JailSignature { JailSignature { - base_path: self.base_path.clone(), base_paths: self.base_paths.clone(), non_accessible_globs: self.non_accessible_globs.clone(), default_exclude_globs: self.default_exclude_globs.clone(), @@ -310,7 +308,6 @@ mod tests { #[test] fn empty_yaml_parses_to_defaults() { let cfg: CoderConfig = serde_yaml::from_str("{}").expect("empty yaml parses"); - assert_eq!(cfg.base_path, None); assert!(cfg.base_paths.is_empty()); assert!(cfg.non_accessible_globs.is_empty()); assert_eq!( @@ -348,18 +345,19 @@ mod tests { } #[test] - fn legacy_base_path_parses_as_option() { - let cfg: CoderConfig = serde_yaml::from_str("base_path: /tmp/legacy").unwrap(); - assert_eq!(cfg.base_path, Some(PathBuf::from("/tmp/legacy"))); + fn stored_base_path_and_base_paths_are_ignored() { + // Removed in 0.7.0 WITHOUT a reject: these keys never had a runtime + // effect (code_resolver_config always overwrote the roots from + // fs.host_roots), so an old stored value carrying them parses fine + // and the roots stay runtime-filled (empty here). + let cfg: CoderConfig = + serde_yaml::from_str("base_path: /tmp/legacy\nbase_paths: [/tmp/x]\n").unwrap(); assert!(cfg.base_paths.is_empty()); } #[test] fn custom_yaml_overrides_each_field() { let yaml = r#" -base_paths: - - /tmp/c - - /tmp/d non_accessible_globs: - "**/.env" default_exclude_globs: @@ -377,11 +375,6 @@ max_output_bytes: 31 search_response_budget_bytes: 29 "#; let cfg: CoderConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(cfg.base_path, None); - assert_eq!( - cfg.base_paths, - vec![PathBuf::from("/tmp/c"), PathBuf::from("/tmp/d")] - ); assert_eq!(cfg.non_accessible_globs, vec!["**/.env".to_string()]); assert_eq!(cfg.default_exclude_globs, vec!["**/build/**".to_string()]); assert_eq!(cfg.max_read_bytes, 42); @@ -414,13 +407,11 @@ search_response_budget_bytes: 29 #[test] fn from_json_round_trips_custom_values() { let json = serde_json::json!({ - "base_paths": ["/tmp/x"], "non_accessible_globs": ["**/.env"], "max_read_bytes": 99, "tree_default_depth": 2, }); let cfg = CoderConfig::from_json(&json).unwrap(); - assert_eq!(cfg.base_paths, vec![PathBuf::from("/tmp/x")]); assert_eq!(cfg.non_accessible_globs, vec!["**/.env".to_string()]); assert_eq!(cfg.max_read_bytes, 99); assert_eq!(cfg.tree_default_depth, 2); @@ -450,24 +441,24 @@ search_response_budget_bytes: 29 #[test] fn to_json_round_trips_through_from_json() { let yaml = r#" -base_paths: - - /tmp/a max_output_bytes: 7 search_response_budget_bytes: 11 "#; let cfg = CoderConfig::from_yaml(yaml).unwrap(); let back = CoderConfig::from_json(&cfg.to_json()).unwrap(); - assert_eq!(back.base_paths, vec![PathBuf::from("/tmp/a")]); assert_eq!(back.max_output_bytes, 7); assert_eq!(back.search_response_budget_bytes, 11); } #[test] fn from_yaml_expands_env_var() { - std::env::set_var("CODER_TEST_ROOT", "/tmp/expanded-root"); - let yaml = "base_paths:\n - \"${CODER_TEST_ROOT}\"\n"; + std::env::set_var("CODER_TEST_ROOT", "/tmp/expanded-glob"); + let yaml = "non_accessible_globs:\n - \"${CODER_TEST_ROOT}\"\n"; let cfg = CoderConfig::from_yaml(yaml).unwrap(); - assert_eq!(cfg.base_paths, vec![PathBuf::from("/tmp/expanded-root")]); + assert_eq!( + cfg.non_accessible_globs, + vec!["/tmp/expanded-glob".to_string()] + ); std::env::remove_var("CODER_TEST_ROOT"); } @@ -507,16 +498,6 @@ search_response_budget_bytes: 11 assert_eq!(base.jail_signature(), tuned.jail_signature()); } - #[test] - fn jail_signature_differs_when_base_path_changes() { - let a = CoderConfig::default(); - let b = CoderConfig { - base_path: Some(PathBuf::from("/tmp/legacy")), - ..CoderConfig::default() - }; - assert_ne!(a.jail_signature(), b.jail_signature()); - } - #[test] fn jail_signature_differs_when_base_paths_changes() { let a = CoderConfig { diff --git a/shell/src/code/path.rs b/shell/src/code/path.rs index 84a5d1130..1262fef0d 100644 --- a/shell/src/code/path.rs +++ b/shell/src/code/path.rs @@ -1,7 +1,7 @@ //! Path resolution and access control. //! -//! The worker is jailed to a set of allowed roots (`base_paths`; the -//! legacy `base_path` is honored as a one-entry list). Relative wire +//! The worker is jailed to a set of allowed roots (`base_paths`, filled at +//! runtime from `fs.host_roots`). Relative wire //! paths resolve against the FIRST root (the "primary"); absolute wire //! paths are accepted when they canonicalise inside ANY allowed root. //! `PathResolver` canonicalises inputs (symlink-aware) and verifies @@ -54,9 +54,8 @@ pub struct PathResolver { default_exclude_dirs: GlobSet, } -/// Effective roots when neither `base_paths` nor legacy `base_path` is -/// configured: the engine workspace cwd plus `/tmp` (a deliberate, -/// user-approved default). +/// Effective roots when `base_paths` is empty: the engine workspace cwd +/// plus `/tmp` (a deliberate, user-approved default). fn default_roots() -> Vec { vec![PathBuf::from("./"), PathBuf::from("/tmp")] } @@ -83,19 +82,10 @@ fn display_paths(paths: &[PathBuf]) -> String { impl PathResolver { pub fn new(cfg: &CoderConfig) -> Result { - let configured: Vec = match (&cfg.base_path, cfg.base_paths.as_slice()) { - (Some(_), [_, ..]) => { - return Err(CoderError::BadInput( - "both `base_path` and `base_paths` are set; set either \ - `base_path` or `base_paths` in config.yaml, not both. \ - Remove `base_path` and keep only `base_paths` \ - (legacy `base_path` is honored as a one-entry list)." - .into(), - )) - } - (Some(single), []) => vec![single.clone()], - (None, []) => default_roots(), - (None, many) => many.to_vec(), + let configured: Vec = if cfg.base_paths.is_empty() { + default_roots() + } else { + cfg.base_paths.clone() }; let mut roots_canon: Vec = Vec::with_capacity(configured.len()); @@ -115,12 +105,12 @@ impl PathResolver { } } if roots_canon.is_empty() { - // C210 like the both-set case above: an operator config error - // detected at construction time, not a runtime I/O failure. + // C210: an operator config error detected at construction time, + // not a runtime I/O failure. return Err(CoderError::BadInput(format!( "no reachable roots: none of [{}] could be canonicalized. \ Ensure the directories exist and are accessible, then set \ - `base_paths` in config.yaml to at least one reachable path.", + `fs.host_roots` to at least one reachable path.", display_paths(&configured) ))); } @@ -685,19 +675,6 @@ mod tests { assert!(in_b.starts_with(canon(b.path()))); } - #[test] - fn both_base_path_and_base_paths_set_is_construction_error() { - let a = tempdir().unwrap(); - let b = tempdir().unwrap(); - let cfg = CoderConfig { - base_path: Some(a.path().to_path_buf()), - base_paths: vec![b.path().to_path_buf()], - ..CoderConfig::default() - }; - let err = PathResolver::new(&cfg).unwrap_err(); - assert_eq!(err.code(), "C210"); - } - #[test] fn zero_reachable_roots_is_construction_error() { let cfg = cfg_roots( @@ -708,7 +685,19 @@ mod tests { vec![], ); let err = PathResolver::new(&cfg).unwrap_err(); - // C210: operator config error, same class as the both-set case. + // C210: operator config error detected at construction time. + assert_eq!(err.code(), "C210"); + } + + #[test] + fn single_unreachable_root_is_construction_error() { + // One-entry form of the case above (the shape a single-root + // fs.host_roots produces): still an operator config error. + let cfg = cfg_roots( + vec![PathBuf::from("/this/does/not/exist/probably/xyz123")], + vec![], + ); + let err = PathResolver::new(&cfg).unwrap_err(); assert_eq!(err.code(), "C210"); } @@ -776,12 +765,9 @@ mod tests { } #[test] - fn legacy_base_path_honored_as_single_root() { + fn single_base_path_entry_jails_to_that_root() { let tmp = tempdir().unwrap(); - let cfg = CoderConfig { - base_path: Some(tmp.path().to_path_buf()), - ..CoderConfig::default() - }; + let cfg = cfg_roots(vec![tmp.path().to_path_buf()], vec![]); let r = PathResolver::new(&cfg).unwrap(); assert_eq!(r.roots().len(), 1); assert_eq!(r.resolve(".").unwrap(), canon(tmp.path())); @@ -943,17 +929,6 @@ mod tests { assert!(r.require_writable("node_modules/x.txt").is_ok()); } - #[test] - fn legacy_missing_base_path_is_construction_error() { - let cfg = CoderConfig { - base_path: Some(PathBuf::from("/this/does/not/exist/probably/xyz123")), - ..CoderConfig::default() - }; - let err = PathResolver::new(&cfg).unwrap_err(); - // C210: operator config error, same class as the both-set case. - assert_eq!(err.code(), "C210"); - } - // RECOVERY-PAIR TEST: parse the first allowed root out of the C215 error // text, write a file there, then verify success. This proves the error // message alone contains enough information for a caller to make a diff --git a/shell/src/config.rs b/shell/src/config.rs index 9cde4e0ac..7f819b351 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -90,20 +90,13 @@ pub struct ShellConfig { /// The folded `code` surface (`coder::*`) config: glob protection /// (`non_accessible_globs`), noise excludes (`default_exclude_globs`), and /// per-file/response budgets. The code resolver's ROOTS are NOT taken from - /// here — it uses `fs.host_roots` so there is a single jail config; any - /// `base_path`/`base_paths` set under `code` is ignored. + /// here — it uses `fs.host_roots` so there is a single jail config + /// (`code.base_path`/`base_paths` were removed from the schema in 0.7.0; + /// stored values still carrying them are ignored — they never had an + /// effect). #[serde(default)] pub code: crate::code::config::CoderConfig, - /// One-shot migration marker (D4/T5): set true once the legacy `coder` - /// config entry has been folded into this value at boot. Persisted in the - /// stored value so the fold runs exactly once, but hidden from the operator - /// schema (not a knob anyone edits). PERSISTS (no `skip`) — that is the - /// whole point of an idempotency marker. - #[serde(default)] - #[schemars(skip)] - pub migrated_from_coder: bool, - #[serde(default, skip)] #[schemars(skip)] pub compiled_denylist: Vec, @@ -158,6 +151,23 @@ fn check_removed_keys<'a>(keys: impl Iterator) -> Result<(), Str )) } +/// Nested `fs` key removed in 0.7.0: `host_root`, the 0.6.x single-root +/// alias. serde ignores unknown fields, so a config still carrying it would +/// otherwise parse with NO jail configured — and either fail the jail check +/// with a message that never names the stale key, or (with `allow_unjailed`) +/// silently boot unjailed. Same fail-closed treatment as the top-level keys. +fn check_removed_fs_keys<'a>(mut keys: impl Iterator) -> Result<(), String> { + if keys.any(|k| k == "host_root") { + return Err( + "config key removed in 0.7.0: `fs.host_root` -> `fs.host_roots` (one-entry list). \ + Set fs: { host_roots: [] }. If this is the stored value, rewrite it via \ + configuration::set (id: shell)." + .to_string(), + ); + } + Ok(()) +} + /// Environment policy for spawned commands (host target). Replaces the /// 0.6.x top-level `inherit_env` / `allowed_env` keys (renamed in 0.7.0; /// the old keys are rejected at parse with a migration hint). @@ -203,20 +213,16 @@ impl Default for EnvConfig { /// budgets and hard-denied paths. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FsConfig { - /// Legacy single jail root. Honored as a one-entry `host_roots` list. - /// Setting BOTH `host_root` and `host_roots` is a config error - /// (`validate_fs_jail`). Prefer `host_roots`. - #[serde(default)] - pub host_root: Option, /// Allowed jail roots. The FIRST entry is the PRIMARY root: relative wire /// paths and a relative per-call `cwd`/`base_dir` resolve against it. /// Absolute paths are accepted when they canonicalize inside ANY listed - /// root. Empty (and `host_root` unset) means unjailed — refused at boot - /// unless `allow_unjailed` is true. + /// root. Empty means unjailed — refused at boot unless `allow_unjailed` + /// is true. (The 0.6.x single-root `host_root` alias was removed in + /// 0.7.0 and is rejected at parse with a migration hint.) #[serde(default)] pub host_roots: Vec, - /// Operator opt-in for running with `host_root: null`. When false (the - /// default) the worker refuses to start unjailed — the entire host + /// Operator opt-in for running with an empty `host_roots`. When false + /// (the default) the worker refuses to start unjailed — the entire host /// filesystem is reachable through `shell::fs::*` aside from the small /// denylist, which is rarely what the operator actually wants. Setting /// this to true is equivalent to acknowledging that fact (test @@ -271,30 +277,22 @@ fn default_sandbox_enabled() -> bool { } impl FsConfig { - /// Effective jail roots, in priority order (index 0 = primary). Returns - /// `host_roots` when set, else the legacy `host_root` as a one-entry list, - /// else empty (unjailed). Does NOT canonicalize — that happens once at - /// backend construction. `validate_fs_jail` rejects setting both keys. + /// Effective jail roots, in priority order (index 0 = primary). Empty + /// means unjailed. Does NOT canonicalize — that happens once at backend + /// construction. pub fn roots(&self) -> Vec { - if !self.host_roots.is_empty() { - self.host_roots.clone() - } else if let Some(r) = &self.host_root { - vec![r.clone()] - } else { - Vec::new() - } + self.host_roots.clone() } /// True when a jail boundary is configured (at least one root). pub fn is_jailed(&self) -> bool { - !self.host_roots.is_empty() || self.host_root.is_some() + !self.host_roots.is_empty() } } impl Default for FsConfig { fn default() -> Self { Self { - host_root: None, host_roots: Vec::new(), allow_unjailed: false, max_read_bytes: default_max_read_bytes(), @@ -329,7 +327,6 @@ impl Default for ShellConfig { fs: FsConfig::default(), sandbox: SandboxConfig::default(), code: crate::code::config::CoderConfig::default(), - migrated_from_coder: false, compiled_denylist: Vec::new(), } } @@ -340,7 +337,7 @@ impl ShellConfig { /// registration and used as the runtime fallback when the stored value is /// null, so the worker boots with no config file at all (database-style /// zero-config). This is deliberately NOT `Default::default()` — that is - /// unjailed (`host_root: None`) so an operator config that omits the jail + /// unjailed (empty `host_roots`) so an operator config that omits the jail /// fails closed. This seed is the shipped permissive dev default: jailed to /// `/tmp`, env forwarded, open exec with a catastrophic-only denylist. It is /// kept in sync with `config.yaml` by a unit test. @@ -395,12 +392,12 @@ impl ShellConfig { } /// Assemble the `CoderConfig` the code `PathResolver` is built from: the - /// glob/budget settings from the `code` block, but with ROOTS taken from - /// `fs.host_roots` (the unified jail) — never from `code.base_paths`. This - /// is what keeps the merge's promise that the operator sets the root once. + /// glob/budget settings from the `code` block, with ROOTS taken from + /// `fs.host_roots` (the unified jail) — `code.base_paths` is runtime + /// plumbing filled here, never read from config. This is what keeps the + /// merge's promise that the operator sets the root once. pub fn code_resolver_config(&self) -> crate::code::config::CoderConfig { let mut c = self.code.clone(); - c.base_path = None; c.base_paths = self.fs.roots(); c } @@ -417,18 +414,12 @@ impl ShellConfig { } /// Refuse to start with the host backend exposing the entire filesystem - /// behind only the (advisory) denylist — the operator must either pin a - /// host_root jail or explicitly opt in via `fs.allow_unjailed: true`. + /// behind only the (advisory) denylist — the operator must either pin + /// `fs.host_roots` or explicitly opt in via `fs.allow_unjailed: true`. pub fn validate_fs_jail(&self) -> Result<()> { - if self.fs.host_root.is_some() && !self.fs.host_roots.is_empty() { - anyhow::bail!( - "both fs.host_root and fs.host_roots are set — set either fs.host_root (legacy \ - single root) or fs.host_roots (the list form), not both. Keep only fs.host_roots." - ); - } if !self.fs.is_jailed() && !self.fs.allow_unjailed { anyhow::bail!( - "fs.host_root/fs.host_roots are unset and fs.allow_unjailed is false — refusing \ + "fs.host_roots is empty and fs.allow_unjailed is false — refusing \ to start unjailed. Set fs.host_roots to the directories you intend to expose, or \ set fs.allow_unjailed: true to accept that the entire host filesystem is \ reachable through shell::fs::* (subject only to the advisory denylist)." @@ -475,9 +466,9 @@ impl ShellConfig { // Confinement guard: a command given as a PATH (contains a '/') that // canonicalizes to a location INSIDE the writable fs jail is rejected. - // `shell::fs::write` can plant an executable (0755) under `fs.host_root`, + // `shell::fs::write` can plant an executable (0755) under a jail root, // and the basename allowlist check above matches by file_name — so - // `command: "/ls"` would otherwise pass the allowlist and be + // `command: "/ls"` would otherwise pass the allowlist and be // executed verbatim, a host RCE that bypasses the read-only allowlist. // Bare program names (no '/') are PATH-resolved by the OS and stay // allowed; legitimate absolute paths OUTSIDE the jail (e.g. /usr/bin/ls) @@ -485,18 +476,18 @@ impl ShellConfig { // fails to canonicalize (does not exist) is NOT rejected here — the // normal exec spawn surfaces its own not-found error. if cmd.contains('/') { - // Unjailed mode (host_root: null) has NO writable boundary — the + // Unjailed mode (empty host_roots) has NO writable boundary — the // whole host filesystem is reachable via shell::fs::write, so an // agent can plant `/tmp/ls` and run `command: "/tmp/ls"` (basename // `ls` is allowlisted), bypassing the read-only allowlist entirely. // There is no path that distinguishes "agent-planted" from "system // binary" here, so reject ALL command paths and require a bare, // PATH-resolved name. (In jailed mode the check below is precise: - // only paths inside host_root are rejected.) + // only paths inside the jail roots are rejected.) if !self.fs.is_jailed() { return Err(format!( "command path '{}' is not allowed when fs is unjailed \ - (no fs.host_root/fs.host_roots): any host path is writable via \ + (fs.host_roots is empty): any host path is writable via \ shell::fs::write, so a command path could execute \ agent-planted bytes and bypass the allowlist. Use a bare \ command name (PATH-resolved).", @@ -545,6 +536,9 @@ impl ShellConfig { serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}"))?; if let Some(map) = raw.as_mapping() { check_removed_keys(map.keys().filter_map(|k| k.as_str()))?; + if let Some(fs) = map.get("fs").and_then(|v| v.as_mapping()) { + check_removed_fs_keys(fs.keys().filter_map(|k| k.as_str()))?; + } } serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}")) } @@ -559,6 +553,9 @@ impl ShellConfig { pub fn from_json(value: &serde_json::Value) -> Result { if let Some(obj) = value.as_object() { check_removed_keys(obj.keys().map(String::as_str))?; + if let Some(fs) = obj.get("fs").and_then(serde_json::Value::as_object) { + check_removed_fs_keys(fs.keys().map(String::as_str))?; + } } serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) } @@ -639,16 +636,16 @@ mod tests { // Basename matching for an absolute command path is only meaningful in // JAILED mode: an out-of-jail path (not writable via shell::fs::write) // is permitted by basename. Unjailed mode rejects all paths outright - // (see exec_command_path_rejected_when_unjailed), so set a host_root + // (see exec_command_path_rejected_when_unjailed), so set a jail root // that does NOT contain /usr/bin/ls to exercise the basename contract. let mut c = cfg_with(vec!["ls"], vec![]); - c.fs.host_root = - Some(std::env::temp_dir().join(format!("shell-basename-{}", uuid::Uuid::new_v4()))); - std::fs::create_dir_all(c.fs.host_root.as_ref().unwrap()).unwrap(); + c.fs.host_roots = + vec![std::env::temp_dir().join(format!("shell-basename-{}", uuid::Uuid::new_v4()))]; + std::fs::create_dir_all(&c.fs.host_roots[0]).unwrap(); assert!(c .is_command_allowed(&["/usr/bin/ls".into(), "-la".into()]) .is_ok()); - std::fs::remove_dir_all(c.fs.host_root.as_ref().unwrap()).ok(); + std::fs::remove_dir_all(&c.fs.host_roots[0]).ok(); } #[test] @@ -881,9 +878,9 @@ mod tests { #[test] fn exec_command_path_inside_jail_is_rejected() { - // An agent can plant `/ls` (0755) via shell::fs::write; the + // An agent can plant `/ls` (0755) via shell::fs::write; the // basename allowlist matches "ls", so without the confinement guard - // `command: "/ls"` would execute that jail-planted file — + // `command: "/ls"` would execute that jail-planted file — // host RCE. The guard must reject a command path that canonicalizes // inside the jail while still permitting bare PATH-resolved names and // out-of-jail absolute paths. @@ -895,7 +892,7 @@ mod tests { allowlist: vec!["ls".into()], ..Default::default() }; - c.fs.host_root = Some(root.clone()); + c.fs.host_roots = vec![root.clone()]; c.compile_denylist().unwrap(); // The jail-planted path is rejected with a jail-mentioning error. @@ -920,7 +917,7 @@ mod tests { .to_string()], ..Default::default() }; - c2.fs.host_root = Some(root.clone()); + c2.fs.host_roots = vec![root.clone()]; c2.compile_denylist().unwrap(); assert!( c2.is_command_allowed(&[candidate.to_string()]).is_ok(), @@ -934,7 +931,7 @@ mod tests { #[test] fn exec_command_path_rejected_when_unjailed() { - // Unjailed mode (host_root: null) has no writable boundary: the whole + // Unjailed mode (empty host_roots) has no writable boundary: the whole // host FS is reachable via shell::fs::write, so ANY command path could // execute agent-planted bytes and bypass the allowlist. Reject every // path; only bare PATH-resolved names are permitted. @@ -942,7 +939,7 @@ mod tests { allowlist: vec!["ls".into()], ..Default::default() }; - c.fs.host_root = None; + c.fs.host_roots = Vec::new(); c.fs.allow_unjailed = true; c.compile_denylist().unwrap(); @@ -971,7 +968,7 @@ mod tests { assert_eq!(c.fs.max_read_bytes, 0); assert_eq!(c.fs.max_write_bytes, 0); assert!(c.sandbox.enabled); - assert!(c.fs.host_root.is_none()); + assert!(c.fs.host_roots.is_empty()); } #[test] @@ -979,7 +976,7 @@ mod tests { let yaml = r#" allowlist: [] fs: - host_root: /tmp/shell + host_roots: [/tmp/shell] max_read_bytes: 1024 denylist_paths: - /etc @@ -988,8 +985,8 @@ sandbox: "#; let c: ShellConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!( - c.fs.host_root.as_deref(), - Some(std::path::Path::new("/tmp/shell")) + c.fs.host_roots, + vec![std::path::PathBuf::from("/tmp/shell")] ); assert_eq!(c.fs.max_read_bytes, 1024); assert!(!c.sandbox.enabled); @@ -1010,7 +1007,7 @@ sandbox: let c = ShellConfig::default(); let err = c.validate_fs_jail().expect_err("must reject default"); let msg = format!("{err}"); - assert!(msg.contains("host_root")); + assert!(msg.contains("host_roots")); assert!(msg.contains("allow_unjailed")); } @@ -1022,10 +1019,10 @@ sandbox: } #[test] - fn validate_fs_jail_accepts_pinned_host_root() { + fn validate_fs_jail_accepts_single_host_root() { let mut c = ShellConfig::default(); - c.fs.host_root = Some(std::path::PathBuf::from("/tmp/something")); - c.validate_fs_jail().expect("pinned host_root is valid"); + c.fs.host_roots = vec![std::path::PathBuf::from("/tmp/something")]; + c.validate_fs_jail().expect("a one-entry host_roots is valid"); } #[test] @@ -1036,29 +1033,11 @@ sandbox: } #[test] - fn validate_fs_jail_rejects_both_host_root_and_host_roots() { - let mut c = ShellConfig::default(); - c.fs.host_root = Some("/tmp/a".into()); - c.fs.host_roots = vec!["/tmp/b".into()]; - let err = c.validate_fs_jail().expect_err("both set must be rejected"); - let msg = format!("{err}"); - assert!( - msg.contains("both fs.host_root and fs.host_roots"), - "got: {msg}" - ); - } - - #[test] - fn roots_prefers_host_roots_then_legacy_host_root() { + fn roots_returns_host_roots_and_empty_means_unjailed() { let mut c = FsConfig::default(); assert!(c.roots().is_empty(), "unset = unjailed"); assert!(!c.is_jailed()); - c.host_root = Some("/tmp/legacy".into()); - assert_eq!(c.roots(), vec![std::path::PathBuf::from("/tmp/legacy")]); - assert!(c.is_jailed()); c.host_roots = vec!["/tmp/a".into(), "/tmp/b".into()]; - // host_roots wins when both are present (validate_fs_jail rejects that - // combo at boot, but roots() stays deterministic). assert_eq!( c.roots(), vec![ @@ -1066,6 +1045,7 @@ sandbox: std::path::PathBuf::from("/tmp/b") ] ); + assert!(c.is_jailed()); } #[test] @@ -1115,18 +1095,39 @@ sandbox: #[test] fn to_json_from_json_round_trips() { let mut c = ShellConfig::default(); - c.fs.host_root = Some(std::path::PathBuf::from("/tmp/shell")); + c.fs.host_roots = vec![std::path::PathBuf::from("/tmp/shell")]; c.allowlist = vec!["ls".into(), "cat".into()]; let v = c.to_json(); let back = ShellConfig::from_json(&v).expect("from_json round-trips"); assert_eq!(back.allowlist, c.allowlist); - assert_eq!(back.fs.host_root, c.fs.host_root); + assert_eq!(back.fs.host_roots, c.fs.host_roots); } #[test] fn from_yaml_parses_seed() { - let c = ShellConfig::from_yaml("allowlist: [ls]\nfs:\n host_root: /tmp/x\n") + let c = ShellConfig::from_yaml("allowlist: [ls]\nfs:\n host_roots: [/tmp/x]\n") .expect("seed yaml parses"); assert_eq!(c.allowlist, vec!["ls".to_string()]); } + + /// A 0.6.x seed still carrying the removed single-root alias must be + /// rejected with a hint naming the list form — serde would otherwise + /// ignore it and parse a config with NO jail configured. + #[test] + fn from_yaml_rejects_removed_fs_host_root_with_hint() { + let err = ShellConfig::from_yaml("fs:\n host_root: /tmp\n").expect_err("removed key rejects"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("fs.host_roots"), "{err}"); + } + + /// Same through the live-value (JSON) funnel — what an un-migrated stored + /// configuration hits at boot and hot-reload. + #[test] + fn from_json_rejects_removed_fs_host_root_with_hint() { + let v = serde_json::json!({"fs": {"host_root": "/tmp"}}); + let err = ShellConfig::from_json(&v).expect_err("removed key rejects"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("fs.host_roots"), "{err}"); + assert!(err.contains("configuration::set"), "{err}"); + } } diff --git a/shell/src/configuration.rs b/shell/src/configuration.rs index c8fbd99b4..ed14ae75a 100644 --- a/shell/src/configuration.rs +++ b/shell/src/configuration.rs @@ -108,7 +108,7 @@ pub fn build_runtime(cfg: &ShellConfig, iii: &IIIClient) -> Result Result Option { - if shell.migrated_from_coder { - return None; - } - - // Roots — NEVER WIDEN. Only when shell has no jail do we adopt coder's - // EXPLICIT roots (its implicit ["./","/tmp"] default is not migrated). - if !shell.fs.is_jailed() { - let coder_roots: Vec = if !coder.base_paths.is_empty() { - coder.base_paths.clone() - } else if let Some(p) = &coder.base_path { - vec![p.clone()] - } else { - Vec::new() - }; - if !coder_roots.is_empty() { - shell.fs.host_roots = coder_roots; - } - } - - // Code block — fill an UNTOUCHED block wholesale (globs + excludes + - // budgets), minus the roots (which live in fs.host_roots). A tuned block - // keeps its values; only an empty protected-glob list is back-filled. - let default_code = crate::code::config::CoderConfig::default(); - // "Untouched" means the ENTIRE code block is still at its defaults (roots - // excluded — they live in fs and are cleared on both sides). Comparing the - // whole struct, not just the glob lists, prevents the fold from silently - // overwriting a tuned numeric knob (e.g. max_read_bytes) that the operator - // set while leaving the default globs. - let code_untouched = { - let mut probe = shell.code.clone(); - probe.base_path = None; - probe.base_paths = Vec::new(); - probe == default_code - }; - if code_untouched { - let mut adopted = coder.clone(); - adopted.base_path = None; - adopted.base_paths = Vec::new(); - shell.code = adopted; - } else if shell.code.non_accessible_globs.is_empty() { - shell.code.non_accessible_globs = coder.non_accessible_globs.clone(); - } - - shell.migrated_from_coder = true; - Some(shell) -} - -/// Fetch the stored value for an arbitrary configuration id (`Ok(None)` when -/// the entry does not exist). Generalises [`try_get_config_value`] so the -/// migration can read both `coder` and `shell`. -async fn try_get_value_for(iii: &IIIClient, id: &str) -> Result, String> { - match trigger_with_retry(iii, "configuration::get", json!({ "id": id })).await { - Ok(resp) => Ok(resp.get("value").cloned()), - Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), - Err(e) => Err(e), - } -} - -/// One-shot boot migration: fold a legacy `coder` config entry into the `shell` -/// value (see [`fold_coder_into_shell`]). MUST run AFTER schema registration -/// and BEFORE the initial fetch so `build_runtime` sees the merged value. -/// -/// BEST-EFFORT / NON-FATAL: a missing `coder` entry, an unparseable value, or a -/// write failure logs and returns without aborting boot — the worker proceeds -/// with the existing `shell` config. The legacy `coder` entry is left intact -/// (inert) as the rollback artifact; the console annotates it (T8). -pub async fn migrate_legacy_coder(iii: &IIIClient) { - let coder_value = match try_get_value_for(iii, "coder").await { - Ok(Some(v)) if !v.is_null() => v, - Ok(_) => return, // no legacy entry — fresh install or already gone - Err(e) => { - tracing::warn!(error = %e, "could not read legacy coder config; skipping migration"); - return; - } - }; - let coder = match crate::code::config::CoderConfig::from_json(&coder_value) { - Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, "legacy coder config unparseable; skipping migration"); - return; - } - }; - // Current shell value, or the (invalid, unjailed) default when none is - // stored yet — the latter is the "only coder was configured" upgrade path, - // where the fold derives a valid jailed shell config from coder's roots. - let shell = match try_get_value_for(iii, CONFIG_ID).await { - Ok(Some(v)) if !v.is_null() => match ShellConfig::from_json(&v) { - Ok(c) => c, - // A stored-but-unparseable shell value must NOT be silently - // overwritten by a default+coder fold — that would clobber a - // possibly-recoverable config. Skip; the subsequent fetch_config - // surfaces the parse error and boot fails closed cleanly. - Err(e) => { - tracing::warn!(error = %e, "stored shell config is unparseable; skipping coder migration"); - return; - } - }, - _ => ShellConfig::default(), - }; - match fold_coder_into_shell(shell, &coder) { - None => tracing::debug!("coder→shell migration already applied; skipping"), - Some(merged) => { - let payload = json!({ "id": CONFIG_ID, "value": merged.to_json() }); - match trigger_with_retry(iii, "configuration::set", payload).await { - Ok(_) => tracing::info!( - "folded the legacy coder config into the shell config (one-shot)" - ), - Err(e) => tracing::warn!( - error = %e, - "failed to persist coder→shell migration; continuing with the existing shell config" - ), - } - } - } -} - /// Register the `shell` configuration schema with the configuration worker. /// /// `initial_value` (used only on first registration; preserved afterwards) is @@ -294,7 +156,7 @@ pub async fn register_config(iii: &IIIClient, seed: Option<&ShellConfig>) -> Res }; if let Some(cfg) = &candidate { // Validate with the SAME checks build_runtime uses (denylist regex - // compile, fs-jail rule, host_root/denylist reachability) BEFORE + // compile, fs-jail rule, host_roots/denylist reachability) BEFORE // persisting. configuration::register preserves the value after first // registration, so a one-line typo in --config — or an unbootable // built-in seed — would become a persistent outage. If invalid, register @@ -377,7 +239,7 @@ async fn apply_config(state: &AppState, cfg: ShellConfig) -> Result<(), String> let now_jailed = new_runtime.config.fs.is_jailed(); if was_jailed && !now_jailed { tracing::warn!( - "configuration change WIDENED the fs jail (host_root cleared); the entire \ + "configuration change WIDENED the fs jail (host_roots cleared); the entire \ host filesystem is now reachable through shell::fs::* (denylist still applies)" ); } @@ -463,7 +325,7 @@ where } Err(e) => { // Config was fetched but is unbuildable (bad denylist regex, unreachable - // host_root, …). Re-fetching returns the SAME rejected value, so ack + + // jail root, …). Re-fetching returns the SAME rejected value, so ack + // keep last-good to avoid a retry storm; the loud error log is the signal. // Record the rejection so `shell::config-status` makes the divergence // (active policy older than the central store) operator-visible. @@ -568,7 +430,7 @@ mod tests { // that silently retries but never panics or blocks this thread. let iii = iii_sdk::register_worker("ws://127.0.0.1:59599", iii_sdk::InitOptions::default()); let mut cfg = ShellConfig::default(); - cfg.fs.host_root = Some(std::path::PathBuf::from("/nonexistent/shell-jail-xyz")); + cfg.fs.host_roots = vec![std::path::PathBuf::from("/nonexistent/shell-jail-xyz")]; cfg.fs.allow_unjailed = false; let res = build_runtime(&cfg, &iii); assert!(res.is_err(), "build_runtime must return Err, not panic"); @@ -588,150 +450,25 @@ mod tests { } #[test] - fn prepare_config_accepts_pinned_host_root() { + fn prepare_config_accepts_pinned_host_roots() { let mut c = ShellConfig::default(); - c.fs.host_root = Some(std::path::PathBuf::from("/tmp/shell")); - prepare_config(&c).expect("pinned host_root is valid"); + c.fs.host_roots = vec![std::path::PathBuf::from("/tmp/shell")]; + prepare_config(&c).expect("pinned host_roots is valid"); } #[test] - fn legacy_host_root_only_config_boots_jailed_to_that_root() { - // REGRESSION (T6, CRITICAL): a pre-merge shell config — a single - // fs.host_root, NO fs.host_roots, NO `code:` block — must still - // deserialize via serde defaults, boot, and jail shell::fs to that one - // root. This guards every existing shell deployment across the coder - // merge. (Byte-identical fs *behaviour* is additionally proven by the - // unchanged shell::fs test suite continuing to pass.) - let dir = std::env::temp_dir().join("shell-t6-legacy-regression"); - std::fs::create_dir_all(&dir).unwrap(); - // Exactly the pre-merge config shape — nothing the merge added. - let yaml = format!("allowlist: []\nfs:\n host_root: {}\n", dir.display()); - let cfg = ShellConfig::from_yaml(&yaml).expect("legacy yaml parses via serde defaults"); - assert!( - cfg.fs.host_roots.is_empty(), - "a legacy config carries no host_roots" - ); - assert_eq!(cfg.fs.host_root.as_deref(), Some(dir.as_path())); - // The added `code` block defaulted (empty), and the effective jail is - // the single legacy root via the compatibility alias. - assert_eq!(cfg.fs.roots(), vec![dir.clone()]); - assert!(cfg.code.non_accessible_globs.is_empty()); - - let iii = iii_sdk::register_worker("ws://127.0.0.1:59571", iii_sdk::InitOptions::default()); - let runtime = build_runtime(&cfg, &iii).expect("legacy config still builds a runtime"); - assert!(runtime.config.fs.is_jailed()); - assert_eq!(runtime.config.fs.roots(), vec![dir.clone()]); - } - - #[test] - fn fold_never_widens_an_already_jailed_shell() { - // SECURITY: shell already has a jail root; coder declares DIFFERENT - // roots. The fold must KEEP shell's roots (adopting coder's would widen - // the jail) and only fill the empty code protected-globs. - let mut shell = ShellConfig::default(); - shell.fs.host_root = Some("/srv/app".into()); - let coder = crate::code::config::CoderConfig { - base_paths: vec!["/etc".into(), "/var".into()], - non_accessible_globs: vec!["**/.env".into()], - ..Default::default() - }; - let merged = fold_coder_into_shell(shell, &coder).expect("a change (marker + globs)"); - assert_eq!( - merged.fs.host_root.as_deref(), - Some(std::path::Path::new("/srv/app")) - ); - assert!( - merged.fs.host_roots.is_empty(), - "coder roots must NOT be adopted into an already-jailed shell (no widening)" - ); - assert_eq!( - merged.fs.roots(), - vec![std::path::PathBuf::from("/srv/app")] - ); - assert_eq!( - merged.code.non_accessible_globs, - vec!["**/.env".to_string()] - ); - assert!(merged.migrated_from_coder); - } - - #[test] - fn fold_adopts_coder_roots_only_when_shell_unjailed() { - // The "only coder was configured" upgrade path: shell has no jail, coder - // does — derive shell's jail from coder's roots + tuning. - let shell = ShellConfig::default(); - assert!(!shell.fs.is_jailed()); - let coder = crate::code::config::CoderConfig { - base_paths: vec!["/work/project".into()], - non_accessible_globs: vec!["**/*.pem".into()], - ..Default::default() - }; - let merged = fold_coder_into_shell(shell, &coder).expect("a change"); - assert_eq!( - merged.fs.host_roots, - vec![std::path::PathBuf::from("/work/project")] - ); - assert!(merged.fs.is_jailed()); - assert_eq!( - merged.code.non_accessible_globs, - vec!["**/*.pem".to_string()] - ); - assert!(merged.migrated_from_coder); - } - - #[test] - fn fold_is_idempotent_once_marked() { - let shell = ShellConfig { - migrated_from_coder: true, - ..Default::default() - }; - let coder = crate::code::config::CoderConfig::default(); - assert!( - fold_coder_into_shell(shell, &coder).is_none(), - "an already-migrated shell value is a no-op" - ); - } - - #[test] - fn fold_never_drops_existing_shell_protected_globs() { - // shell already tuned its protected globs; the fold must not overwrite. - let mut shell = ShellConfig::default(); - shell.fs.host_root = Some("/srv".into()); - shell.code.non_accessible_globs = vec!["**/secret.key".into()]; - let coder = crate::code::config::CoderConfig { - non_accessible_globs: vec!["**/.env".into()], - ..Default::default() - }; - let merged = fold_coder_into_shell(shell, &coder).expect("marker set"); - assert_eq!( - merged.code.non_accessible_globs, - vec!["**/secret.key".to_string()], - "shell's own protected globs must be preserved" - ); - } - - #[test] - fn fold_preserves_a_tuned_code_knob_with_default_globs() { - // Regression: a shell that tuned a numeric knob (max_read_bytes) but - // left default globs is NOT "untouched" — the fold must keep the knob, - // not overwrite the whole code block with coder's defaults. - let mut shell = ShellConfig::default(); - shell.fs.host_root = Some("/srv".into()); - shell.code.max_read_bytes = 5_000_000; - let coder = crate::code::config::CoderConfig { - non_accessible_globs: vec!["**/.env".into()], - ..Default::default() - }; - let merged = fold_coder_into_shell(shell, &coder).expect("a change"); - assert_eq!( - merged.code.max_read_bytes, 5_000_000, - "a tuned numeric knob must survive the fold" - ); - // The empty protected globs are still back-filled from coder. - assert_eq!( - merged.code.non_accessible_globs, - vec!["**/.env".to_string()] - ); + fn legacy_host_root_config_is_rejected_with_hint() { + // INVERSION of the old T6 regression test: through 0.6.x a single + // fs.host_root was honored as a one-entry jail, and this test proved + // that pre-merge shape still booted. 0.7.0 removes the alias outright, + // so the SAME config shape must now FAIL CLOSED at parse with a hint + // naming fs.host_roots — serde would otherwise ignore the stale key + // and the worker would refuse to start with a message that never + // names the actual mistake. + let yaml = "allowlist: []\nfs:\n host_root: /tmp/legacy-root\n"; + let err = ShellConfig::from_yaml(yaml).expect_err("the 0.6.x alias must be rejected"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("fs.host_roots"), "{err}"); } #[test] @@ -770,7 +507,7 @@ mod tests { let iii = iii_sdk::register_worker("ws://127.0.0.1:59597", iii_sdk::InitOptions::default()); let mut base = ShellConfig::default(); - base.fs.host_root = Some(dir_old.clone()); + base.fs.host_roots = vec![dir_old.clone()]; let initial = build_runtime(&base, &iii).expect("initial runtime"); let state = AppState { runtime: Arc::new(RwLock::new(initial)), @@ -780,9 +517,9 @@ mod tests { }; let mut cfg_old = ShellConfig::default(); - cfg_old.fs.host_root = Some(dir_old.clone()); + cfg_old.fs.host_roots = vec![dir_old.clone()]; let mut cfg_new = ShellConfig::default(); - cfg_new.fs.host_root = Some(dir_new.clone()); + cfg_new.fs.host_roots = vec![dir_new.clone()]; // OLDER reload: acquires the reload lock first, then stalls inside the // (lock-held) fetch — simulating a slow build for an older event. @@ -810,10 +547,10 @@ mod tests { h1.await.unwrap(); h2.await.unwrap(); - let final_root = state.runtime.read().await.config.fs.host_root.clone(); + let final_root = state.runtime.read().await.config.fs.host_roots.clone(); assert_eq!( final_root, - Some(dir_new), + vec![dir_new], "newest config must win; a stale older build must not clobber it" ); } @@ -826,7 +563,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let iii = iii_sdk::register_worker("ws://127.0.0.1:59596", iii_sdk::InitOptions::default()); let mut base = ShellConfig::default(); - base.fs.host_root = Some(dir.clone()); + base.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&base, &iii).expect("initial"))), iii: iii.clone(), @@ -842,8 +579,8 @@ mod tests { "transient fetch failure must surface as Err, not ack success" ); assert_eq!( - state.runtime.read().await.config.fs.host_root, - Some(dir), + state.runtime.read().await.config.fs.host_roots, + vec![dir], "runtime unchanged on fetch failure" ); // A transient fetch failure is NOT a config rejection: status untouched. @@ -863,14 +600,14 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let iii = iii_sdk::register_worker("ws://127.0.0.1:59595", iii_sdk::InitOptions::default()); let mut good = ShellConfig::default(); - good.fs.host_root = Some(dir.clone()); + good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), }; - // ShellConfig::default() is unjailed (host_root None, allow_unjailed false) → rejected by prepare_config. + // ShellConfig::default() is unjailed (empty host_roots, allow_unjailed false) → rejected by prepare_config. let res = reload_serialized(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; assert!( @@ -878,8 +615,8 @@ mod tests { "invalid config must be acked as Rejected (no retry storm), not Err" ); assert_eq!( - state.runtime.read().await.config.fs.host_root, - Some(dir), + state.runtime.read().await.config.fs.host_roots, + vec![dir], "runtime keeps last-good on invalid config" ); } @@ -893,9 +630,9 @@ mod tests { std::fs::create_dir_all(&dir_b).unwrap(); let iii = iii_sdk::register_worker("ws://127.0.0.1:59594", iii_sdk::InitOptions::default()); let mut a = ShellConfig::default(); - a.fs.host_root = Some(dir_a.clone()); + a.fs.host_roots = vec![dir_a.clone()]; let mut b = ShellConfig::default(); - b.fs.host_root = Some(dir_b.clone()); + b.fs.host_roots = vec![dir_b.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&a, &iii).expect("initial"))), iii: iii.clone(), @@ -909,8 +646,8 @@ mod tests { .await; assert!(matches!(res, Ok(ReloadOutcome::Applied))); assert_eq!( - state.runtime.read().await.config.fs.host_root, - Some(dir_b), + state.runtime.read().await.config.fs.host_roots, + vec![dir_b], "reconcile/reload applies the freshly fetched config" ); } @@ -924,7 +661,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let iii = iii_sdk::register_worker("ws://127.0.0.1:59592", iii_sdk::InitOptions::default()); let mut good = ShellConfig::default(); - good.fs.host_root = Some(dir.clone()); + good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), iii: iii.clone(), @@ -951,13 +688,13 @@ mod tests { } // Runtime kept the previous (valid) policy. assert_eq!( - state.runtime.read().await.config.fs.host_root, - Some(dir.clone()) + state.runtime.read().await.config.fs.host_roots, + vec![dir.clone()] ); // A subsequent valid reload flips back to Applied but preserves the count. let mut good2 = ShellConfig::default(); - good2.fs.host_root = Some(dir.clone()); + good2.fs.host_roots = vec![dir.clone()]; let res = reload_serialized(&state, { let g = good2.clone(); move || async move { Ok::<_, String>(g) } @@ -1002,7 +739,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let iii = iii_sdk::register_worker("ws://127.0.0.1:59591", iii_sdk::InitOptions::default()); let mut good = ShellConfig::default(); - good.fs.host_root = Some(dir.clone()); + good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), iii: iii.clone(), @@ -1030,7 +767,7 @@ mod tests { // A valid config reconciles cleanly. let mut good2 = ShellConfig::default(); - good2.fs.host_root = Some(dir.clone()); + good2.fs.host_roots = vec![dir.clone()]; let res = reconcile_with(&state, { let g = good2.clone(); move || async move { Ok::<_, String>(g) } diff --git a/shell/src/exec/host.rs b/shell/src/exec/host.rs index 8598386e4..4768a812c 100644 --- a/shell/src/exec/host.rs +++ b/shell/src/exec/host.rs @@ -368,7 +368,7 @@ mod tests { let root = std::env::temp_dir().join(format!("shell-cwd-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(root.join("workdir")).unwrap(); let mut cfg = test_cfg(); - cfg.fs.host_root = Some(root.clone()); + cfg.fs.host_roots = vec![root.clone()]; let overrides = crate::exec::policy::build_overrides(Some("workdir"), None, None, &cfg) .expect("workdir is inside the jail"); @@ -390,7 +390,7 @@ mod tests { let root = std::env::temp_dir().join(format!("shell-cwd-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(root.join("session")).unwrap(); let mut cfg = test_cfg(); - cfg.fs.host_root = Some(root.clone()); + cfg.fs.host_roots = vec![root.clone()]; let base = root.join("session").to_string_lossy().into_owned(); let overrides = crate::exec::policy::build_overrides(None, None, Some(&base), &cfg) @@ -503,7 +503,7 @@ mod tests { let root = std::env::temp_dir().join(format!("shell-cwd-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).unwrap(); let mut cfg = test_cfg(); - cfg.fs.host_root = Some(root.clone()); + cfg.fs.host_roots = vec![root.clone()]; let err = crate::exec::policy::build_overrides(Some("../../etc"), None, None, &cfg) .expect_err("escape must reject"); assert_eq!(err.code, "S215"); diff --git a/shell/src/exec/policy.rs b/shell/src/exec/policy.rs index 235a3bc31..d810e92d4 100644 --- a/shell/src/exec/policy.rs +++ b/shell/src/exec/policy.rs @@ -8,11 +8,11 @@ //! //! Gating rules (mandatory, not best-effort): //! - `cwd` is confined to the SAME jail the fs backend enforces — it is -//! canonicalized and must `starts_with(host_root)` and miss the denylist, -//! exactly like `shell::fs::*` paths. A `cwd` resolving outside the jail is -//! rejected `S215`. When `fs.host_root` is unset (operator opted into -//! `allow_unjailed`), the same code path runs with no root to confine to, -//! matching the fs backend's unjailed behaviour. +//! canonicalized and must resolve inside a jail root (`fs.host_roots`) and +//! miss the denylist, exactly like `shell::fs::*` paths. A `cwd` resolving +//! outside the jail is rejected `S215`. When `fs.host_roots` is empty +//! (operator opted into `allow_unjailed`), the same code path runs with no +//! root to confine to, matching the fs backend's unjailed behaviour. //! - `env` may set a VALUE only for a key the operator already put in //! `cfg.env.allow`, and NEVER for an exec-hijacking key (see //! [`DANGEROUS_ENV_KEYS`]) — those are rejected even if an operator @@ -121,7 +121,7 @@ impl ExecOverrides { } } -/// Canonicalize `host_root` + every `denylist_paths` entry the same way +/// Canonicalize every jail root + every `denylist_paths` entry the same way /// `HostFsBackend::try_new` does, so the confinement helpers see the identical /// inputs. An unreachable root is an operator config error (surfaced S216); a /// non-existent denylist entry can't be escaped through, so it is kept as-is. @@ -131,7 +131,7 @@ fn jail_inputs(cfg: &ShellConfig) -> Result<(Vec, Vec), ExecEr let canon = std::fs::canonicalize(&root).map_err(|e| { ExecError::new( "S216", - format!("host_root unreachable ({}): {e}", root.display()), + format!("jail root unreachable ({}): {e}", root.display()), ) })?; if !host_roots_canon.contains(&canon) { @@ -211,8 +211,9 @@ fn confine_base_dir( /// same one `shell::fs::*` enforces: canonicalize → `starts_with(root)` → /// denylist. When `base_dir_canon` is set the confinement root is the session /// directory (relative `cwd` anchors there; an absolute `cwd` outside it is -/// S220); otherwise it is `host_root` (unchanged). Per-call (not cached like -/// the fs backend) because the exec handler reads the live config snapshot. +/// S220); otherwise it is the jail roots (unchanged). Per-call (not cached +/// like the fs backend) because the exec handler reads the live config +/// snapshot. fn confine_cwd( cwd: &str, host_roots_canon: &[PathBuf], @@ -302,8 +303,9 @@ fn validate_env( /// BOTH the confinement root for `cwd` AND the effective working directory when /// no `cwd` is given. /// -/// - `base_dir=None`: today's behaviour — `cwd` is confined to `host_root` and -/// an omitted `cwd` leaves the working dir to fall back to `cfg.working_dir`. +/// - `base_dir=None`: today's behaviour — `cwd` is confined to the jail roots +/// and an omitted `cwd` leaves the working dir to fall back to +/// `cfg.working_dir`. /// - `base_dir=Some`, `cwd=None`: the child runs in `base_dir`. /// - `base_dir=Some`, `cwd=Some`: the child runs in `cwd`, which must resolve /// inside `base_dir` (relative anchors there; an absolute cwd outside it is @@ -374,7 +376,7 @@ mod tests { }, ..Default::default() }; - c.fs.host_root = Some(root.to_path_buf()); + c.fs.host_roots = vec![root.to_path_buf()]; c } @@ -488,7 +490,7 @@ mod tests { } /// Test shim: derive the canonical jail inputs from `cfg` and confine `cwd` - /// against `host_root` with NO session base_dir, so the existing + /// against the jail roots with NO session base_dir, so the existing /// no-base_dir cwd tests keep asserting the same contract against the new /// four-arg `confine_cwd`. fn confine_cwd_via_cfg(cwd: &str, cfg: &ShellConfig) -> Result { @@ -550,7 +552,7 @@ mod tests { // --- per-call base_dir (session scope) --- - /// With base_dir set, a relative cwd anchors at base_dir (not host_root), + /// With base_dir set, a relative cwd anchors at base_dir (not the jail root), /// and an OMITTED cwd makes base_dir itself the working directory. #[test] fn base_dir_anchors_relative_cwd_and_becomes_default_cwd() { @@ -567,7 +569,7 @@ mod tests { "omitted cwd defaults to base_dir" ); - // relative cwd anchors at base_dir, not host_root. +// relative cwd anchors at base_dir, not the jail root. let ov = build_overrides(Some("inner"), None, Some(&base), &c).expect("inner is under base_dir"); assert_eq!( @@ -578,10 +580,10 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } - /// DX-1: an ABSOLUTE cwd that is inside host_root but OUTSIDE base_dir is + /// DX-1: an ABSOLUTE cwd that is inside the jail but OUTSIDE base_dir is /// rejected with the new S220 code, and the message names the session dir. #[test] - fn abs_cwd_inside_host_root_but_outside_base_dir_is_s220_naming_session() { + fn abs_cwd_inside_jail_but_outside_base_dir_is_s220_naming_session() { let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(root.join("session")).unwrap(); std::fs::create_dir_all(root.join("other")).unwrap(); @@ -599,17 +601,17 @@ mod tests { ); // It must NOT reuse the generic 'outside every allowed root' S215 wording. assert!( - !err.message.contains("escapes host_root"), + !err.message.contains("escapes the fs jail"), "must not contradict the tool's own roots, got: {}", err.message ); std::fs::remove_dir_all(&root).ok(); } - /// An absolute selected base_dir outside the configured host root is + /// An absolute selected base_dir outside the configured jail roots is /// trusted as the working directory root for exec. #[test] - fn absolute_base_dir_outside_host_root_is_honored() { + fn absolute_base_dir_outside_jail_roots_is_honored() { let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); let selected = std::env::temp_dir().join(format!("shell-policy-selected-{}", uuid::Uuid::new_v4())); @@ -634,7 +636,7 @@ mod tests { } /// `base_dir` is trusted harness metadata, so relative values are rejected - /// instead of interpreted relative to host_root. + /// instead of interpreted relative to the jail root. #[test] fn relative_base_dir_is_rejected() { let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); @@ -646,7 +648,7 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } - /// With no session scope, a relative cwd still anchors at host_root and an + /// With no session scope, a relative cwd still anchors at the primary jail root and an /// omitted cwd stays None (build_command then falls back to cfg.working_dir). #[test] fn base_dir_none_is_unchanged_behaviour() { @@ -661,12 +663,12 @@ mod tests { "no base_dir, no cwd ⇒ None (prior behaviour)" ); - // Relative cwd still anchors at host_root when base_dir is absent. +// Relative cwd still anchors at the primary jail root when base_dir is absent. let ov = build_overrides(Some("sub"), None, None, &c).expect("ok"); assert_eq!( ov.cwd.as_deref(), Some(root.join("sub").canonicalize().unwrap().as_path()), - "relative cwd anchors at host_root when base_dir is absent" + "relative cwd anchors at the primary jail root when base_dir is absent" ); std::fs::remove_dir_all(&root).ok(); } diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index 9ccc35f1d..00470f2e6 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -73,8 +73,7 @@ impl ChannelMaker for IiiChannelMaker { #[derive(Debug, Clone, Default)] pub struct HostFsConfig { /// Effective jail roots (index 0 = primary; empty = unjailed). Built from - /// `FsConfig::roots()` so the legacy single `host_root` and the - /// `host_roots` list both land here as one canonical list. + /// `FsConfig::roots()` (the `fs.host_roots` list) as one canonical list. pub host_roots: Vec, pub max_read_bytes: usize, pub max_write_bytes: usize, @@ -182,7 +181,7 @@ impl HostFsBackend { } } - /// Resolve `host_root` and every `denylist_paths` entry to canonical form + /// Resolve every jail root and `denylist_paths` entry to canonical form /// once at startup. Errors here are operator config bugs (path doesn't /// exist, can't be canonicalized, etc.) and the worker should refuse to /// start instead of degrading to lexical fallback per-call. @@ -192,7 +191,7 @@ impl HostFsBackend { let canon = std::fs::canonicalize(root).map_err(|e| { FsError::new( "S216", - format!("host_root unreachable ({}): {e}", root.display()), + format!("jail root unreachable ({}): {e}", root.display()), ) })?; // Dedup after canonicalization (a root listed twice, or once @@ -310,7 +309,7 @@ impl HostFsBackend { /// `base_dir` is in effect, a relative operand must anchor at `base_dir` /// (the directory `validate_path_scoped` validated against) so the /// validated and operated-on paths cannot diverge. `None` ⇒ delegates to - /// [`Self::lexical_operand`] (the unchanged `host_root`-anchored operand). + /// [`Self::lexical_operand`] (the unchanged primary-root-anchored operand). fn lexical_operand_scoped(&self, path: &str, base_dir_canon: Option<&Path>) -> PathBuf { match base_dir_canon { None => self.lexical_operand(path), @@ -329,7 +328,7 @@ impl HostFsBackend { /// /// `pub(crate)` so the exec backend can confine a per-call `cwd` against the /// SAME jail the fs backend enforces (shell::exec/exec_bg `cwd`) instead of -/// duplicating the canonicalize / starts_with(host_root) / denylist logic. +/// duplicating the canonicalize / jail-root containment / denylist logic. pub(crate) fn confine_path( path: &str, host_roots_canon: &[PathBuf], @@ -501,9 +500,9 @@ fn confine_base_dir( /// base_dir-aware jail confinement, LAYERED on top of [`confine_path`]. When /// `base_dir_canon` is `Some`, the call is scoped to that session directory: -/// relative paths anchor at `base_dir` (not `host_root`) and the resolved path +/// relative paths anchor at `base_dir` (not the primary jail root) and the resolved path /// must land INSIDE `base_dir`. When it is `None`, this is exactly -/// [`confine_path`] against `host_root`. +/// [`confine_path`] against the jail roots. /// /// The core jail algorithm (`confine_path` → /// `canonicalize_with_fallback`/`normalize_lexical`) is reused verbatim, not @@ -511,7 +510,7 @@ fn confine_base_dir( /// relative-anchor and the containment check. The only addition is the DX-1 /// error refinement: an ABSOLUTE path that is inside a configured host root but /// outside `base_dir` is rejected with S220 naming the session directory, -/// instead of the generic "escapes host_root" S215. +/// instead of the generic "escapes the fs jail roots" S215. /// /// `pub(crate)` so the exec backend can confine a per-call `cwd` against the /// SAME session-scoped jail (shell::exec/exec_bg `base_dir`) instead of @@ -542,9 +541,9 @@ pub(crate) fn confine_path_with_base_dir( // path under the session directory rather than guessing roots. if e.code == "S215" && Path::new(path).is_absolute() { if let Ok(canon) = canonicalize_with_fallback(Path::new(path)) { - let inside_host_root = host_roots_canon.iter().any(|hr| canon.starts_with(hr)); + let inside_jail_root = host_roots_canon.iter().any(|hr| canon.starts_with(hr)); let denied = denylist_canon.iter().any(|d| canon.starts_with(d)); - if inside_host_root && !canon.starts_with(base) && !denied { + if inside_jail_root && !canon.starts_with(base) && !denied { return Err(FsError::new( "S220", format!( @@ -857,7 +856,7 @@ impl FsBackend for HostFsBackend { // Jail validation runs here, on the async fn, BEFORE the blocking work. // A per-call base_dir (when set) scopes both the relative anchor and the // containment ceiling to the session directory; None ⇒ the unchanged - // host_root jail. + // configured jail. let base = self.confine_base_dir(req.base_dir.as_deref())?; let p = self.validate_path_scoped(&req.path, base.as_deref())?; // The symlink_metadata stat, read_dir, and the per-entry @@ -1415,7 +1414,7 @@ impl FsBackend for HostFsBackend { // a `sed --path=large-dir --recursive` stalled the executor for the // entire traversal — exactly what spawn_blocking was meant to prevent. // We move it all into the closure. The per-file jail confinement - // (confine_path: starts_with(host_root) + denylist) still runs for + // (confine_path: jail-root containment + denylist) still runs for // EVERY file — it just runs on the blocking thread now, with owned // copies of the precomputed canonical root + denylist (compiled // regexes are Send+Sync and move in too). Streaming read/write paths @@ -1433,7 +1432,7 @@ impl FsBackend for HostFsBackend { let non_accessible = self.non_accessible.clone(); // Resolve the optional session base_dir up front (on the async fn) so the // blocking closure confines + anchors every operand to it instead of the - // global host_root. None ⇒ unchanged host_root behaviour. + // global jail roots. None ⇒ unchanged jail behaviour. let base_dir_canon = self.confine_base_dir(req.base_dir.as_deref())?; let access_roots = access_roots(&host_roots_canon, base_dir_canon.as_deref()); // Per-file read cap: sed builds a same-size output String in memory, so @@ -1913,7 +1912,7 @@ mod tests { } #[test] - fn try_new_returns_err_on_unreachable_host_root() { + fn try_new_returns_err_on_unreachable_jail_root() { let cfg = Arc::new(HostFsConfig { host_roots: vec![PathBuf::from("/nonexistent/shell-jail-xyz")], ..HostFsConfig::default() @@ -2031,7 +2030,7 @@ mod tests { } #[test] - fn relative_path_resolves_under_host_root() { + fn relative_path_resolves_under_jail_root() { // Regression: agents commonly probe with `.` or bare names; under a // jail there is exactly one sensible base, so resolve instead of // erroring with S210. @@ -2049,7 +2048,7 @@ mod tests { } #[test] - fn relative_dotdot_cannot_escape_host_root() { + fn relative_dotdot_cannot_escape_jail_root() { let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root.clone()], @@ -2061,7 +2060,7 @@ mod tests { } #[test] - fn empty_path_rejected_even_under_host_root() { + fn empty_path_rejected_even_under_jail_root() { let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root], @@ -2073,10 +2072,10 @@ mod tests { } #[test] - fn escape_error_names_the_host_root() { - // Regression: "path escapes host_root: " gave the caller no way - // to recover — the agent burned turns guessing. The message must name - // the jail root. + fn escape_error_names_the_jail_root() { + // Regression: the pre-fix S215 wording named no root at all ("path + // escapes the jail: "), giving the caller no way to recover — + // the agent burned turns guessing. The message must name the jail root. let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root.clone()], @@ -2088,13 +2087,13 @@ mod tests { let root_canon = root.canonicalize().unwrap(); assert!( err.message.contains(&root_canon.display().to_string()), - "S215 message must name host_root, got: {}", + "S215 message must name the jail root, got: {}", err.message ); } #[test] - fn empty_path_rejected_without_host_root() { + fn empty_path_rejected_when_unjailed() { let h = stub_backend(HostFsConfig::default()); let err = h.validate_path("").unwrap_err(); assert_eq!(err.code, "S210"); @@ -2102,7 +2101,7 @@ mod tests { #[tokio::test] async fn rm_relative_path_operates_on_jail_file_not_cwd() { - // Regression: rm validated host_root/ but removed / + // Regression: rm validated / but removed / // (the operand was rebuilt from the raw request string). The operand // must be the SAME jail-anchored path validate_path saw. let root = tmp(); @@ -2326,7 +2325,7 @@ mod tests { let root_canon = root.canonicalize().unwrap(); assert!( err.message.contains(&root_canon.display().to_string()), - "S215 message must name host_root, got: {}", + "S215 message must name the jail root, got: {}", err.message ); } @@ -2338,7 +2337,7 @@ mod tests { } #[test] - fn rejects_path_outside_host_root() { + fn rejects_path_outside_jail_root() { let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root.clone()], @@ -2350,7 +2349,7 @@ mod tests { } #[test] - fn allows_descendant_of_host_root() { + fn allows_descendant_of_jail_root() { let root = tmp(); fs::create_dir(root.join("sub")).unwrap(); let cfg = HostFsConfig { @@ -2915,7 +2914,7 @@ mod tests { } #[tokio::test] - async fn write_rejects_path_outside_host_root_with_s215() { + async fn write_rejects_path_outside_jail_root_with_s215() { let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root.clone()], @@ -3101,7 +3100,7 @@ mod tests { } #[tokio::test] - async fn read_rejects_path_outside_host_root_with_s215() { + async fn read_rejects_path_outside_jail_root_with_s215() { let root = tmp(); let cfg = HostFsConfig { host_roots: vec![root.clone()], @@ -3739,9 +3738,9 @@ mod tests { } // --- jail escape via a LIVE (non-dangling) symlink whose target is - // outside the jail. The canonicalize + starts_with(host_root) gate in + // outside the jail. The canonicalize + jail-root containment gate in // validate_path is the core security control; this exact vector was - // untested. We point host_root/escape at a real existing dir outside the + // untested. We point /escape at a real existing dir outside the // jail and assert read/stat/ls all reject with S215. #[tokio::test] @@ -3795,8 +3794,8 @@ mod tests { }) } - /// A write with a relative path anchors at base_dir, not at host_root: the - /// file lands under /session/, proving base_dir re-anchors the + /// A write with a relative path anchors at base_dir, not at the jail root: the + /// file lands under /session/, proving base_dir re-anchors the /// relative path. #[tokio::test] async fn write_relative_path_anchors_at_base_dir() { @@ -3819,13 +3818,13 @@ mod tests { fs::read_to_string(root.join("session/out.txt")).unwrap(), "scoped\n" ); - // It must NOT have landed at the host_root level. + // It must NOT have landed at the jail-root level. assert!(!root.join("out.txt").exists()); } /// rm with a relative path is confined to base_dir: the victim under - /// /session is removed, while an identically-named file at the - /// host_root level is untouched. + /// /session is removed, while an identically-named file at the + /// jail-root level is untouched. #[tokio::test] async fn rm_relative_path_is_confined_to_base_dir() { let root = tmp(); @@ -3849,7 +3848,7 @@ mod tests { ); assert!( root.join("victim.txt").exists(), - "host_root sibling untouched — rm was confined to base_dir" + "jail-root sibling untouched — rm was confined to base_dir" ); } @@ -3878,18 +3877,18 @@ mod tests { ); } - /// DX-1: an ABSOLUTE path that is inside host_root but OUTSIDE base_dir is + /// DX-1: an ABSOLUTE path that is inside the jail root but OUTSIDE base_dir is /// rejected with the new S220 code, and the message NAMES the session dir - /// (not the generic "escapes host_root" S215, which would contradict the + /// (not the generic "escapes the fs jail" S215, which would contradict the /// tool's own configured roots). #[tokio::test] - async fn abs_path_inside_host_root_outside_base_dir_is_s220_naming_session() { + async fn abs_path_inside_jail_root_outside_base_dir_is_s220_naming_session() { let root = tmp(); fs::create_dir_all(root.join("session")).unwrap(); fs::create_dir_all(root.join("other")).unwrap(); fs::write(root.join("other/secret.txt"), "x").unwrap(); let b = jailed_backend(&root); - // Absolute path that resolves inside host_root/other — a sibling of the + // Absolute path that resolves inside /other — a sibling of the // session dir, still inside an allowed root, but not this session. let abs = root.join("other/secret.txt").canonicalize().unwrap(); let base = root.join("session").to_string_lossy().into_owned(); @@ -3908,7 +3907,7 @@ mod tests { err.message ); assert!( - !err.message.contains("escapes host_root"), + !err.message.contains("escapes the fs jail"), "must not reuse the generic jail-escape wording, got: {}", err.message ); @@ -3918,7 +3917,7 @@ mod tests { /// sits outside the configured host roots. The operation is then confined /// under that selected directory. #[tokio::test] - async fn base_dir_outside_host_root_is_honored_as_selected_root() { + async fn base_dir_outside_jail_root_is_honored_as_selected_root() { let root = tmp(); let selected = tmp(); fs::create_dir_all(selected.join("project")).unwrap(); @@ -3936,7 +3935,7 @@ mod tests { } #[tokio::test] - async fn base_dir_outside_host_root_still_applies_non_accessible_globs() { + async fn base_dir_outside_jail_root_still_applies_non_accessible_globs() { let root = tmp(); let selected = tmp(); fs::write(selected.join(".env"), "secret").unwrap(); @@ -3969,11 +3968,11 @@ mod tests { assert_eq!(err.code, "S210"); } - /// A genuinely jail-escaping absolute path (outside host_root entirely) + /// A genuinely jail-escaping absolute path (outside the jail root entirely) /// under a base_dir still rejects S215 — the DX-1 refinement only applies /// to paths that ARE inside an allowed root. #[tokio::test] - async fn abs_path_outside_host_root_under_base_dir_still_s215() { + async fn abs_path_outside_jail_root_under_base_dir_still_s215() { let root = tmp(); fs::create_dir_all(root.join("session")).unwrap(); let b = jailed_backend(&root); @@ -3988,10 +3987,10 @@ mod tests { assert_eq!(err.code, "S215", "outside every allowed root stays S215"); } - /// With no session scope, a relative write still anchors at host_root (not + /// With no session scope, a relative write still anchors at the jail root (not /// at any session dir). #[tokio::test] - async fn base_dir_none_reproduces_host_root_anchoring() { + async fn base_dir_none_reproduces_jail_root_anchoring() { let root = tmp(); let b = jailed_backend(&root); let resp = b @@ -4003,12 +4002,12 @@ mod tests { base_dir: None, }) .await - .expect("relative write with base_dir=None anchors at host_root"); + .expect("relative write with base_dir=None anchors at the jail root"); assert_eq!(resp.bytes_written, 7); assert_eq!( fs::read_to_string(root.join("top.txt")).unwrap(), "legacy\n", - "base_dir=None ⇒ anchors at host_root exactly as before" + "base_dir=None ⇒ anchors at the jail root exactly as before" ); } } diff --git a/shell/src/fs/mod.rs b/shell/src/fs/mod.rs index 21793ab07..024e8875f 100644 --- a/shell/src/fs/mod.rs +++ b/shell/src/fs/mod.rs @@ -224,7 +224,7 @@ pub struct LsRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Internal harness-scoped working directory; omitted from published schema. #[serde(default)] @@ -248,7 +248,7 @@ pub struct StatRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Internal harness-scoped working directory; omitted from published schema. #[serde(default)] @@ -272,7 +272,7 @@ pub struct MkdirRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Octal permission string, e.g. "0755". #[serde(default = "default_mkdir_mode")] @@ -304,7 +304,7 @@ pub struct RmRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Required to delete a non-empty directory. #[serde(default)] @@ -332,7 +332,7 @@ pub struct ChmodRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Octal permission string, e.g. "0755". pub mode: String, @@ -371,9 +371,9 @@ pub struct MvRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Source path; jail-relative when fs.host_root is set, else absolute. + /// Source path; jail-relative when fs.host_roots is set, else absolute. pub src: String, - /// Destination path; jail-relative when fs.host_root is set, else absolute. + /// Destination path; jail-relative when fs.host_roots is set, else absolute. pub dst: String, /// Replace an existing destination instead of returning an error. #[serde(default)] @@ -402,7 +402,7 @@ pub struct GrepRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Rust regex (RE2-like) matched against each line. pub pattern: String, @@ -532,7 +532,7 @@ impl From for WriteContent { /// One file in a batch `shell::fs::write` (`files: [...]`). #[derive(Debug, Deserialize, JsonSchema)] pub struct WriteFileSpec { - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Inline string (recommended) or a streaming ContentRef. pub content: WriteContentWire, @@ -549,7 +549,7 @@ pub struct WriteRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Single-file form: the path to write. Jail-relative when fs.host_root is + /// Single-file form: the path to write. Jail-relative when fs.host_roots is /// set, else absolute. Omit when using `files`. #[serde(default)] pub path: Option, @@ -650,7 +650,7 @@ pub struct ReadRequest { /// host (default) or { kind: "sandbox", sandbox_id }. #[serde(default)] pub target: Target, - /// Jail-relative when fs.host_root is set, else absolute. + /// Jail-relative when fs.host_roots is set, else absolute. pub path: String, /// Internal harness-scoped working directory; omitted from published schema. #[serde(default)] diff --git a/shell/src/functions/types.rs b/shell/src/functions/types.rs index 1f975d711..6aca08af0 100644 --- a/shell/src/functions/types.rs +++ b/shell/src/functions/types.rs @@ -107,8 +107,8 @@ pub struct ExecRequest { pub timeout_ms: Option, /// Optional working directory for this call (host target only). Confined to /// the fs jail exactly like `shell::fs::*` paths: jail-relative when - /// `fs.host_root` is set (else absolute), canonicalized, and must resolve - /// inside `host_root` and miss the denylist — a path that escapes returns + /// `fs.host_roots` is set (else absolute), canonicalized, and must resolve + /// inside a jail root and miss the denylist — a path that escapes returns /// S215. Must already exist and be a directory. Omit to use the configured /// `working_dir` (unchanged default). Rejected (S210) on a sandbox target. #[serde(default)] @@ -159,7 +159,7 @@ pub struct ExecBgRequest { pub timeout_ms: Option, /// Optional working directory for this job (host target only). Same jail /// confinement and rules as [`ExecRequest::cwd`]: canonicalized, must - /// resolve inside `host_root` (S215 on escape) and be an existing + /// resolve inside a jail root (S215 on escape) and be an existing /// directory. Rejected (S210) on a sandbox target. #[serde(default)] pub cwd: Option, diff --git a/shell/src/functions/workspace.rs b/shell/src/functions/workspace.rs index 9bdf3d193..af9ea698d 100644 --- a/shell/src/functions/workspace.rs +++ b/shell/src/functions/workspace.rs @@ -172,7 +172,7 @@ mod tests { } #[test] - fn roots_include_configured_host_root_and_operator_anchors() { + fn roots_include_configured_jail_root_and_operator_anchors() { let jail = tempdir().unwrap(); let cfg = cfg_with_root(jail.path().to_path_buf()); diff --git a/shell/src/main.rs b/shell/src/main.rs index 36d88ecb6..2e34fe24a 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -155,11 +155,6 @@ async fn main() -> Result<()> { .map_err(anyhow::Error::msg) .context("registering shell configuration schema")?; - // One-shot, best-effort fold of a legacy `coder` config entry into the - // `shell` value (never-widen; idempotent). Runs after schema registration - // and before the fetch below so the merged value is what we boot from. - configuration::migrate_legacy_coder(&iii).await; - let cfg = configuration::fetch_config(&iii) .await .map_err(anyhow::Error::msg) @@ -225,7 +220,7 @@ async fn main() -> Result<()> { tracing::info!("code surface (coder::*) registered over the unified fs jail"); } else { tracing::warn!( - "fs is unjailed (no fs.host_root/fs.host_roots) — code surface (coder::*) NOT \ + "fs is unjailed (fs.host_roots is empty) — code surface (coder::*) NOT \ registered; coder file functions require a jail root" ); } @@ -538,12 +533,13 @@ fn register_fs(iii: &iii_sdk::IIIClient, state: &AppState) { } fs_fn!("shell::fs::ls", fs_ls, fs::LsRequest, fs::LsResponse, - "List directory contents. `path` is relative to the configured fs jail root (fs.host_root) \ - when set, otherwise absolute. `target` defaults to host; pass { kind: \"sandbox\", sandbox_id } \ + "List directory contents. `path` is relative to the primary fs jail root (the first \ + fs.host_roots entry) when set, otherwise absolute. `target` defaults to host; pass \ + { kind: \"sandbox\", sandbox_id } \ to run in a microVM. Errors return { code, message }; common: S210 bad path, S211 not found, \ S212 not a directory, S215 jail/denylist."); fs_fn!("shell::fs::stat", fs_stat, fs::StatRequest, fs::StatResponse, - "Stat a single path (jail-relative when fs.host_root is set). Returns the entry's type, size, \ + "Stat a single path (jail-relative when fs.host_roots is set). Returns the entry's type, size, \ mode, and mtime. Errors return { code, message }; common: S211 not found, S215 jail/denylist."); fs_fn!("shell::fs::mkdir", fs_mkdir, fs::MkdirRequest, fs::MkdirResponse, "Create a directory. `mode` is an octal string like \"0755\". `parents: true` creates missing \ diff --git a/shell/src/path/mod.rs b/shell/src/path/mod.rs index 2e462aca3..0966c3a00 100644 --- a/shell/src/path/mod.rs +++ b/shell/src/path/mod.rs @@ -20,13 +20,13 @@ use std::path::{Component, Path, PathBuf}; /// ancestor, even when `p` itself doesn't yet exist. The naive fallback — /// "canonicalize, on ENOENT use the lexical path" — is a jail-escape vector /// when the path traverses a symlink whose target is outside the jail: the -/// lexical form still `starts_with(host_root)`, but the kernel will follow +/// lexical form still `starts_with()`, but the kernel will follow /// the link on the subsequent syscall. Walking up to the longest existing /// ancestor and canonicalizing *that* forces every symlink in the existing /// portion to be resolved; the non-existent tail can't itself contain /// symlinks (it doesn't exist) but can still contain `..`/`.`, which we /// then collapse lexically against the canonical prefix so the -/// `starts_with(host_root)` check is sound. +/// jail-root containment check is sound. pub(crate) fn canonicalize_with_fallback(p: &Path) -> std::io::Result { if let Ok(c) = std::fs::canonicalize(p) { return Ok(c); @@ -37,7 +37,7 @@ pub(crate) fn canonicalize_with_fallback(p: &Path) -> std::io::Result { // forward through each tail component and reject if any of them is a // *dangling* symlink: canonicalize fails on dangling symlinks (target // doesn't exist), so they'd otherwise survive into the lexical tail - // and let `starts_with(host_root)` succeed against a path the kernel + // and let the jail-root containment check succeed against a path the kernel // would resolve outside the jail. Existing-but-resolvable symlinks // are caught by the top-of-function canonicalize. for anc in p.ancestors().skip(1) { diff --git a/shell/tests/code_golden_errors.rs b/shell/tests/code_golden_errors.rs index 94640cfef..569561cf0 100644 --- a/shell/tests/code_golden_errors.rs +++ b/shell/tests/code_golden_errors.rs @@ -177,16 +177,18 @@ async fn error_message_formats_match_golden() { ); }; - // --- C210: operator config error — both root forms set ------------- + // --- C210: operator config error — no reachable roots --------------- + // (Replaces the retired both-root-forms case: `base_path` was removed in + // 0.7.0, so the remaining construction-time config error is a root set + // where nothing canonicalizes.) { let cfg = CoderConfig { - base_path: Some(jail.root0.clone()), - base_paths: vec![jail.root1.clone()], + base_paths: vec![PathBuf::from("/this/does/not/exist/golden-xyz")], ..CoderConfig::default() }; - let err = PathResolver::new(&cfg).expect_err("both-set must fail"); + let err = PathResolver::new(&cfg).expect_err("unreachable roots must fail"); put( - "C210_config_both_root_forms_set", + "C210_config_no_reachable_roots", from_coder_error(&err), &jail, ); diff --git a/shell/tests/e2e/README.md b/shell/tests/e2e/README.md index 69665e6f5..3e39c45be 100644 --- a/shell/tests/e2e/README.md +++ b/shell/tests/e2e/README.md @@ -116,16 +116,16 @@ so they're easy to grep. | Finding | Where | Suite | |---|---|---| | S-H1 (`chmod -R` follows symlinks) | `cases-vuln-repro.ts` | default | -| S-H2 (`host_root: null` is unjailed) | `cases-vuln-repro.ts` | default | +| S-H2 (no `host_roots` is unjailed) | `cases-vuln-repro.ts` | default | | S-H3 (denylist regex bypass via shell vars) | `cases-vuln-repro.ts` | default | | S-H4 (`shell::list` cross-call argv/stdout leak) | `cases-vuln-repro.ts` | default | | S-C1 (symlink-parent jail escape on writes) | `cases-vuln-repro-jailed.ts` | jailed | The default suite (`./run-tests.sh`) runs the unjailed four alongside the rest. The jailed suite (`./run-tests-jailed.sh`) boots the engine -with `config-jailed.yaml` (`host_root: /private/tmp/iii-shell-jailed-root`) +with `config-jailed.yaml` (`host_roots: [/private/tmp/iii-shell-jailed-root]`) and runs only the C1 repro — the rest of the suite assumes -`host_root: null` and would mis-fail with a jail set. +no jail (`host_roots` unset) and would mis-fail with a jail set. ## Coder BDD coverage diff --git a/shell/tests/e2e/config-jailed.yaml b/shell/tests/e2e/config-jailed.yaml index 309f8b623..bb6f1b819 100644 --- a/shell/tests/e2e/config-jailed.yaml +++ b/shell/tests/e2e/config-jailed.yaml @@ -1,7 +1,7 @@ # Variant of config.yaml used by run-tests-jailed.sh to reproduce # S-C1 (symlink-parent jail escape) — see # `cases-vuln-repro-jailed.ts`. Only difference from the default -# config is `fs.host_root` being set to a fixed absolute path; the +# config is `fs.host_roots` being set to a fixed absolute path; the # jailed suite creates the directory and a symlink inside it that # points at /private/tmp/. The vuln is that # `validate_path` falls back to lexical normalization on ENOENT @@ -40,7 +40,7 @@ workers: max_concurrent_jobs: 2 job_retention_secs: 2 fs: - host_root: /private/tmp/iii-shell-jailed-root + host_roots: [/private/tmp/iii-shell-jailed-root] max_read_bytes: 1048576 max_write_bytes: 1048576 denylist_paths: diff --git a/shell/tests/e2e/config.yaml b/shell/tests/e2e/config.yaml index 5e65e6436..7ac6cb06c 100644 --- a/shell/tests/e2e/config.yaml +++ b/shell/tests/e2e/config.yaml @@ -56,10 +56,10 @@ workers: max_concurrent_jobs: 2 job_retention_secs: 2 fs: - host_root: null # Test harness needs to write to OS tmpdirs scattered across the - # filesystem, so the jail is intentionally off here. Production - # operators should leave this false (default) and pin host_root. + # filesystem, so the jail is intentionally off here (no host_roots). + # Production operators should leave this false (default) and pin + # host_roots. allow_unjailed: true # Caps deliberately set to 1 MiB so size-cap-boundary tests # in cases-fs-protocol-break.ts can trip S218 cheaply. Streaming diff --git a/shell/tests/e2e/run-tests-jailed.sh b/shell/tests/e2e/run-tests-jailed.sh index 4be202f3c..5e8e26a36 100755 --- a/shell/tests/e2e/run-tests-jailed.sh +++ b/shell/tests/e2e/run-tests-jailed.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Variant of run-tests.sh that boots the engine against -# `config-jailed.yaml` (with `fs.host_root` set) and runs the +# `config-jailed.yaml` (with `fs.host_roots` set) and runs the # jailed-only vuln-repro suite (S-C1). Reuses the same engine startup # logic; the differences are: (1) a different config, (2) HARNESS_SUITE=jailed # so runner.ts picks the matching case set, (3) a separate report path @@ -94,7 +94,7 @@ fi export HARNESS_TEST_VAR="harness-allowed-value" -echo "[run-tests-jailed] starting iii engine (jailed config: host_root=$JAIL_ROOT)" +echo "[run-tests-jailed] starting iii engine (jailed config: host_roots=[$JAIL_ROOT])" : > "$ENGINE_LOG" : > "$HARNESS_LOG" diff --git a/shell/tests/e2e/workers/harness/src/cases-safety.ts b/shell/tests/e2e/workers/harness/src/cases-safety.ts index d1e056ce5..bec3a6e13 100644 --- a/shell/tests/e2e/workers/harness/src/cases-safety.ts +++ b/shell/tests/e2e/workers/harness/src/cases-safety.ts @@ -12,11 +12,11 @@ export const SAFETY_CASES: TestCase[] = [ }, }, { - // This suite runs UNJAILED (config.yaml host_root: null). In that mode a + // This suite runs UNJAILED (config.yaml sets no host_roots). In that mode a // command path (anything with '/') is rejected outright: the whole host FS // is writable via shell::fs::write, so a path could execute agent-planted // bytes and bypass the read-only allowlist. Bare PATH-resolved names work. - // (In jailed mode an absolute path OUTSIDE host_root is still permitted by + // (In jailed mode an absolute path OUTSIDE the jail roots is still permitted by // basename — exercised by the jailed suite / Rust unit tests.) name: 'unjailed mode rejects command paths (RCE guard)', async run({ call, expectError }) { diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts index 07a4c0d97..51ee0bfe9 100644 --- a/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro-jailed.ts @@ -1,6 +1,6 @@ // Regression test for S-C1 (symlink-parent jail escape). Originally // this case demonstrated the vuln by writing through a symlink whose -// target was outside host_root and observing the bytes land outside; +// target was outside the jail root and observing the bytes land outside; // after the fix in `shell/src/fs/host.rs` (canonicalize_with_fallback // + lexical normalization of the non-existent tail), the same // invocation must reject with S215. Runs only against @@ -39,8 +39,8 @@ export const VULN_REPRO_JAILED_CASES: TestCase[] = [ // FIX: validate_path now canonicalizes the longest existing // ancestor (which resolves the symlink to externalDir, outside - // host_root) and lexically appends the non-existent tail. The - // resulting path no longer starts_with(host_root) — engine + // the jail root) and lexically appends the non-existent tail. The + // resulting path no longer resolves inside the jail root — engine // rejects with S215. let rejected = false; let observed = ''; @@ -81,7 +81,7 @@ export const VULN_REPRO_JAILED_CASES: TestCase[] = [ // The original lexical-fallback flaw existed both in validate_path // and in the parents:true defense-in-depth re-check. The fix to // validate_path closes the upstream side; the defense-in-depth now - // uses host_root_canon (precomputed at HostFsBackend::new()) to + // uses host_roots_canon (precomputed at HostFsBackend::new()) to // re-check the parent. This case exercises the parents:true path // explicitly: the external escape directory does NOT pre-exist, so // create_dir_all has to walk through the symlink, and the diff --git a/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts index 68eafa213..30a696ea2 100644 --- a/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts +++ b/shell/tests/e2e/workers/harness/src/cases-vuln-repro.ts @@ -4,7 +4,7 @@ // post-fix behavior (error code, redaction, opt-in flag). Names start // with `vuln_repro_` for grep continuity. // -// Coverage in this file (default config = host_root: null, +// Coverage in this file (default config = no host_roots, // allow_unjailed: true): // - S-H1: chmod -R no longer rewrites symlink targets // - S-H2: unjailed mode requires explicit allow_unjailed: true (the @@ -16,7 +16,7 @@ // reachable only via shell::status (the UUID is the cap). // // S-C1 (symlink-parent escape) is tested in `cases-vuln-repro-jailed.ts` -// against `config-jailed.yaml` (host_root set). +// against `config-jailed.yaml` (host_roots set). import { mkdtempSync, mkdirSync, writeFileSync, statSync, symlinkSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -116,7 +116,7 @@ export const VULN_REPRO_CASES: TestCase[] = [ }, { // S-H2: shell/src/config.rs (validate_fs_jail). - // Fix: the worker refuses to start when host_root is null AND + // Fix: the worker refuses to start when host_roots is empty AND // fs.allow_unjailed is false. The test config sets allow_unjailed: // true (test harness writes to OS tmpdirs scattered across the // FS), so the writes-anywhere behavior is preserved as the diff --git a/shell/tests/e2e/workers/harness/src/runner.ts b/shell/tests/e2e/workers/harness/src/runner.ts index b9c188fd7..deb5f1ce9 100644 --- a/shell/tests/e2e/workers/harness/src/runner.ts +++ b/shell/tests/e2e/workers/harness/src/runner.ts @@ -127,7 +127,7 @@ export class Runner { // set) is the full unjailed suite + the unjailed vuln repros. // `jailed` runs ONLY the symlink-jail-escape repro against an // engine started with config-jailed.yaml — the rest of the suite - // assumes host_root: null and would mis-fail there. + // assumes no host_roots and would mis-fail there. const suite = process.env.HARNESS_SUITE ?? 'default'; const allCases: TestCase[] = suite === 'jailed' diff --git a/shell/tests/golden/errors.json b/shell/tests/golden/errors.json index 4c7f168dd..90930649f 100644 --- a/shell/tests/golden/errors.json +++ b/shell/tests/golden/errors.json @@ -1,7 +1,7 @@ { - "C210_config_both_root_forms_set": { + "C210_config_no_reachable_roots": { "code": "C210", - "message": "both `base_path` and `base_paths` are set; set either `base_path` or `base_paths` in config.yaml, not both. Remove `base_path` and keep only `base_paths` (legacy `base_path` is honored as a one-entry list)." + "message": "no reachable roots: none of [/this/does/not/exist/golden-xyz] could be canonicalized. Ensure the directories exist and are accessible, then set `fs.host_roots` to at least one reachable path." }, "C210_create_bad_mode": { "code": "C210", From 915a59ffae1b584ce638de84132b81ca874ee555 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 09:37:18 -0300 Subject: [PATCH 07/12] test: pin code_resolver_config root-fill from the fs jail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base_paths is serde-skipped since 0.7.0, so code_resolver_config is the SOLE source of coder::* roots — a regression dropping the fill would silently jail the code surface to the default ['./', '/tmp'], wider than the operator's fs jail. --- shell/src/config.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/shell/src/config.rs b/shell/src/config.rs index 7f819b351..18ea581a0 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -1092,6 +1092,32 @@ sandbox: assert!(!props.contains_key("compiled_denylist")); } + /// The resolver-roots wiring hop: `code_resolver_config` must copy + /// `fs.roots()` into `code.base_paths` while preserving the rest of the + /// `code` block. Since 0.7.0 `code.base_paths` is serde-skipped, this + /// copy is the ONLY source of coder roots — a regression dropping it + /// would hand `PathResolver` an empty root set, which falls back to the + /// default `["./", "/tmp"]` jail: coder::* silently jailed WIDER than + /// the operator's fs jail. (Same wiring class as the D4 glob test in + /// tests/code_unified_protection.rs.) + #[test] + fn code_resolver_config_fills_roots_from_fs_jail() { + let mut c = ShellConfig::default(); + c.fs.host_roots = vec!["/tmp/a".into(), "/tmp/b".into()]; + c.code.non_accessible_globs = vec!["**/.env".into()]; + let resolved = c.code_resolver_config(); + assert_eq!( + resolved.base_paths, + c.fs.roots(), + "coder roots must come from the unified fs jail" + ); + assert_eq!( + resolved.non_accessible_globs, + vec!["**/.env".to_string()], + "the rest of the code block is preserved" + ); + } + #[test] fn to_json_from_json_round_trips() { let mut c = ShellConfig::default(); From 5e632eff9f66460eaacbe3140e61ba7d89e791e4 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 11:53:32 -0300 Subject: [PATCH 08/12] =?UTF-8?q?fix(shell):=20pre-landing=20review=20?= =?UTF-8?q?=E2=80=94=20fail-closed=20seed/reload,=20wrapper=20denylist=20h?= =?UTF-8?q?ardening,=20half-migration=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies findings from a specialist + red-team + Claude/Codex adversarial review pass over the env-consolidation branch: - Seed-file parse failures now abort boot instead of silently seeding the permissive built-in default in place of the operator's intended policy — every un-migrated 0.6.x --config file now hits this path. A genuinely missing file still falls through gracefully. - Hot-reload no longer misclassifies a fetched-but-unparseable stored value as a transient fetch error (infinite retry against bytes that can never parse); it is now Rejected — keep last-good, ack, record for shell::config-status — the same treatment as an unbuildable config. reload_serialized's fetch closure now returns the raw fetched Value so parsing happens inside the classification, not before it. - EnvConfig denies unknown fields, and the removed-key checker was unified into one function covering the top-level, fs, and env objects in a single traversal — a half-migration (old key names nested under the new env: block) now gets the same friendly hint as every other removed key, instead of a generic serde error. Consolidating the two separate checkers surfaced a real bug: the original used .any(), which short-circuits, so a config carrying both removed env keys only ever named the first in its error; now collects every hit. - The anchored, wrapper-tolerant denylist patterns are now case-insensitive and handle env's idiomatic KEY=VALUE form (env FOO=bar shutdown bypassed the tripwire; only bare env shutdown was covered before). - The boot-time reachability probe runs on a detached thread so its unbounded DNS resolution and bounded TCP connects can never delay boot, and logs host:port instead of the raw URL (a wss://user:pass@host URL could otherwise leak credentials to the log). - cargo fmt violations, an EnvConfig test-fixture helper to deduplicate four near-identical constructions, and a cross-test mutex to fix a real intermittent flake (two tests mutating process env could race on separate cargo-test threads). 1278 unit tests, e2e 171/171 + jailed 2/2, cargo fmt/clippy clean. --- shell/config.yaml | 24 ++- shell/src/code/config.rs | 6 + shell/src/config.rs | 356 +++++++++++++++++++++++++++------ shell/src/configuration.rs | 124 ++++++++++-- shell/src/exec/host.rs | 57 +++++- shell/src/exec/policy.rs | 4 +- shell/src/functions/exec_bg.rs | 10 +- shell/src/functions/kill.rs | 5 +- shell/src/main.rs | 82 +++++--- 9 files changed, 521 insertions(+), 147 deletions(-) diff --git a/shell/config.yaml b/shell/config.yaml index 56b838036..2b13f488d 100644 --- a/shell/config.yaml +++ b/shell/config.yaml @@ -41,18 +41,24 @@ allowlist: [] # are pure dev friction with no security value, so they were dropped; only # catastrophic, host-wrecking patterns are kept to catch honest mistakes. # -# Command-SHAPED patterns (mkfs/dd/shutdown/reboot) are anchored to argv[0] -# (`^(\S*/)?name`) so they fire only when the tool IS the command — a coding -# agent running `grep -rn shutdown src/` or `rg "dd if=" docs/` is not -# rejected. Argument-shaped patterns (rm -rf /, the fork bomb, /etc/shadow) -# stay unanchored: their dangerous form lives in the arguments. +# Command-SHAPED patterns (mkfs/dd/shutdown/reboot) are anchored to argv[0], +# case-insensitive, tolerating an optional sudo/doas/nohup/timeout wrapper (or +# `env`, with its idiomatic `KEY=VALUE...` assignments) — mkfs/dd-to-device/ +# shutdown/reboot normally require root, so `sudo shutdown -h now` is +# arguably the MOST likely accidental invocation. They fire only when the +# tool IS the command (wrapped or not) — a coding agent running +# `grep -rn shutdown src/` or `rg "dd if=" docs/` is not rejected. +# Argument-shaped patterns (rm -rf /, the fork bomb, /etc/shadow) stay +# unanchored: their dangerous form lives in the arguments. Still advisory +# only: a wrapper not named here, flags BEFORE the wrapped command +# (`sudo -u root shutdown`), or spawning via `sh -c`, still evade it. denylist_patterns: - "rm\\s+-rf\\s+/" - ":\\(\\)\\s*\\{\\s*:\\|" # fork bomb - - "^(\\S*/)?mkfs" - - "^(\\S*/)?dd\\s+if=" - - "^(\\S*/)?shutdown\\b" - - "^(\\S*/)?reboot\\b" + - "(?i)^(?:(?:\\S*/)?env(?:\\s+\\S+=\\S*)*\\s+|(?:\\S*/)?(?:sudo|doas|nohup)\\s+|(?:\\S*/)?timeout(?:\\s+\\S+)?\\s+)*(\\S*/)?mkfs" + - "(?i)^(?:(?:\\S*/)?env(?:\\s+\\S+=\\S*)*\\s+|(?:\\S*/)?(?:sudo|doas|nohup)\\s+|(?:\\S*/)?timeout(?:\\s+\\S+)?\\s+)*(\\S*/)?dd\\s+if=" + - "(?i)^(?:(?:\\S*/)?env(?:\\s+\\S+=\\S*)*\\s+|(?:\\S*/)?(?:sudo|doas|nohup)\\s+|(?:\\S*/)?timeout(?:\\s+\\S+)?\\s+)*(\\S*/)?shutdown\\b" + - "(?i)^(?:(?:\\S*/)?env(?:\\s+\\S+=\\S*)*\\s+|(?:\\S*/)?(?:sudo|doas|nohup)\\s+|(?:\\S*/)?timeout(?:\\s+\\S+)?\\s+)*(\\S*/)?reboot\\b" - "/etc/shadow" max_concurrent_jobs: 16 job_retention_secs: 3600 diff --git a/shell/src/code/config.rs b/shell/src/code/config.rs index 160b3e2fd..d57ea8ffd 100644 --- a/shell/src/code/config.rs +++ b/shell/src/code/config.rs @@ -452,6 +452,12 @@ search_response_budget_bytes: 11 #[test] fn from_yaml_expands_env_var() { + // Serialized against every other process-env-mutating test in the + // crate (see ENV_TEST_MUTEX) — set_var/var race across threads + // otherwise. + let _env_lock = crate::config::ENV_TEST_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); std::env::set_var("CODER_TEST_ROOT", "/tmp/expanded-glob"); let yaml = "non_accessible_globs:\n - \"${CODER_TEST_ROOT}\"\n"; let cfg = CoderConfig::from_yaml(yaml).unwrap(); diff --git a/shell/src/config.rs b/shell/src/config.rs index 18ea581a0..a55ba3375 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -4,6 +4,19 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Serializes every test in the crate that mutates PROCESS-WIDE environment +/// state (`std::env::set_var`/`remove_var`). `cargo test` runs tests within +/// one binary on multiple threads by default; `set_var`/`var` on different +/// threads race at the libc level regardless of Rust-side `Mutex` discipline +/// around any ONE test's own state, so every such test — here and in +/// `code/config.rs`'s `from_yaml_expands_env_var` — must hold this lock for +/// its full set→use→unset span. `.unwrap_or_else(...)` recovers from +/// poisoning: an earlier test panicking mid-mutation still leaves the +/// environment consistent (its own RAII guard/explicit cleanup already ran), +/// so a poisoned lock is not a reason to fail later tests too. +#[cfg(test)] +pub(crate) static ENV_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Root configuration for the shell worker: exec policy (timeouts, output /// caps, allow/denylist, env forwarding), the fs jail, the sandbox toggle, /// and the folded `coder::*` code surface. Stored in the `configuration` @@ -124,54 +137,121 @@ fn default_job_retention_secs() -> u64 { 3600 } -/// Top-level keys removed in 0.7.0 and where they moved. serde ignores -/// unknown fields, so a 0.6.x config carrying `inherit_env: true` would -/// otherwise parse into `env.inherit = false` — silently disabling env -/// forwarding. Fail closed with a migration hint instead. +/// Top-level keys removed in 0.7.0 and where they moved. const REMOVED_TOP_LEVEL_KEYS: &[(&str, &str)] = &[("inherit_env", "env.inherit"), ("allowed_env", "env.allow")]; -fn check_removed_keys<'a>(keys: impl Iterator) -> Result<(), String> { - let hits: Vec = keys - .filter_map(|k| { - REMOVED_TOP_LEVEL_KEYS - .iter() - .find(|(old, _)| *old == k) - .map(|(old, new)| format!("`{old}` -> `{new}`")) +/// Keys removed from the nested `fs` block in 0.7.0 and where they moved. +const REMOVED_FS_KEYS: &[(&str, &str)] = &[("host_root", "fs.host_roots")]; + +/// Keys removed from the nested `env` block in 0.7.0: an operator +/// half-migrating by nesting the OLD field names under the new block (e.g. +/// `env: { inherit_env: true }`) would otherwise hit `EnvConfig`'s +/// `deny_unknown_fields` with a generic serde "unknown field" error and no +/// migration guidance. Give it the same friendly hint as every other +/// removed-key case. +const REMOVED_ENV_KEYS: &[(&str, &str)] = + &[("inherit_env", "env.inherit"), ("allowed_env", "env.allow")]; + +const CONFIGURATION_SET_HINT: &str = + "If this is the stored value, rewrite it via configuration::set (id: shell)."; + +/// Fail closed on any 0.7.0-removed key, wherever serde would otherwise +/// silently ignore it and boot with a narrower (env forwarding) or absent +/// (fs jail) policy than the operator intended. One traversal over the +/// top-level document and the nested `fs`/`env` objects, shared by +/// `from_yaml` (via a text->Value bridge, see its doc comment) and +/// `from_json` — a future removed key is wired into ONE place, not +/// duplicated per funnel. Reports every hit in a single error so an operator +/// who left MULTIPLE removed keys unmigrated fixes everything in one pass. +fn check_removed_keys(value: &serde_json::Value) -> Result<(), String> { + let Some(obj) = value.as_object() else { + return Ok(()); + }; + + // NOTE: `.filter()` + `.count()`, NOT `.any()` — `.any()` short-circuits + // on the first match, so a config carrying BOTH removed env keys would + // only ever name the first in its error. + let mut hits = Vec::new(); + let env_hit = REMOVED_TOP_LEVEL_KEYS + .iter() + .filter(|(old, new)| { + let present = obj.contains_key(*old); + if present { + hits.push(format!("`{old}` -> `{new}`")); + } + present }) - .collect(); + .count() + > 0; + + let fs_hit = obj + .get("fs") + .and_then(serde_json::Value::as_object) + .is_some_and(|fs| { + REMOVED_FS_KEYS + .iter() + .filter(|(old, new)| { + let present = fs.contains_key(*old); + if present { + hits.push(format!("`fs.{old}` -> `{new}` (one-entry list)")); + } + present + }) + .count() + > 0 + }); + + // Half-migration: the OLD key names nested under the NEW `env:` block. + let env_nested_hit = obj + .get("env") + .and_then(serde_json::Value::as_object) + .is_some_and(|env| { + REMOVED_ENV_KEYS + .iter() + .filter(|(old, new)| { + let present = env.contains_key(*old); + if present { + hits.push(format!("`env.{old}` -> `{new}`")); + } + present + }) + .count() + > 0 + }); + if hits.is_empty() { return Ok(()); } - Err(format!( - "config keys removed in 0.7.0: {}. Nest them under `env:` (e.g. env: {{ inherit: true, \ - allow: [PATH, HOME] }}). If this is the stored value, rewrite it via \ - configuration::set (id: shell).", - hits.join(", ") - )) -} -/// Nested `fs` key removed in 0.7.0: `host_root`, the 0.6.x single-root -/// alias. serde ignores unknown fields, so a config still carrying it would -/// otherwise parse with NO jail configured — and either fail the jail check -/// with a message that never names the stale key, or (with `allow_unjailed`) -/// silently boot unjailed. Same fail-closed treatment as the top-level keys. -fn check_removed_fs_keys<'a>(mut keys: impl Iterator) -> Result<(), String> { - if keys.any(|k| k == "host_root") { - return Err( - "config key removed in 0.7.0: `fs.host_root` -> `fs.host_roots` (one-entry list). \ - Set fs: { host_roots: [] }. If this is the stored value, rewrite it via \ - configuration::set (id: shell)." - .to_string(), - ); + let mut remedies = Vec::new(); + if env_hit || env_nested_hit { + remedies + .push("Nest env keys under `env:` (e.g. env: { inherit: true, allow: [PATH, HOME] })."); + } + if fs_hit { + remedies.push("Set the fs jail under `fs: { host_roots: [] }`."); } - Ok(()) + + Err(format!( + "config keys removed in 0.7.0: {}. {} {CONFIGURATION_SET_HINT}", + hits.join(", "), + remedies.join(" "), + )) } /// Environment policy for spawned commands (host target). Replaces the /// 0.6.x top-level `inherit_env` / `allowed_env` keys (renamed in 0.7.0; /// the old keys are rejected at parse with a migration hint). +/// +/// `deny_unknown_fields`: unlike the request types (which deliberately +/// tolerate unknown fields the engine may inject), this struct has no such +/// fields and no forward-compat need to ignore extras. `check_removed_keys` +/// catches the anticipated half-migration (old names nested under `env:`) +/// with a friendly hint BEFORE deserialization; this attribute is the +/// backstop for anything that check doesn't know about (e.g. a typo). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EnvConfig { /// Forward the worker's ENTIRE environment to child processes. Toolchains /// (cargo, rustup, git, node) need this to find PATH/HOME/CARGO_HOME. @@ -208,6 +288,19 @@ impl Default for EnvConfig { } } +#[cfg(test)] +impl EnvConfig { + /// Test fixture: forward the worker's full env, like the shipped dev + /// seed. Several unrelated test modules need an open exec env and would + /// otherwise repeat `EnvConfig { inherit: true, ..Default::default() }`. + pub fn inherit_all() -> Self { + Self { + inherit: true, + ..Default::default() + } + } +} + /// The filesystem jail: which host roots are reachable through /// `shell::fs::*`, `coder::*`, and per-call exec `cwd`, plus read/write /// budgets and hard-denied paths. @@ -332,6 +425,33 @@ impl Default for ShellConfig { } } +/// Optional wrapper-invocation chain (`sudo`, `doas`, `nohup`, `env` +/// — optionally with `KEY=VALUE` assignments, its idiomatic form — or +/// `timeout [duration]`, each optionally path-qualified) a command-shaped +/// denylist pattern tolerates before the real command name. mkfs/dd-to-device/ +/// shutdown/reboot normally require root, so `sudo shutdown -h now` is +/// arguably the MOST likely accidental invocation — anchoring bare argv[0] +/// without this silently drops the exact case the tripwire exists to catch. +/// +/// Still advisory-only, deliberately so (see the crate-level threat model: +/// the fs jail and sandbox backend are the actual security boundary, not +/// this list). Known gaps this tolerance does NOT close: a wrapper not named +/// here; flags interposed before the wrapped command (`sudo -u root +/// shutdown`); or the command spawned indirectly via `sh -c`. Closing those +/// would need per-argv-token wrapper-skipping instead of a joined-string +/// regex — a bigger design change than a tripwire warrants. +const DENYLIST_WRAPPER_PREFIX: &str = r"(?:(?:\S*/)?env(?:\s+\S+=\S*)*\s+|(?:\S*/)?(?:sudo|doas|nohup)\s+|(?:\S*/)?timeout(?:\s+\S+)?\s+)*"; + +/// Build a command-shaped denylist pattern: anchored to the start of the +/// joined argv line, tolerant of [`DENYLIST_WRAPPER_PREFIX`], tolerant of a +/// path-qualified command name, then `cmd_pattern` verbatim. Case-insensitive +/// (`(?i)`): the wrapper names and dangerous commands are conventionally +/// lowercase, but the tripwire should still fire on `SUDO shutdown` — this +/// pattern exists to catch mistakes, not to reward exact casing. +fn command_shaped_denylist_pattern(cmd_pattern: &str) -> String { + format!("(?i)^{DENYLIST_WRAPPER_PREFIX}(\\S*/)?{cmd_pattern}") +} + impl ShellConfig { /// The bootable, zero-config default: seeded as `initial_value` on first /// registration and used as the runtime fallback when the stored value is @@ -353,18 +473,20 @@ impl ShellConfig { ..EnvConfig::default() }, // Command-SHAPED patterns (mkfs/shutdown/reboot/dd) are anchored to - // argv[0] — `^(\S*/)?name` fires when the tool IS the command, not - // when the word appears in an argument, so `grep -rn shutdown src/` - // or `rg "dd if=" docs/` are not rejected. Argument-shaped patterns - // (rm -rf /, the fork bomb, /etc/shadow) stay full-line: their - // dangerous form lives in the arguments. + // argv[0] (tolerating a sudo/doas/nohup/env/timeout wrapper — see + // DENYLIST_WRAPPER_PREFIX) — they fire when the tool IS the + // command, not when the word appears in an argument, so + // `grep -rn shutdown src/` or `rg "dd if=" docs/` are not + // rejected. Argument-shaped patterns (rm -rf /, the fork bomb, + // /etc/shadow) stay full-line: their dangerous form lives in the + // arguments. denylist_patterns: vec![ r"rm\s+-rf\s+/".into(), r":\(\)\s*\{\s*:\|".into(), - r"^(\S*/)?mkfs".into(), - r"^(\S*/)?dd\s+if=".into(), - r"^(\S*/)?shutdown\b".into(), - r"^(\S*/)?reboot\b".into(), + command_shaped_denylist_pattern("mkfs"), + command_shaped_denylist_pattern(r"dd\s+if="), + command_shaped_denylist_pattern(r"shutdown\b"), + command_shaped_denylist_pattern(r"reboot\b"), "/etc/shadow".into(), ], fs: FsConfig { @@ -526,20 +648,21 @@ impl ShellConfig { /// Parse a YAML seed (no denylist compile, no jail validation — those run /// in `configuration::build_runtime`). /// - /// The removed-key check parses to a `Value` first, but the config itself - /// deserializes from the TEXT again: `serde_yaml::from_value` self-tags - /// plain scalars (an unquoted `false` in `allowlist` becomes `Bool` and can - /// no longer deserialize into `String`), while `from_str` drives parsing by - /// the target type. Double-parsing a config-sized string is free. + /// The removed-key check runs over a `serde_json::Value` bridged from the + /// parsed YAML (`serde_yaml::Value` implements `Serialize`, so + /// `serde_json::to_value` round-trips it) so `from_yaml`/`from_json` share + /// ONE traversal (`check_removed_keys`) instead of duplicating the + /// top-level/`fs`/`env` walk per funnel. The config itself still + /// deserializes from the ORIGINAL TEXT, not the bridged Value: + /// `serde_yaml::from_value` self-tags plain scalars (an unquoted `false` + /// in `allowlist` becomes `Bool` and can no longer deserialize into + /// `String`), while `from_str` drives parsing by the target type. + /// Double-parsing a config-sized string is free. pub fn from_yaml(yaml: &str) -> Result { let raw: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}"))?; - if let Some(map) = raw.as_mapping() { - check_removed_keys(map.keys().filter_map(|k| k.as_str()))?; - if let Some(fs) = map.get("fs").and_then(|v| v.as_mapping()) { - check_removed_fs_keys(fs.keys().filter_map(|k| k.as_str()))?; - } - } + let bridged = serde_json::to_value(&raw).map_err(|e| format!("yaml->json bridge: {e}"))?; + check_removed_keys(&bridged)?; serde_yaml::from_str(yaml).map_err(|e| format!("yaml parse: {e}")) } @@ -551,12 +674,7 @@ impl ShellConfig { /// Deserialize the live value fetched from the configuration worker. pub fn from_json(value: &serde_json::Value) -> Result { - if let Some(obj) = value.as_object() { - check_removed_keys(obj.keys().map(String::as_str))?; - if let Some(fs) = obj.get("fs").and_then(serde_json::Value::as_object) { - check_removed_fs_keys(fs.keys().map(String::as_str))?; - } - } + check_removed_keys(value)?; serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) } @@ -723,20 +841,91 @@ mod tests { vec!["/sbin/shutdown".to_string(), "-r".into()], vec!["reboot".to_string()], vec!["mkfs.ext4".to_string(), "/dev/sda1".into()], - vec!["dd".to_string(), "if=/dev/zero".into(), "of=/dev/sda".into()], + vec![ + "dd".to_string(), + "if=/dev/zero".into(), + "of=/dev/sda".into(), + ], ] { assert!( c.is_command_allowed(&argv).is_err(), "{argv:?} must trip the anchored denylist" ); } + // ...and a bounded set of wrapper prefixes are tolerated too: mkfs/ + // dd-to-device/shutdown/reboot normally require root, so `sudo + // shutdown -h now` is arguably the MOST likely accidental invocation + // — anchoring bare argv[0] alone would silently drop exactly this + // case (a real regression caught in review). + for argv in [ + vec![ + "sudo".to_string(), + "shutdown".into(), + "-h".into(), + "now".into(), + ], + vec!["/usr/bin/sudo".to_string(), "reboot".into()], + vec!["doas".to_string(), "reboot".into()], + vec!["nohup".to_string(), "shutdown".into(), "-r".into()], + vec![ + "sudo".to_string(), + "dd".to_string(), + "if=/dev/zero".into(), + "of=/dev/sda".into(), + ], + vec![ + "timeout".to_string(), + "10".into(), + "shutdown".into(), + "now".into(), + ], + // env's idiomatic form: KEY=VALUE assignments before the command + // — a bare `env cmd` check alone would miss the common case. + vec![ + "env".to_string(), + "FOO=bar".into(), + "shutdown".into(), + "-h".into(), + "now".into(), + ], + vec![ + "env".to_string(), + "A=1".into(), + "B=2".into(), + "reboot".into(), + ], + // Case-insensitive: the tripwire exists to catch mistakes, not + // to reward exact casing. + vec![ + "SUDO".to_string(), + "shutdown".into(), + "-h".into(), + "now".into(), + ], + vec!["Sudo".to_string(), "Shutdown".into()], + ] { + assert!( + c.is_command_allowed(&argv).is_err(), + "{argv:?} (wrapped) must trip the anchored denylist" + ); + } // ...but NOT when the word merely appears in an argument — a coding // agent grepping a codebase for "shutdown" is not a mistake. for argv in [ - vec!["grep".to_string(), "-rn".into(), "shutdown".into(), "src/".into()], + vec![ + "grep".to_string(), + "-rn".into(), + "shutdown".into(), + "src/".into(), + ], vec!["cargo".to_string(), "test".into(), "reboot".into()], vec!["rg".to_string(), "dd if=".into(), "docs/".into()], - vec!["git".to_string(), "log".into(), "--grep".into(), "mkfs".into()], + vec![ + "git".to_string(), + "log".into(), + "--grep".into(), + "mkfs".into(), + ], ] { assert!( c.is_command_allowed(&argv).is_ok(), @@ -838,7 +1027,9 @@ mod tests { #[test] fn json_schema_every_field_has_description() { let schema = ShellConfig::json_schema(); - let props = schema["properties"].as_object().expect("top-level properties"); + let props = schema["properties"] + .as_object() + .expect("top-level properties"); assert!(!props.is_empty()); for (name, prop) in props { assert!( @@ -1022,7 +1213,8 @@ sandbox: fn validate_fs_jail_accepts_single_host_root() { let mut c = ShellConfig::default(); c.fs.host_roots = vec![std::path::PathBuf::from("/tmp/something")]; - c.validate_fs_jail().expect("a one-entry host_roots is valid"); + c.validate_fs_jail() + .expect("a one-entry host_roots is valid"); } #[test] @@ -1141,7 +1333,8 @@ sandbox: /// ignore it and parse a config with NO jail configured. #[test] fn from_yaml_rejects_removed_fs_host_root_with_hint() { - let err = ShellConfig::from_yaml("fs:\n host_root: /tmp\n").expect_err("removed key rejects"); + let err = + ShellConfig::from_yaml("fs:\n host_root: /tmp\n").expect_err("removed key rejects"); assert!(err.contains("removed in 0.7.0"), "{err}"); assert!(err.contains("fs.host_roots"), "{err}"); } @@ -1156,4 +1349,37 @@ sandbox: assert!(err.contains("fs.host_roots"), "{err}"); assert!(err.contains("configuration::set"), "{err}"); } + + /// Both a top-level removed env key AND the nested `fs.host_root` present + /// at once must name BOTH in one error — the unified `check_removed_keys` + /// walks the top-level, `fs`, and `env` objects together, not as two + /// independent checks that could each report only their own hit. + #[test] + fn removed_keys_error_names_both_env_and_fs_hits() { + let v = serde_json::json!({ + "inherit_env": true, + "fs": {"host_root": "/tmp"}, + }); + let err = ShellConfig::from_json(&v).expect_err("removed keys reject"); + assert!(err.contains("`inherit_env` -> `env.inherit`"), "{err}"); + assert!(err.contains("fs.host_roots"), "{err}"); + } + + /// Half-migration: nesting the OLD field names under the NEW `env:` + /// block (e.g. an operator who read "nest it under env:" too literally) + /// must get the SAME friendly migration hint as every other removed-key + /// case, not `EnvConfig`'s generic `deny_unknown_fields` serde error — + /// found during pre-landing review as a DX regression the unified + /// checker introduced. + #[test] + fn from_json_rejects_half_migrated_env_block_with_hint() { + let v = serde_json::json!({ + "env": {"inherit_env": true}, + "fs": {"allow_unjailed": true}, + }); + let err = ShellConfig::from_json(&v).expect_err("half-migrated env block rejects"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("`env.inherit_env` -> `env.inherit`"), "{err}"); + assert!(err.contains("configuration::set"), "{err}"); + } } diff --git a/shell/src/configuration.rs b/shell/src/configuration.rs index ed14ae75a..f14514714 100644 --- a/shell/src/configuration.rs +++ b/shell/src/configuration.rs @@ -186,6 +186,16 @@ async fn should_seed_default_value(iii: &IIIClient) -> Result { /// Read the live `shell` configuration (env-expanded by the configuration worker). pub async fn fetch_config(iii: &IIIClient) -> Result { let value = get_config_value(iii).await?; + parse_fetched_value(value) +} + +/// Parse a successfully fetched raw configuration value. Split from +/// [`fetch_config`] so `reload_serialized` can classify a PARSE failure of a +/// fetched value as `Rejected` (permanent — re-fetching returns the same +/// value) rather than a transient fetch error (retryable). Conflating the two +/// turns every un-migrated stored value into a retry storm and hides the +/// divergence from `shell::config-status`. +fn parse_fetched_value(value: Value) -> Result { if value.is_null() { // Null means register_config did not seed (its seed_default failed // validation) or the stored value was nulled at runtime. Fall back to @@ -296,14 +306,21 @@ pub fn register_config_trigger(iii: &IIIClient, state: AppState) -> Result<(), E /// awaited INSIDE the lock, so overlapping `configuration:updated` events are /// applied one at a time and each observes the latest authoritative value — a /// slow build from an older event can never overwrite a newer applied config. +/// +/// Three-way classification: a TRANSPORT failure (fetch Err) is transient — +/// surface Err so the dispatcher retries. A PARSE failure of the fetched value +/// (removed 0.7.0 keys, malformed JSON shape) is permanent — re-fetching +/// returns the same bytes — so it is `Rejected`: keep last-good, record for +/// `shell::config-status`, ack (no retry storm). An unbuildable parsed config +/// is likewise `Rejected` via `apply_config`. async fn reload_serialized(state: &AppState, fetch: F) -> Result where F: FnOnce() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let _reload = state.reload_lock.lock().await; - let cfg = match fetch().await { - Ok(cfg) => cfg, + let raw = match fetch().await { + Ok(raw) => raw, Err(e) => { // Transient fetch failure (e.g. configuration::get timed out): the // authoritative value is unknown. Keep the previous runtime AND @@ -315,6 +332,20 @@ where return Err(e); } }; + let cfg = match parse_fetched_value(raw) { + Ok(cfg) => cfg, + Err(e) => { + // The value WAS fetched; it just doesn't parse (e.g. an + // un-migrated 0.6.x shape carrying removed keys). Same class as + // unbuildable below: permanent until the store changes. + tracing::error!( + error = %e, + "rejected configuration change; keeping previous runtime (not retrying — value does not parse)" + ); + state.reload_status.write().await.record_rejected(e); + return Ok(ReloadOutcome::Rejected); + } + }; match apply_config(state, cfg).await { Ok(()) => { tracing::info!("shell runtime reloaded after configuration change"); @@ -353,7 +384,7 @@ async fn on_config_change(state: &AppState) -> Result<(), String> { // and a slow build from an older event cannot roll back a newer policy. // Steady-state: an invalid config keeps last-good (Rejected is not an error // here), only a transient fetch failure propagates Err for the dispatcher. - reload_serialized(state, || fetch_config(&state.iii)) + reload_serialized(state, || get_config_value(&state.iii)) .await .map(|_| ()) } @@ -366,7 +397,7 @@ async fn on_config_change(state: &AppState) -> Result<(), String> { /// (unbuildable) authoritative config aborts startup rather than serving the /// stale last-good runtime, and a transient fetch failure also aborts. pub async fn reconcile(state: &AppState) -> Result<(), String> { - reconcile_with(state, || fetch_config(&state.iii)).await + reconcile_with(state, || get_config_value(&state.iii)).await } /// Boot reconcile core, split from `fetch_config` so the fail-closed mapping is @@ -374,7 +405,7 @@ pub async fn reconcile(state: &AppState) -> Result<(), String> { async fn reconcile_with(state: &AppState, fetch: F) -> Result<(), String> where F: FnOnce() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { match reload_serialized(state, fetch).await? { ReloadOutcome::Applied => Ok(()), @@ -528,7 +559,7 @@ mod tests { let h1 = tokio::spawn(async move { let _ = reload_serialized(&s1, || async move { tokio::time::sleep(std::time::Duration::from_millis(150)).await; - Ok::<_, String>(old) + Ok::<_, String>(old.to_json()) }) .await; }); @@ -541,7 +572,7 @@ mod tests { let s2 = state.clone(); let new = cfg_new.clone(); let h2 = tokio::spawn(async move { - let _ = reload_serialized(&s2, || async move { Ok::<_, String>(new) }).await; + let _ = reload_serialized(&s2, || async move { Ok::<_, String>(new.to_json()) }).await; }); h1.await.unwrap(); @@ -571,7 +602,7 @@ mod tests { reload_status: Arc::new(RwLock::new(ReloadStatus::default())), }; let res = reload_serialized(&state, || async { - Err::("get timed out".into()) + Err::("get timed out".into()) }) .await; assert!( @@ -608,8 +639,10 @@ mod tests { reload_status: Arc::new(RwLock::new(ReloadStatus::default())), }; // ShellConfig::default() is unjailed (empty host_roots, allow_unjailed false) → rejected by prepare_config. - let res = - reload_serialized(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + let res = reload_serialized(&state, || async { + Ok::<_, String>(ShellConfig::default().to_json()) + }) + .await; assert!( matches!(res, Ok(ReloadOutcome::Rejected)), "invalid config must be acked as Rejected (no retry storm), not Err" @@ -621,6 +654,55 @@ mod tests { ); } + /// A stored value that FAILS TO PARSE (e.g. a 0.6.x shape carrying the + /// removed `inherit_env`/`allowed_env` keys) must classify the same as an + /// unbuildable-but-parseable config: `Rejected`, keep last-good, ack (no + /// retry storm), and record the rejection for `shell::config-status`. + /// Before the fix this hit the transient-fetch-failure branch instead — + /// re-fetching returns the identical bytes, so the dispatcher would retry + /// forever against a value that can never parse. + #[tokio::test] + async fn reload_rejects_and_records_unparseable_stored_value() { + let dir = std::env::temp_dir().join("shell-reload-unparseable-9b3c"); + std::fs::create_dir_all(&dir).unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59593", iii_sdk::InitOptions::default()); + let mut good = ShellConfig::default(); + good.fs.host_roots = vec![dir.clone()]; + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + // A literal 0.6.x stored shape: top-level inherit_env/allowed_env, + // no `env` block — fails check_removed_keys inside parse_fetched_value. + let stale_060_shape = serde_json::json!({ + "inherit_env": true, + "allowed_env": ["PATH", "HOME"], + }); + let res = + reload_serialized(&state, || async move { Ok::<_, String>(stale_060_shape) }).await; + assert!( + matches!(res, Ok(ReloadOutcome::Rejected)), + "unparseable stored value must be Rejected (permanent), not Err (transient): {res:?}" + ); + assert_eq!( + state.runtime.read().await.config.fs.host_roots, + vec![dir], + "runtime keeps last-good on an unparseable stored value" + ); + let s = state.reload_status.read().await; + assert_eq!(s.last_outcome, ReloadOutcome::Rejected); + assert!( + s.last_error + .as_deref() + .is_some_and(|e| e.contains("removed in 0.7.0")), + "config-status must surface the migration hint: {:?}", + s.last_error + ); + assert_eq!(s.rejected_reloads, 1); + } + #[tokio::test] async fn reload_applies_newly_fetched_config() { // The reconcile primitive: a valid newly-fetched config is applied and Ok'd. @@ -641,7 +723,7 @@ mod tests { }; let res = reload_serialized(&state, { let b = b.clone(); - move || async move { Ok::<_, String>(b) } + move || async move { Ok::<_, String>(b.to_json()) } }) .await; assert!(matches!(res, Ok(ReloadOutcome::Applied))); @@ -677,8 +759,10 @@ mod tests { } // A rejected (invalid: unjailed default) reload keeps last-good AND records it. - let res = - reload_serialized(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + let res = reload_serialized(&state, || async { + Ok::<_, String>(ShellConfig::default().to_json()) + }) + .await; assert!(res.is_ok(), "invalid config is acked (no storm)"); { let s = state.reload_status.read().await; @@ -697,7 +781,7 @@ mod tests { good2.fs.host_roots = vec![dir.clone()]; let res = reload_serialized(&state, { let g = good2.clone(); - move || async move { Ok::<_, String>(g) } + move || async move { Ok::<_, String>(g.to_json()) } }) .await; assert!(res.is_ok()); @@ -748,8 +832,10 @@ mod tests { }; // Invalid (unjailed default) authoritative config → fail closed. - let res = - reconcile_with(&state, || async { Ok::<_, String>(ShellConfig::default()) }).await; + let res = reconcile_with(&state, || async { + Ok::<_, String>(ShellConfig::default().to_json()) + }) + .await; assert!( res.is_err(), "boot reconcile must fail closed on an invalid stored config" @@ -757,7 +843,7 @@ mod tests { // Transient fetch failure → fail closed too. let res = reconcile_with(&state, || async { - Err::("get timed out".into()) + Err::("get timed out".into()) }) .await; assert!( @@ -770,7 +856,7 @@ mod tests { good2.fs.host_roots = vec![dir.clone()]; let res = reconcile_with(&state, { let g = good2.clone(); - move || async move { Ok::<_, String>(g) } + move || async move { Ok::<_, String>(g.to_json()) } }) .await; assert!(res.is_ok(), "boot reconcile applies a valid config"); diff --git a/shell/src/exec/host.rs b/shell/src/exec/host.rs index 4768a812c..71f3098a1 100644 --- a/shell/src/exec/host.rs +++ b/shell/src/exec/host.rs @@ -252,10 +252,7 @@ mod tests { fn test_cfg() -> ShellConfig { let mut c = ShellConfig { - env: crate::config::EnvConfig { - inherit: true, - ..Default::default() - }, + env: crate::config::EnvConfig::inherit_all(), max_output_bytes: 4096, ..Default::default() }; @@ -404,18 +401,59 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + /// Removes the named process env vars on drop, even if the test body + /// panics between `set_var` and where a plain cleanup call would have + /// run — an assertion failure without this leaks the var into every + /// later test in the binary. Unique per-test var names (see the caller) + /// keep the residual risk to a logical leak, not a data race: full + /// concurrent-mutation safety would need a process-wide env mutex shared + /// with `code/config.rs`'s `CODER_TEST_ROOT` test, a larger change than + /// this test warrants on its own. + /// Holds [`crate::config::ENV_TEST_MUTEX`] for its whole lifetime AND + /// removes the named process env vars on drop, even if the test body + /// panics between `set_var` and where a plain cleanup call would have + /// run. The mutex serializes against every other test in the crate that + /// touches process env (see that constant's doc comment for why); the + /// Drop cleanup handles the panic case the mutex alone doesn't cover. + /// Held across `.await` below — fine under the default `#[tokio::test]` + /// current-thread flavor, where the outer test future need not be + /// `Send`. + struct EnvVarGuard { + keys: &'static [&'static str], + _lock: std::sync::MutexGuard<'static, ()>, + } + impl EnvVarGuard { + fn new(keys: &'static [&'static str]) -> Self { + let lock = crate::config::ENV_TEST_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Self { keys, _lock: lock } + } + } + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for k in self.keys { + std::env::remove_var(k); + } + } + } + /// With `env.inherit: false`, the child env is scrubbed to exactly the /// `env.allow` keys: an allowed worker var round-trips, a non-allowed one /// never reaches the child. (Unit twin of the e2e scrub/passthrough cases.) #[tokio::test] async fn inherit_false_forwards_only_allow_keys() { - // Unique names so parallel tests can't collide on process env state. - std::env::set_var("SHELL_DX_ALLOWED_9F3A", "allowed-value"); - std::env::set_var("SHELL_DX_BLOCKED_9F3A", "blocked-value"); + const ALLOWED: &str = "SHELL_DX_ALLOWED_9F3A"; + const BLOCKED: &str = "SHELL_DX_BLOCKED_9F3A"; + // Guard acquired BEFORE set_var: it holds the cross-test env mutex, + // so the sets below can't race with another test's env mutation. + let _guard = EnvVarGuard::new(&[ALLOWED, BLOCKED]); + std::env::set_var(ALLOWED, "allowed-value"); + std::env::set_var(BLOCKED, "blocked-value"); let mut cfg = test_cfg(); cfg.env.inherit = false; - cfg.env.allow = vec!["PATH".into(), "SHELL_DX_ALLOWED_9F3A".into()]; + cfg.env.allow = vec!["PATH".into(), ALLOWED.into()]; let out = run_to_completion( &["env".into()], @@ -435,9 +473,6 @@ mod tests { "non-allowed key scrubbed: {}", out.stdout ); - - std::env::remove_var("SHELL_DX_ALLOWED_9F3A"); - std::env::remove_var("SHELL_DX_BLOCKED_9F3A"); } /// A permitted `env` key is visible to the child process. We forward diff --git a/shell/src/exec/policy.rs b/shell/src/exec/policy.rs index d810e92d4..f64a1bc16 100644 --- a/shell/src/exec/policy.rs +++ b/shell/src/exec/policy.rs @@ -569,7 +569,7 @@ mod tests { "omitted cwd defaults to base_dir" ); -// relative cwd anchors at base_dir, not the jail root. + // relative cwd anchors at base_dir, not the jail root. let ov = build_overrides(Some("inner"), None, Some(&base), &c).expect("inner is under base_dir"); assert_eq!( @@ -663,7 +663,7 @@ mod tests { "no base_dir, no cwd ⇒ None (prior behaviour)" ); -// Relative cwd still anchors at the primary jail root when base_dir is absent. + // Relative cwd still anchors at the primary jail root when base_dir is absent. let ov = build_overrides(Some("sub"), None, None, &c).expect("ok"); assert_eq!( ov.cwd.as_deref(), diff --git a/shell/src/functions/exec_bg.rs b/shell/src/functions/exec_bg.rs index d12e5a1f2..5fae5cc81 100644 --- a/shell/src/functions/exec_bg.rs +++ b/shell/src/functions/exec_bg.rs @@ -502,10 +502,7 @@ mod host_path_tests { // (0 → unbounded bg job.) fn cfg(bg_cap_ms: u64, max_concurrent_jobs: usize) -> Arc { let mut c = ShellConfig { - env: crate::config::EnvConfig { - inherit: true, - ..Default::default() - }, + env: crate::config::EnvConfig::inherit_all(), max_output_bytes: 4096, max_timeout_ms: bg_cap_ms, max_bg_timeout_ms: bg_cap_ms, @@ -793,10 +790,7 @@ mod sandbox_path_tests { fn cfg_open() -> Arc { let mut c = ShellConfig { - env: crate::config::EnvConfig { - inherit: true, - ..Default::default() - }, + env: crate::config::EnvConfig::inherit_all(), max_output_bytes: 4096, ..Default::default() }; diff --git a/shell/src/functions/kill.rs b/shell/src/functions/kill.rs index be0ffe97d..284052cab 100644 --- a/shell/src/functions/kill.rs +++ b/shell/src/functions/kill.rs @@ -189,10 +189,7 @@ mod host_kill_tests { fn open_cfg() -> ShellConfig { let mut c = ShellConfig { - env: crate::config::EnvConfig { - inherit: true, - ..Default::default() - }, + env: crate::config::EnvConfig::inherit_all(), max_output_bytes: 4096, ..Default::default() }; diff --git a/shell/src/main.rs b/shell/src/main.rs index 2e34fe24a..50648714a 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -55,37 +55,42 @@ fn ws_host_port(url_str: &str) -> Option<(String, u16)> { Some((host, port)) } -/// One loud, actionable ERROR when the engine is unreachable, BEFORE handing -/// off to the SDK's silent infinite 2s-backoff reconnect loop (which only -/// WARNs). Never fails fast — supervised deployments rely on the SDK retry — -/// and never blocks boot for more than ~4s (2s connect timeout, at most two -/// resolved addresses tried). Parse/resolve failures just skip the probe: the -/// SDK is the authority on what URLs it accepts. +/// One loud, actionable ERROR when the engine is unreachable, BEFORE the SDK's +/// silent infinite 2s-backoff reconnect loop makes the failure look quiet. +/// Runs DETACHED (spawned thread): it only logs, never gates boot, so neither +/// the synchronous DNS lookup (unbounded — system resolver) nor the 2s TCP +/// connect attempts can delay startup. Never fails fast — supervised +/// deployments rely on the SDK retry. Parse/resolve failures just skip the +/// probe: the SDK is the authority on what URLs it accepts. Log lines carry +/// host:port, not the raw URL — a wss:// URL can embed credentials. fn probe_engine_reachable(url_str: &str) { - use std::net::{TcpStream, ToSocketAddrs}; let Some((host, port)) = ws_host_port(url_str) else { - tracing::warn!(url = %url_str, "could not parse engine URL; skipping reachability probe"); + tracing::warn!("could not parse engine URL; skipping reachability probe"); return; }; - let addrs = match (host.as_str(), port).to_socket_addrs() { - Ok(a) => a.collect::>(), - Err(e) => { - tracing::warn!(url = %url_str, error = %e, "engine host did not resolve"); - return; + std::thread::spawn(move || { + use std::net::{TcpStream, ToSocketAddrs}; + let addrs = match (host.as_str(), port).to_socket_addrs() { + Ok(a) => a.collect::>(), + Err(e) => { + tracing::warn!(host = %host, port, error = %e, "engine host did not resolve"); + return; + } + }; + let reachable = addrs + .iter() + .take(2) + .any(|a| TcpStream::connect_timeout(a, std::time::Duration::from_secs(2)).is_ok()); + if !reachable { + tracing::error!( + host = %host, + port, + "engine unreachable at {host}:{port} — is the iii engine running? Set --url \ + or the III_URL env var if it listens elsewhere. Continuing to retry in the \ + background every 2s." + ); } - }; - let reachable = addrs - .iter() - .take(2) - .any(|a| TcpStream::connect_timeout(a, std::time::Duration::from_secs(2)).is_ok()); - if !reachable { - tracing::error!( - url = %url_str, - "engine unreachable at {url_str} — is the iii engine running? Set --url or the \ - III_URL env var if it listens elsewhere. Continuing to retry in the background \ - every 2s." - ); - } + }); } /// Identify this worker to the engine as `shell` (name, runtime, version, pid) @@ -139,15 +144,31 @@ async fn main() -> Result<()> { // installed. Idempotent and a silent no-op when no collector is attached. telemetry::init(); + // Seed policy: a MISSING file is the zero-config path (warn + fall through + // to the stored value or built-in seed). A file that EXISTS but fails to + // parse aborts boot: on first registration the fallback would silently + // replace the operator's intended policy with the permissive dev seed — + // fail-open — and with 0.7.0's removed-key rejection every un-migrated + // 0.6.x config file hits exactly this path. Mirrors the stored-value + // story: reject loudly, never degrade to a wider policy. let seed = match config::ShellConfig::from_file(&cli.config) { Ok(cfg) => { tracing::info!(path = %cli.config, "loaded seed config for initial registration"); Some(cfg) } - Err(e) => { - tracing::warn!(path = %cli.config, error = %e, "could not load --config seed; using the stored configuration value if present, else the built-in zero-config default"); + Err(e) if !std::path::Path::new(&cli.config).exists() => { + tracing::warn!(path = %cli.config, error = %e, "no --config seed file; using the stored configuration value if present, else the built-in zero-config default"); None } + Err(e) => { + anyhow::bail!( + "--config seed {} exists but failed to parse: {e}. Refusing to boot: falling \ + back would seed the permissive built-in default in place of the intended \ + policy. Fix the file (see the migration hint above) or remove it to opt \ + into the zero-config default.", + cli.config + ); + } }; configuration::register_config(&iii, seed.as_ref()) @@ -680,7 +701,10 @@ mod tests { #[test] fn ws_host_port_uses_known_default_ports() { - assert_eq!(ws_host_port("ws://localhost"), Some(("localhost".to_string(), 80))); + assert_eq!( + ws_host_port("ws://localhost"), + Some(("localhost".to_string(), 80)) + ); assert_eq!( ws_host_port("wss://engine.example"), Some(("engine.example".to_string(), 443)) From bcb0fcad62e51f5bfe4924c9fdcdd7209c34edbb Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 11:53:40 -0300 Subject: [PATCH 09/12] docs(shell): document pre-landing review fixes in CHANGELOG and README Adds a Fixed section to the 0.7.0 CHANGELOG entry covering the seed/reload fail-closed fixes, the half-migration rejection, the wrapper-tolerant denylist case-insensitivity and env KEY=VALUE coverage, and the flaky-test fix. Strengthens the coder-migration-removal wording in both files to spell out the concrete consequence of skipping the boot-0.6.x-first escape hatch (silent seed to the generic /tmp default) rather than only naming the removal. Updates the Running section's probe description to match the detached-thread, host:port-only logging behavior. --- shell/CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++---- shell/README.md | 49 ++++++++++++++++++++++++++++---------- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/shell/CHANGELOG.md b/shell/CHANGELOG.md index bc3bade31..dc9be1a24 100644 --- a/shell/CHANGELOG.md +++ b/shell/CHANGELOG.md @@ -28,8 +28,10 @@ binary itself. the `shell` value at boot, and boot no longer probes `configuration::get` for a `coder` entry — which also removes the boot-time "configuration 'coder' not found" WARN retries. Stored values still - carrying the marker parse fine (it is ignored). Stacks that still need the - fold should boot 0.6.x once before upgrading. + carrying the marker parse fine (it is ignored). **Skipping this on a + standalone-`coder`-only install silently seeds the generic permissive + `/tmp` dev default for `shell` instead of the old coder roots/globs** — + stacks that still need the fold should boot 0.6.x once before upgrading. ### Added - `--version` prints the worker version. @@ -53,8 +55,8 @@ binary itself. rewrite their stored value by hand should adopt the anchored forms too. - The denylist rejection message now says it is an advisory tripwire and to rephrase the command, so agents stop retrying verbatim. -- The seed uses the preferred multi-root jail form (`fs.host_roots: [/tmp]`) - instead of the legacy `fs.host_root`. +- The seed uses the multi-root jail form (`fs.host_roots: [/tmp]`) — the + singular `fs.host_root` was then removed outright (see Breaking). - Seed `default_timeout_ms` raised 10s → 30s: the seed raises `max_timeout_ms` to 120s so real builds survive; callers omitting `timeout_ms` shouldn't be reaped at 10s on the same workload. The CODE @@ -66,6 +68,55 @@ binary itself. - Every `code.*` (CoderConfig) budget field now carries a schema description; the schema test covers all nested definitions, not just the top level. +### Fixed (pre-landing review) +- **Seed-file parse failures now fail closed.** A `--config` file that EXISTS + but fails to parse (e.g. still carries the removed 0.6.x keys) aborts boot + instead of warning and silently seeding the permissive built-in default in + its place — that fallback would have handed a fresh registration an open + allowlist and full env forwarding instead of the operator's intended + policy. A genuinely MISSING file still falls through gracefully. +- **Hot-reload no longer retry-storms on an unparseable stored value.** + Previously, a stored config carrying removed keys failed inside the fetch + step and was misclassified as a *transient* error (dispatcher retries + forever against bytes that can never parse). It is now classified as + `Rejected` — the same treatment as an unbuildable-but-parseable config: + keep last-good, ack (no storm), and record the rejection for + `shell::config-status`. +- **`env.allow` half-migration is rejected.** `EnvConfig` now denies unknown + fields, so nesting the OLD key names under the new block (e.g. + `env: { inherit_env: true }`) fails closed instead of silently falling back + to the wider default `allow` list. +- **Command-shaped denylist patterns tolerate a wrapper prefix** + (`sudo`, `doas`, `nohup`, `env`, `timeout [duration]`, optionally + path-qualified): `sudo shutdown -h now` trips the tripwire again — the + argv[0]-anchoring in the first pass of this release had dropped that case, + arguably the most likely accidental invocation for commands that normally + require root. +- **The removed-key check found and fixed its own bug during consolidation**: + merging the top-level and nested-`fs` checks into one function surfaced + that the original used `.any()`, which short-circuits — a config carrying + BOTH `inherit_env` and `allowed_env` only ever named the first in its error. + Every removed key present is now named in one pass. +- **The boot-time reachability probe runs detached** so its DNS resolution + (unbounded — system resolver) and 2s-per-address TCP connect attempts can + never delay startup, and it logs host:port rather than the raw URL (a + `wss://user:pass@host` URL could otherwise leak credentials to the log). +- **A half-migrated `env` block now gets the same migration hint as every + other removed key.** Nesting the OLD field names under the NEW `env:` + block (e.g. `env: { inherit_env: true }`) previously hit `EnvConfig`'s + generic `deny_unknown_fields` serde error with no guidance; it now names + the key and points at `env.inherit`/`env.allow` like the other rejections. +- **The anchored denylist wrapper tolerance is now case-insensitive and + handles `env`'s idiomatic `KEY=VALUE...` form.** `SUDO shutdown -h now` + and `env FOO=bar shutdown -h now` (env's actual common usage — bare + `env cmd` was covered, `env KEY=VAL cmd` was not) now trip the tripwire; + previously both silently bypassed it, undermining the wrapper-tolerance + feature's own stated purpose for its most-used wrapper. +- **Fixed a flaky test**: two tests that mutate process-wide environment + state (`std::env::set_var`) could race on separate `cargo test` threads + within the same binary, producing an intermittent, environment-dependent + failure. Both now serialize on a shared test-only mutex. + ### Migration ```yaml # 0.6.x # 0.7.0 diff --git a/shell/README.md b/shell/README.md index 203a8d241..3efe38310 100644 --- a/shell/README.md +++ b/shell/README.md @@ -42,10 +42,18 @@ engine with pure defaults. The full operator surface: | `--version` | — | Print the worker version (also registered with the engine as worker metadata). | | `RUST_LOG` env var | `info` | Log filter (tracing `EnvFilter` syntax, e.g. `RUST_LOG=shell=debug,info`). | -If the engine is unreachable at boot, a pre-connect probe logs one ERROR -("engine unreachable at — is the iii engine running? Set --url or the -III_URL env var...") and the worker keeps retrying in the background every 2s — -it never exits, so supervised deployments recover as soon as the engine is up. +If the engine is unreachable at boot, a pre-connect probe (run detached, so it +never delays startup) logs one ERROR ("engine unreachable at : — +is the iii engine running? Set --url or the III_URL env var...", logging the +resolved host/port rather than the raw URL) and the worker keeps retrying in +the background every 2s — it never exits, so supervised deployments recover +as soon as the engine is up. + +A `--config` seed file that exists but fails to parse (for example, one still +carrying 0.6.x keys) aborts boot rather than silently falling back to the +permissive built-in default — see [Upgrading to 0.7.0](#upgrading-to-070). A +genuinely missing file still falls through gracefully to the stored value or +the built-in zero-config default. ## Configure @@ -68,8 +76,10 @@ env: # allowlist means OPEN — the shipped default, so any command runs. # denylist_patterns are advisory regex over argv.join(" "), a tripwire for # catastrophic mistakes only, NOT a security boundary. Command-shaped -# patterns are anchored to argv[0] so `grep -rn shutdown src/` is not -# rejected; argument-shaped ones (rm -rf /) stay unanchored. +# patterns are anchored to argv[0] (tolerating a sudo/doas/nohup/env/timeout +# wrapper) so `grep -rn shutdown src/` is not rejected but `sudo shutdown -h +# now` still is; argument-shaped ones (rm -rf /) stay unanchored. See the +# shipped config.yaml for the full patterns. allowlist: [] denylist_patterns: - "rm\\s+-rf\\s+/" @@ -218,7 +228,13 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i shape. **Sequencing matters**: update the binary FIRST, then the stored value — writing the new shape while 0.6.x is still running makes the old worker hot-reload it, ignore the unknown `env` block, and silently stop - forwarding env until restart. + forwarding env until restart. A **half-migration** (nesting the OLD key + names under the new block, e.g. `env: { inherit_env: true }`) is also + rejected — `env` denies unknown fields — rather than silently falling back + to the wider default `allow` list. + A `--config` seed file carrying any of these removed keys now **aborts + boot** rather than warning and falling back to the permissive built-in + seed; only a genuinely missing seed file falls through gracefully. - **BREAKING: `fs.host_root` (single-root alias) removed.** The 0.6.x one-entry alias for the jail root is **rejected at parse** with a migration hint ("config key removed in 0.7.0: `fs.host_root` -> `fs.host_roots` @@ -242,15 +258,24 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i value at boot (the `migrated_from_coder` marker field is gone too; stored values still carrying it parse fine and the marker is ignored). Boot also no longer probes `configuration::get` for the `coder` entry, so the - "configuration 'coder' not found" WARN retries at startup are gone. If you - are upgrading a pre-0.6 stack that still relies on the fold, boot 0.6.x - once first (it performs the migration), then upgrade to 0.7.0. + "configuration 'coder' not found" WARN retries at startup are gone. + **Consequence if you skip the escape hatch below**: an install with only a + standalone `coder` entry and no `shell` entry boots 0.7.0 with the generic + permissive `/tmp` dev seed for `shell` — the old `coder` roots and + protected globs are NOT carried over, silently. If you are upgrading a + pre-0.6 stack that still relies on the fold, boot 0.6.x once first (it + performs the migration and writes the `shell` entry), THEN upgrade to + 0.7.0. - **`--version` added**, and `--url`/`III_URL` and `RUST_LOG` are now documented (see [Running](#running)). -- **Unreachable-engine boot is loud**: one ERROR with the URL and the fix - hint, instead of only the SDK's silent retry WARNs. +- **Unreachable-engine boot is loud**: one ERROR naming the host/port and the + fix hint, instead of only the SDK's silent retry WARNs. The probe runs + detached so it never delays boot. - **Every config field now carries a schema description**, so the console configuration UI documents each knob inline. +- **Command-shaped denylist patterns tolerate a wrapper prefix** + (`sudo`/`doas`/`nohup`/`env`/`timeout [duration]`, optionally + path-qualified): `sudo shutdown -h now` trips the tripwire again. ## Upgrading to 0.4.0 From 6103812a301d2e473af6771396f35fda1fc5a5b9 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 12:03:42 -0300 Subject: [PATCH 10/12] =?UTF-8?q?fix(shell)!:=20reject=20code.base=5Fpath(?= =?UTF-8?q?s)=20and=20migrated=5Ffrom=5Fcoder=20=E2=80=94=20hard=20migrati?= =?UTF-8?q?on,=20no=20legacy=20tolerance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commits silently ignored two removed 0.7.0 keys instead of rejecting them: code.base_path/base_paths (never had a runtime effect, so 'harmless to keep' seemed reasonable) and the migrated_from_coder marker (pure internal bookkeeping, never operator-set). Per explicit direction: this branch does a hard migration with zero legacy-support code, no exceptions for keys that happen to be inert or invisible to the operator. check_removed_keys is restructured around a RemovedKey{old, new: Option} type so pure removals (no replacement — just delete the key) share the same table-driven scan as renames. Both new cases get the same hard-fail treatment and migration hint as inherit_env/allowed_env/ fs.host_root: named in the error, pointed at configuration::set. Corrects the CHANGELOG/README claims accordingly, and is precise about what this does and doesn't fix: an install that already has a stored shell entry (went through the 0.6.x coder fold, carries migrated_from_coder: true) now fails closed at boot instead of silently parsing past the marker. An install with ONLY a standalone coder entry and no shell entry at all still seeds the generic /tmp default silently — there's nothing stored to reject in that case, so the boot-0.6.x-first escape hatch remains the only fix for that scenario. 1282 unit tests (4 new), e2e 171/171 + jailed 2/2, fmt/clippy clean. --- shell/CHANGELOG.md | 35 ++++--- shell/README.md | 37 +++++--- shell/src/code/config.rs | 6 +- shell/src/config.rs | 194 ++++++++++++++++++++++++++++----------- 4 files changed, 188 insertions(+), 84 deletions(-) diff --git a/shell/CHANGELOG.md b/shell/CHANGELOG.md index dc9be1a24..5da1feb9b 100644 --- a/shell/CHANGELOG.md +++ b/shell/CHANGELOG.md @@ -18,20 +18,29 @@ binary itself. parse** with a migration hint (`fs.host_root` -> `fs.host_roots`); serde would otherwise ignore the stale key and the worker would see no jail configured at all. -- **`code.base_path` and `code.base_paths` are removed from the schema.** - They were inert: the code resolver has always taken its roots from - `fs.host_roots` (one jail config), so stored values still carrying them are - silently **ignored** (no reject — they never had an effect). +- **`code.base_path` and `code.base_paths` are removed from the schema and + REJECTED at parse.** They were inert even before removal — the code + resolver has always taken its roots from `fs.host_roots` (one jail + config) — but this is a hard migration: "never had an effect" is not an + exception. A stored value still carrying either fails closed with a hint + naming both keys, same as every other removed key. - **The one-shot coder→shell config migration is removed** - (`migrate_legacy_coder` and the hidden `migrated_from_coder` marker field). - 0.7.0 no longer folds a legacy standalone-`coder` configuration entry into - the `shell` value at boot, and boot no longer probes `configuration::get` - for a `coder` entry — which also removes the boot-time - "configuration 'coder' not found" WARN retries. Stored values still - carrying the marker parse fine (it is ignored). **Skipping this on a - standalone-`coder`-only install silently seeds the generic permissive - `/tmp` dev default for `shell` instead of the old coder roots/globs** — - stacks that still need the fold should boot 0.6.x once before upgrading. + (`migrate_legacy_coder`), and the hidden `migrated_from_coder` marker + field is REJECTED at parse, not silently tolerated. 0.7.0 no longer folds + a legacy standalone-`coder` configuration entry into the `shell` value at + boot, and boot no longer probes `configuration::get` for a `coder` entry + — which also removes the boot-time "configuration 'coder' not found" WARN + retries. Two distinct upgrade scenarios: + - An install that ALREADY has a `shell` entry (it went through the fold + under 0.6.x, so that entry carries `migrated_from_coder: true`) now + fails closed at 0.7.0 boot with a migration hint, instead of silently + parsing past the marker. + - An install with ONLY a standalone `coder` entry and NO `shell` entry at + all has nothing to reject — `register_config` still seeds the generic + permissive `/tmp` dev default for `shell`, silently, because there is no + stored `shell` value to fail closed on. Boot 0.6.x once first (it + performs the fold and writes the `shell` entry) before upgrading to + 0.7.0 to avoid this. ### Added - `--version` prints the worker version. diff --git a/shell/README.md b/shell/README.md index 3efe38310..ee65dac78 100644 --- a/shell/README.md +++ b/shell/README.md @@ -248,24 +248,31 @@ Sandbox-forwarded `fs::*`/`exec` errors can also surface engine codes verbatim i Same fail-closed rationale as the env keys: serde would otherwise ignore the stale key and the worker would see no jail configured at all. -- **BREAKING: `code.base_path`/`code.base_paths` removed from the schema.** - They were inert — the code resolver has taken its roots from - `fs.host_roots` since the coder merge — so stored values still carrying - them are silently **ignored** (no reject; they never had an effect). Set - the jail once via `fs.host_roots`. +- **BREAKING: `code.base_path`/`code.base_paths` removed from the schema and + REJECTED at parse.** They were inert even before removal — the code + resolver has taken its roots from `fs.host_roots` since the coder merge — + but this is a hard migration: "never had an effect" is not an exception. A + stored value still carrying either fails closed with a hint naming both + keys, same as every other removed key. Set the jail once via + `fs.host_roots`. - **The one-shot coder→shell config migration is removed.** 0.7.0 no longer folds a legacy standalone-`coder` configuration entry into the `shell` - value at boot (the `migrated_from_coder` marker field is gone too; stored - values still carrying it parse fine and the marker is ignored). Boot also - no longer probes `configuration::get` for the `coder` entry, so the + value at boot, and the hidden `migrated_from_coder` marker field is + REJECTED at parse rather than silently tolerated. Boot also no longer + probes `configuration::get` for the `coder` entry, so the "configuration 'coder' not found" WARN retries at startup are gone. - **Consequence if you skip the escape hatch below**: an install with only a - standalone `coder` entry and no `shell` entry boots 0.7.0 with the generic - permissive `/tmp` dev seed for `shell` — the old `coder` roots and - protected globs are NOT carried over, silently. If you are upgrading a - pre-0.6 stack that still relies on the fold, boot 0.6.x once first (it - performs the migration and writes the `shell` entry), THEN upgrade to - 0.7.0. + **Two distinct upgrade scenarios**: + - An install that ALREADY has a `shell` entry (it went through the fold + under 0.6.x, so the entry carries `migrated_from_coder: true`) now fails + closed at 0.7.0 boot with a migration hint, instead of silently parsing + past the marker. + - An install with ONLY a standalone `coder` entry and NO `shell` entry at + all has nothing to reject — it still boots 0.7.0 with the generic + permissive `/tmp` dev seed for `shell`, silently, because there is no + stored `shell` value to fail closed on. The old `coder` roots and + protected globs are NOT carried over. Boot 0.6.x once first (it performs + the migration and writes the `shell` entry) before upgrading to 0.7.0 to + avoid this. - **`--version` added**, and `--url`/`III_URL` and `RUST_LOG` are now documented (see [Running](#running)). - **Unreachable-engine boot is loud**: one ERROR naming the host/port and the diff --git a/shell/src/code/config.rs b/shell/src/code/config.rs index d57ea8ffd..5a2a4609d 100644 --- a/shell/src/code/config.rs +++ b/shell/src/code/config.rs @@ -24,8 +24,10 @@ pub struct CoderConfig { /// canonicalize inside ANY listed root. When empty, the effective /// default is `["./", "/tmp"]` (resolved at `PathResolver` /// construction). The 0.6.x `code.base_path`/`base_paths` config keys - /// were removed from the schema in 0.7.0; stored values still carrying - /// them are silently ignored — deliberate, they never had an effect. + /// were removed from the schema in 0.7.0; a stored value still carrying + /// either is REJECTED at parse (`config::check_removed_keys`) — hard + /// migration, no silent tolerance even though they were already inert + /// before removal. #[serde(skip)] #[schemars(skip)] pub base_paths: Vec, diff --git a/shell/src/config.rs b/shell/src/config.rs index a55ba3375..c27b392e1 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -137,12 +137,37 @@ fn default_job_retention_secs() -> u64 { 3600 } -/// Top-level keys removed in 0.7.0 and where they moved. -const REMOVED_TOP_LEVEL_KEYS: &[(&str, &str)] = - &[("inherit_env", "env.inherit"), ("allowed_env", "env.allow")]; +/// A removed config key: `old` is gone; `new` is where it moved (`Some`), or +/// `None` if it has NO replacement (a pure removal — nothing to migrate to, +/// the value should simply be deleted). This crate does a HARD migration: +/// every 0.6.x key that no longer exists is rejected at parse, never +/// silently ignored or tolerated — there is no soft/gradual transition path. +struct RemovedKey { + old: &'static str, + new: Option<&'static str>, +} +const fn renamed(old: &'static str, new: &'static str) -> RemovedKey { + RemovedKey { + old, + new: Some(new), + } +} +const fn deleted(old: &'static str) -> RemovedKey { + RemovedKey { old, new: None } +} + +/// Top-level keys removed in 0.7.0. +const REMOVED_TOP_LEVEL_KEYS: &[RemovedKey] = &[ + renamed("inherit_env", "env.inherit"), + renamed("allowed_env", "env.allow"), + // The one-shot coder->shell migration marker: pure internal bookkeeping + // an operator never set, but still no exception — a hard migration means + // EVERY 0.6.x artifact is rejected, not just the operator-facing ones. + deleted("migrated_from_coder"), +]; -/// Keys removed from the nested `fs` block in 0.7.0 and where they moved. -const REMOVED_FS_KEYS: &[(&str, &str)] = &[("host_root", "fs.host_roots")]; +/// Keys removed from the nested `fs` block in 0.7.0. +const REMOVED_FS_KEYS: &[RemovedKey] = &[renamed("host_root", "fs.host_roots")]; /// Keys removed from the nested `env` block in 0.7.0: an operator /// half-migrating by nesting the OLD field names under the new block (e.g. @@ -150,75 +175,81 @@ const REMOVED_FS_KEYS: &[(&str, &str)] = &[("host_root", "fs.host_roots")]; /// `deny_unknown_fields` with a generic serde "unknown field" error and no /// migration guidance. Give it the same friendly hint as every other /// removed-key case. -const REMOVED_ENV_KEYS: &[(&str, &str)] = - &[("inherit_env", "env.inherit"), ("allowed_env", "env.allow")]; +const REMOVED_ENV_KEYS: &[RemovedKey] = &[ + renamed("inherit_env", "env.inherit"), + renamed("allowed_env", "env.allow"), +]; + +/// Keys removed from the nested `code` block in 0.7.0. Both were already +/// inert before removal — the code resolver has always taken its roots from +/// `fs.host_roots` — but "never had an effect" is not an exception to a hard +/// migration: a 0.6.x stored value carrying them must still be cleaned up, +/// not silently tolerated forever. +const REMOVED_CODE_KEYS: &[RemovedKey] = &[deleted("base_path"), deleted("base_paths")]; const CONFIGURATION_SET_HINT: &str = "If this is the stored value, rewrite it via configuration::set (id: shell)."; +/// Check one nested object (or the top-level document, via `prefix == ""`) +/// against a table of removed keys, pushing a hint for every hit into `hits`. +/// Returns whether anything in `table` matched, so the caller can decide +/// which remedy sentence(s) to attach. Iterates the WHOLE table — never +/// short-circuits — so a value carrying multiple removed keys under the same +/// object gets every one named, not just the first. +fn scan_removed( + obj: &serde_json::Map, + prefix: &str, + table: &[RemovedKey], + hits: &mut Vec, +) -> bool { + let mut hit = false; + for key in table { + if obj.contains_key(key.old) { + hit = true; + match key.new { + Some(new) => hits.push(format!("`{prefix}{}` -> `{new}`", key.old)), + None => hits.push(format!("`{prefix}{}` (removed, no replacement)", key.old)), + } + } + } + hit +} + /// Fail closed on any 0.7.0-removed key, wherever serde would otherwise -/// silently ignore it and boot with a narrower (env forwarding) or absent -/// (fs jail) policy than the operator intended. One traversal over the -/// top-level document and the nested `fs`/`env` objects, shared by -/// `from_yaml` (via a text->Value bridge, see its doc comment) and -/// `from_json` — a future removed key is wired into ONE place, not -/// duplicated per funnel. Reports every hit in a single error so an operator -/// who left MULTIPLE removed keys unmigrated fixes everything in one pass. +/// silently ignore it and boot with a narrower (env forwarding), absent (fs +/// jail), or merely STALE (coder migration marker, inert code roots) state +/// than the operator intended. One traversal over the top-level document and +/// the nested `fs`/`env`/`code` objects, shared by `from_yaml` (via a +/// text->Value bridge, see its doc comment) and `from_json` — a future +/// removed key is wired into ONE place, not duplicated per funnel. Reports +/// every hit in a single error so an operator who left MULTIPLE removed keys +/// unmigrated fixes everything in one pass. This is a HARD migration: there +/// is no silent-ignore path for ANY removed key, including ones (like +/// `code.base_path`/`migrated_from_coder`) that were already inert before +/// removal — "harmless to keep" is not the same as "supported." fn check_removed_keys(value: &serde_json::Value) -> Result<(), String> { let Some(obj) = value.as_object() else { return Ok(()); }; - // NOTE: `.filter()` + `.count()`, NOT `.any()` — `.any()` short-circuits - // on the first match, so a config carrying BOTH removed env keys would - // only ever name the first in its error. let mut hits = Vec::new(); - let env_hit = REMOVED_TOP_LEVEL_KEYS - .iter() - .filter(|(old, new)| { - let present = obj.contains_key(*old); - if present { - hits.push(format!("`{old}` -> `{new}`")); - } - present - }) - .count() - > 0; + let env_hit = scan_removed(obj, "", REMOVED_TOP_LEVEL_KEYS, &mut hits); let fs_hit = obj .get("fs") .and_then(serde_json::Value::as_object) - .is_some_and(|fs| { - REMOVED_FS_KEYS - .iter() - .filter(|(old, new)| { - let present = fs.contains_key(*old); - if present { - hits.push(format!("`fs.{old}` -> `{new}` (one-entry list)")); - } - present - }) - .count() - > 0 - }); + .is_some_and(|fs| scan_removed(fs, "fs.", REMOVED_FS_KEYS, &mut hits)); // Half-migration: the OLD key names nested under the NEW `env:` block. let env_nested_hit = obj .get("env") .and_then(serde_json::Value::as_object) - .is_some_and(|env| { - REMOVED_ENV_KEYS - .iter() - .filter(|(old, new)| { - let present = env.contains_key(*old); - if present { - hits.push(format!("`env.{old}` -> `{new}`")); - } - present - }) - .count() - > 0 - }); + .is_some_and(|env| scan_removed(env, "env.", REMOVED_ENV_KEYS, &mut hits)); + + let code_hit = obj + .get("code") + .and_then(serde_json::Value::as_object) + .is_some_and(|code| scan_removed(code, "code.", REMOVED_CODE_KEYS, &mut hits)); if hits.is_empty() { return Ok(()); @@ -232,6 +263,9 @@ fn check_removed_keys(value: &serde_json::Value) -> Result<(), String> { if fs_hit { remedies.push("Set the fs jail under `fs: { host_roots: [] }`."); } + if code_hit { + remedies.push("Delete `code.base_path`/`code.base_paths` — the code resolver's roots always come from `fs.host_roots`."); + } Err(format!( "config keys removed in 0.7.0: {}. {} {CONFIGURATION_SET_HINT}", @@ -986,13 +1020,65 @@ mod tests { /// no `env` block — must fail closed through `from_json`. #[test] fn stored_060_shape_fails_closed_with_hint() { + // Realistic: a real 0.6.x stored value that went through the + // coder->shell fold also carries `migrated_from_coder: true` — the + // hard-migration check must reject this shape wholesale, not just + // the env keys. let mut v = ShellConfig::seed_default().to_json(); let obj = v.as_object_mut().unwrap(); obj.remove("env"); obj.insert("inherit_env".into(), serde_json::Value::Bool(true)); obj.insert("allowed_env".into(), serde_json::json!(["PATH", "HOME"])); + obj.insert("migrated_from_coder".into(), serde_json::Value::Bool(true)); let err = ShellConfig::from_json(&v).expect_err("0.6.x shape fails closed"); assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!(err.contains("`inherit_env` -> `env.inherit`"), "{err}"); + assert!( + err.contains("`migrated_from_coder` (removed, no replacement)"), + "{err}" + ); + } + + /// Hard migration: `migrated_from_coder` is pure internal bookkeeping an + /// operator never set, but a hard migration has NO exception for + /// "harmless" removed keys — every 0.6.x artifact is rejected, not just + /// the operator-facing ones. + #[test] + fn from_json_rejects_migrated_from_coder_marker() { + let v = serde_json::json!({ + "migrated_from_coder": true, + "fs": {"allow_unjailed": true}, + }); + let err = ShellConfig::from_json(&v).expect_err("removed marker rejects"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!( + err.contains("`migrated_from_coder` (removed, no replacement)"), + "{err}" + ); + assert!(err.contains("configuration::set"), "{err}"); + } + + /// Hard migration: `code.base_path`/`code.base_paths` were already + /// inert before removal (the resolver always used `fs.host_roots`), but + /// "never had an effect" is not an exception — a stored value still + /// carrying either is rejected, not silently tolerated. + #[test] + fn from_json_rejects_removed_code_base_path_keys() { + let v = serde_json::json!({ + "code": {"base_path": "/tmp", "base_paths": ["/tmp/a"]}, + "fs": {"allow_unjailed": true}, + }); + let err = ShellConfig::from_json(&v).expect_err("removed code keys reject"); + assert!(err.contains("removed in 0.7.0"), "{err}"); + assert!( + err.contains("`code.base_path` (removed, no replacement)"), + "{err}" + ); + assert!( + err.contains("`code.base_paths` (removed, no replacement)"), + "{err}" + ); + assert!(err.contains("configuration::set"), "{err}"); } /// Regression: the removed-key pre-parse must NOT change how scalars From 4439f175cdb89af2aca34b049f1a00924d03a40b Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 12:19:38 -0300 Subject: [PATCH 11/12] fix(shell): update comments to clarify rejection of legacy code.base_path values Revises comments in ShellConfig and CoderConfig to specify that stored values carrying removed keys like code.base_path are rejected during parsing, enhancing clarity on migration behavior. Removes outdated test for ignored base_path values, aligning with the hard migration approach established in previous commits. --- shell/src/code/config.rs | 11 ----------- shell/src/config.rs | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/shell/src/code/config.rs b/shell/src/code/config.rs index 5a2a4609d..2019e45c1 100644 --- a/shell/src/code/config.rs +++ b/shell/src/code/config.rs @@ -346,17 +346,6 @@ mod tests { assert_eq!(a, b); } - #[test] - fn stored_base_path_and_base_paths_are_ignored() { - // Removed in 0.7.0 WITHOUT a reject: these keys never had a runtime - // effect (code_resolver_config always overwrote the roots from - // fs.host_roots), so an old stored value carrying them parses fine - // and the roots stay runtime-filled (empty here). - let cfg: CoderConfig = - serde_yaml::from_str("base_path: /tmp/legacy\nbase_paths: [/tmp/x]\n").unwrap(); - assert!(cfg.base_paths.is_empty()); - } - #[test] fn custom_yaml_overrides_each_field() { let yaml = r#" diff --git a/shell/src/config.rs b/shell/src/config.rs index c27b392e1..dcbc5ea6c 100644 --- a/shell/src/config.rs +++ b/shell/src/config.rs @@ -105,8 +105,7 @@ pub struct ShellConfig { /// per-file/response budgets. The code resolver's ROOTS are NOT taken from /// here — it uses `fs.host_roots` so there is a single jail config /// (`code.base_path`/`base_paths` were removed from the schema in 0.7.0; - /// stored values still carrying them are ignored — they never had an - /// effect). + /// stored values still carrying them are rejected at parse). #[serde(default)] pub code: crate::code::config::CoderConfig, From baab3bc26526186d4b7d977898fe2050343cdc73 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 2 Jul 2026 22:30:14 -0300 Subject: [PATCH 12/12] feat(shell): support folder access grants --- shell/src/code/functions/create_file.rs | 72 ++-- shell/src/code/functions/delete_file.rs | 37 +- shell/src/code/functions/list_folder.rs | 9 + shell/src/code/functions/mod.rs | 125 ++++--- shell/src/code/functions/move_file.rs | 56 ++- shell/src/code/functions/read_file.rs | 20 ++ shell/src/code/functions/search.rs | 17 + shell/src/code/functions/tree.rs | 5 + shell/src/code/functions/update_file.rs | 70 +++- shell/src/code/mod.rs | 1 - shell/src/code/path.rs | 128 +++++-- shell/src/code/state.rs | 14 +- shell/src/configuration.rs | 96 ++++++ shell/src/exec/host.rs | 15 +- shell/src/exec/policy.rs | 27 +- shell/src/fs/host.rs | 322 ++++++++++++++++-- shell/src/fs/mod.rs | 71 ++++ shell/src/fs/sandbox.rs | 10 + shell/src/functions/exec.rs | 10 +- shell/src/functions/exec_bg.rs | 10 +- shell/src/functions/types.rs | 8 + shell/src/grant.rs | 68 ++++ shell/src/lib.rs | 1 + shell/src/main.rs | 20 +- shell/tests/code_golden_errors.rs | 24 ++ shell/tests/code_path_jail.rs | 17 +- shell/tests/code_unified_protection.rs | 3 + shell/tests/code_update_ops.rs | 4 + .../features/coder/path_security.feature | 2 +- shell/tests/golden/errors.json | 6 +- shell/tests/host_fs_branches.rs | 16 + shell/tests/sandbox_dispatch.rs | 19 ++ 32 files changed, 1093 insertions(+), 210 deletions(-) create mode 100644 shell/src/grant.rs diff --git a/shell/src/code/functions/create_file.rs b/shell/src/code/functions/create_file.rs index 060e374b4..b5a836186 100644 --- a/shell/src/code/functions/create_file.rs +++ b/shell/src/code/functions/create_file.rs @@ -21,6 +21,10 @@ pub struct CreateFileInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -99,24 +103,31 @@ pub async fn handle( ))); } let base_dir = req.base_dir.as_deref(); - let mut results = Vec::with_capacity(req.files.len()); + let mut entries = Vec::with_capacity(req.files.len()); for spec in req.files { - results.push(create_one(&resolver, &cfg, base_dir, spec)); + match resolver.require_writable_opt(base_dir, &spec.path) { + Ok(abs) => entries.push((spec, Ok(abs))), + Err(e) if is_jail_scope_error(&e) => return Err(err_to_string(e)), + Err(e) => entries.push((spec, Err(e))), + } } + let results = entries + .into_iter() + .map(|(spec, resolved)| create_one(&cfg, spec, resolved)) + .collect(); Ok(CreateFileOutput { results }) } fn create_one( - resolver: &PathResolver, cfg: &CoderConfig, - base_dir: Option<&str>, spec: CreateFileSpec, + resolved: Result, ) -> CreateFileResult { // Resolve up front: from here on every filesystem operation uses ONLY // the resolver-returned path (never re-derived from the raw request), // and the result echoes that canonical absolute path. When resolution // fails there is no canonical path, so the input is echoed verbatim. - let abs = match resolver.require_writable_opt(base_dir, &spec.path) { + let abs = match resolved { Ok(abs) => abs, Err(e) => { return CreateFileResult { @@ -144,6 +155,13 @@ fn create_one( } } +fn is_jail_scope_error(e: &CoderError) -> bool { + matches!( + e, + CoderError::OutsideBase(_) | CoderError::OutsideSession(_) + ) +} + fn try_create_one(cfg: &CoderConfig, abs: &Path, spec: CreateFileSpec) -> Result { let bytes = spec.content.as_bytes(); if (bytes.len() as u64) > cfg.max_write_bytes { @@ -221,6 +239,7 @@ mod tests { overwrite: false, }], base_dir: None, + extra_roots: None, }, ) .await @@ -257,6 +276,7 @@ mod tests { overwrite: false, }], base_dir: None, + extra_roots: None, }, ) .await @@ -281,6 +301,7 @@ mod tests { overwrite: false, }], base_dir: None, + extra_roots: None, }, ) .await @@ -310,6 +331,7 @@ mod tests { overwrite: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -336,6 +358,7 @@ mod tests { overwrite: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -345,47 +368,41 @@ mod tests { } #[tokio::test] - async fn jail_escape_reports_c215_per_item_and_batch_continues() { - // A jail escape on one entry must surface as a per-item C215 (not a - // top-level failure) and must NOT abort the rest of the batch — the - // write-side per-item jail contract that the dropped path-security - // BDD scenarios asserted. + async fn jail_escape_aborts_batch_before_any_write() { + // Jail-scope failures must be top-level call errors so the harness + // post-trigger approval hook can see the C215/C218 and hold the call. + // Preflight all paths before I/O: a later escape must not leave earlier + // entries partially written before the call is re-invoked after a grant. let (tmp, r, c) = setup(); - let out = handle( + let err = handle( r, c, CreateFileInput { files: vec![ CreateFileSpec { - path: "../escape.txt".into(), - content: "x".into(), + path: "ok.txt".into(), + content: "y".into(), mode: "0644".into(), parents: true, overwrite: false, }, CreateFileSpec { - path: "ok.txt".into(), - content: "y".into(), + path: "../escape.txt".into(), + content: "x".into(), mode: "0644".into(), parents: true, overwrite: false, }, ], base_dir: None, + extra_roots: None, }, ) .await - .unwrap(); - assert!(!out.results[0].success, "escape entry must fail"); - assert_eq!(out.results[0].error.as_ref().unwrap().code, "C215"); - assert!( - out.results[1].success, - "the in-jail entry must still be written" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("ok.txt")).unwrap(), - "y" - ); + .unwrap_err(); + let wire: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert_eq!(wire["code"], "C215"); + assert!(!tmp.path().join("ok.txt").exists()); assert!( !tmp.path().join("../escape.txt").exists(), "the escaping path must never be created" @@ -413,6 +430,7 @@ mod tests { overwrite: false, }], base_dir: None, + extra_roots: None, }, ) .await @@ -445,6 +463,7 @@ mod tests { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -474,6 +493,7 @@ mod tests { overwrite: false, }], base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/src/code/functions/delete_file.rs b/shell/src/code/functions/delete_file.rs index 7703be7fe..e332a67ae 100644 --- a/shell/src/code/functions/delete_file.rs +++ b/shell/src/code/functions/delete_file.rs @@ -29,6 +29,10 @@ pub struct DeleteFileInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } // examples are wire-contract; goldens pin them. @@ -69,10 +73,18 @@ pub async fn handle( ))); } let base_dir = req.base_dir.as_deref(); - let mut results = Vec::with_capacity(req.paths.len()); + let mut entries = Vec::with_capacity(req.paths.len()); for p in req.paths { - results.push(delete_one(&resolver, base_dir, &p, req.recursive)); + match resolver.require_writable_opt(base_dir, &p) { + Ok(abs) => entries.push((p, Ok(abs))), + Err(e) if is_jail_scope_error(&e) => return Err(err_to_string(e)), + Err(e) => entries.push((p, Err(e))), + } } + let results = entries + .into_iter() + .map(|(p, resolved)| delete_one(&resolver, base_dir, &p, req.recursive, resolved)) + .collect(); Ok(DeleteFileOutput { results }) } @@ -81,12 +93,13 @@ fn delete_one( base_dir: Option<&str>, rel: &str, recursive: bool, + resolved: Result, ) -> DeleteFileResult { // Resolve up front: deletion operates ONLY on the resolver-returned // path, and the result echoes that canonical absolute path. When // resolution fails there is no canonical path, so the caller's input // is echoed verbatim. - let abs = match resolver.require_writable_opt(base_dir, rel) { + let abs = match resolved { Ok(abs) => abs, Err(e) => { return DeleteFileResult { @@ -114,6 +127,13 @@ fn delete_one( } } +fn is_jail_scope_error(e: &CoderError) -> bool { + matches!( + e, + CoderError::OutsideBase(_) | CoderError::OutsideSession(_) + ) +} + fn try_delete_one( resolver: &PathResolver, base_dir: Option<&str>, @@ -206,6 +226,7 @@ mod tests { paths: vec!["a.txt".into()], recursive: false, base_dir: None, + extra_roots: None, }, ) .await @@ -224,6 +245,7 @@ mod tests { paths: vec!["nope.txt".into()], recursive: false, base_dir: None, + extra_roots: None, }, ) .await @@ -242,6 +264,7 @@ mod tests { paths: vec![".env".into()], recursive: false, base_dir: None, + extra_roots: None, }, ) .await @@ -262,6 +285,7 @@ mod tests { paths: vec!["d".into()], recursive: false, base_dir: None, + extra_roots: None, }, ) .await @@ -280,6 +304,7 @@ mod tests { paths: vec!["d".into()], recursive: true, base_dir: None, + extra_roots: None, }, ) .await @@ -299,6 +324,7 @@ mod tests { paths: vec!["d".into()], recursive: true, base_dir: None, + extra_roots: None, }, ) .await @@ -323,6 +349,7 @@ mod tests { paths: vec!["secrets".into()], recursive: true, base_dir: None, + extra_roots: None, }, ) .await @@ -355,6 +382,7 @@ mod tests { paths: vec![".".into()], recursive: true, base_dir: None, + extra_roots: None, }, ) .await @@ -378,6 +406,7 @@ mod tests { paths: vec![".".into()], recursive: true, base_dir: Some(session.to_string_lossy().into_owned()), + extra_roots: None, }, ) .await @@ -402,6 +431,7 @@ mod tests { paths: vec![abs.clone()], recursive: true, base_dir: Some(abs), + extra_roots: None, }, ) .await @@ -425,6 +455,7 @@ mod tests { paths: vec!["a.txt".into()], recursive: false, base_dir: Some(session.to_string_lossy().into_owned()), + extra_roots: None, }, ) .await diff --git a/shell/src/code/functions/list_folder.rs b/shell/src/code/functions/list_folder.rs index a7850c0b8..e810a5abf 100644 --- a/shell/src/code/functions/list_folder.rs +++ b/shell/src/code/functions/list_folder.rs @@ -33,6 +33,10 @@ pub struct ListFolderInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } fn default_path() -> String { @@ -236,6 +240,7 @@ mod tests { page: 1, page_size: None, base_dir: None, + extra_roots: None, }, ) .await @@ -268,6 +273,7 @@ mod tests { page: 2, page_size: Some(2), base_dir: None, + extra_roots: None, }, ) .await @@ -296,6 +302,7 @@ mod tests { page: 1, page_size: Some(9999), base_dir: None, + extra_roots: None, }, ) .await @@ -316,6 +323,7 @@ mod tests { page: 1, page_size: None, base_dir: None, + extra_roots: None, }, ) .await @@ -346,6 +354,7 @@ mod tests { page: 1, page_size: None, base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/src/code/functions/mod.rs b/shell/src/code/functions/mod.rs index 9d9143971..c28ea79a6 100644 --- a/shell/src/code/functions/mod.rs +++ b/shell/src/code/functions/mod.rs @@ -21,13 +21,10 @@ pub mod search; pub mod tree; pub mod update_file; -use std::sync::Arc; - use iii_sdk::errors::Error; use iii_sdk::{IIIClient, RegisterFunction}; -use crate::code::path::PathResolver; -use crate::code::state::ConfigCell; +use crate::code::state::CodeCells; // --------------------------------------------------------------------------- // Function ids + registration descriptions (ONE place). @@ -234,30 +231,30 @@ pub fn catalog() -> Vec { ] } -pub fn register_all(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +pub fn register_all(iii: &IIIClient, cells: CodeCells) { // DRIFT GUARD: the register_* calls below and the entries in // `catalog()` must stay 1:1 — catalog() feeds the wire-schema goldens // (tests/code_golden_schemas.rs). Adding a function to one list but not // the other trips the debug_assert below (exercised engine-free by // `tests::register_all_count_matches_catalog`). let mut registered: usize = 0; - register_info(iii, resolver.clone(), cfg.clone()); + register_info(iii, cells.clone()); registered += 1; - register_read_file(iii, resolver.clone(), cfg.clone()); + register_read_file(iii, cells.clone()); registered += 1; - register_search(iii, resolver.clone(), cfg.clone()); + register_search(iii, cells.clone()); registered += 1; - register_update_file(iii, resolver.clone(), cfg.clone()); + register_update_file(iii, cells.clone()); registered += 1; - register_create_file(iii, resolver.clone(), cfg.clone()); + register_create_file(iii, cells.clone()); registered += 1; - register_delete_file(iii, resolver.clone()); + register_delete_file(iii, cells.clone()); registered += 1; - register_list_folder(iii, resolver.clone(), cfg.clone()); + register_list_folder(iii, cells.clone()); registered += 1; - register_tree(iii, resolver.clone(), cfg.clone()); + register_tree(iii, cells.clone()); registered += 1; - register_move_file(iii, resolver); + register_move_file(iii, cells); registered += 1; debug_assert_eq!( registered, @@ -269,14 +266,14 @@ pub fn register_all(iii: &IIIClient, resolver: Arc, cfg: ConfigCel tracing::info!(count = registered, "coder registered functions"); } -fn register_info(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_info(iii: &IIIClient, cells: CodeCells) { iii.register_function( INFO_ID, RegisterFunction::new_async(move |_req: info::InfoInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let cfg = cells.config.read().await.clone(); info::handle(resolver, cfg).await.map_err(Error::from) } }) @@ -284,15 +281,16 @@ fn register_info(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) ); } -fn register_read_file(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_read_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( READ_FILE_ID, RegisterFunction::new_async(move |req: read_file::ReadFileInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); read_file::handle(resolver, cfg, req) .await .map_err(Error::from) @@ -302,15 +300,16 @@ fn register_read_file(iii: &IIIClient, resolver: Arc, cfg: ConfigC ); } -fn register_search(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_search(iii: &IIIClient, cells: CodeCells) { iii.register_function( SEARCH_ID, RegisterFunction::new_async(move |req: search::SearchInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); search::handle(resolver, cfg, req) .await .map_err(Error::from) @@ -320,15 +319,16 @@ fn register_search(iii: &IIIClient, resolver: Arc, cfg: ConfigCell ); } -fn register_update_file(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_update_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( UPDATE_FILE_ID, RegisterFunction::new_async(move |req: update_file::UpdateFileInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); update_file::handle(resolver, cfg, req) .await .map_err(Error::from) @@ -338,15 +338,16 @@ fn register_update_file(iii: &IIIClient, resolver: Arc, cfg: Confi ); } -fn register_create_file(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_create_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( CREATE_FILE_ID, RegisterFunction::new_async(move |req: create_file::CreateFileInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); create_file::handle(resolver, cfg, req) .await .map_err(Error::from) @@ -356,13 +357,15 @@ fn register_create_file(iii: &IIIClient, resolver: Arc, cfg: Confi ); } -fn register_delete_file(iii: &IIIClient, resolver: Arc) { +fn register_delete_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( DELETE_FILE_ID, RegisterFunction::new_async(move |req: delete_file::DeleteFileInput| { - let resolver = resolver.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); delete_file::handle(resolver, req) .await .map_err(Error::from) @@ -372,15 +375,16 @@ fn register_delete_file(iii: &IIIClient, resolver: Arc) { ); } -fn register_list_folder(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_list_folder(iii: &IIIClient, cells: CodeCells) { iii.register_function( LIST_FOLDER_ID, RegisterFunction::new_async(move |req: list_folder::ListFolderInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); list_folder::handle(resolver, cfg, req) .await .map_err(Error::from) @@ -390,15 +394,16 @@ fn register_list_folder(iii: &IIIClient, resolver: Arc, cfg: Confi ); } -fn register_tree(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) { +fn register_tree(iii: &IIIClient, cells: CodeCells) { iii.register_function( TREE_ID, RegisterFunction::new_async(move |req: tree::TreeInput| { - let resolver = resolver.clone(); - let cfg = cfg.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); - let cfg = cfg.read().await.clone(); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); + let cfg = cells.config.read().await.clone(); tree::handle(resolver, cfg, req).await.map_err(Error::from) } }) @@ -406,13 +411,15 @@ fn register_tree(iii: &IIIClient, resolver: Arc, cfg: ConfigCell) ); } -fn register_move_file(iii: &IIIClient, resolver: Arc) { +fn register_move_file(iii: &IIIClient, cells: CodeCells) { iii.register_function( MOVE_FILE_ID, RegisterFunction::new_async(move |req: move_file::MoveFileInput| { - let resolver = resolver.clone(); + let cells = cells.clone(); async move { - let resolver = resolver.session_scoped(req.base_dir.as_deref()); + let resolver = cells.resolver.read().await.clone(); + let resolver = + resolver.session_scoped(req.base_dir.as_deref(), req.extra_roots.as_deref()); move_file::handle(resolver, req).await.map_err(Error::from) } }) @@ -423,6 +430,7 @@ fn register_move_file(iii: &IIIClient, resolver: Arc) { #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; /// DRIFT GUARD execution: `IIIClient::new` only buffers registrations into a /// channel (no connection, no runtime needed), so `register_all` runs @@ -431,11 +439,16 @@ mod tests { #[test] fn register_all_count_matches_catalog() { use crate::code::config::CoderConfig; + use crate::code::path::PathResolver; + use crate::code::state::CodeCells; use tokio::sync::RwLock; let iii = IIIClient::new("ws://127.0.0.1:1"); let cfg = CoderConfig::default(); let resolver = Arc::new(PathResolver::new(&cfg).unwrap()); - let cell: ConfigCell = Arc::new(RwLock::new(Arc::new(cfg))); - register_all(&iii, resolver, cell); + let cells = CodeCells { + config: Arc::new(RwLock::new(Arc::new(cfg))), + resolver: Arc::new(RwLock::new(resolver)), + }; + register_all(&iii, cells); } } diff --git a/shell/src/code/functions/move_file.rs b/shell/src/code/functions/move_file.rs index 191d5f56c..3d425e946 100644 --- a/shell/src/code/functions/move_file.rs +++ b/shell/src/code/functions/move_file.rs @@ -30,6 +30,10 @@ pub struct MoveFileInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -118,6 +122,15 @@ pub async fn handle( ))); } let base_dir = req.base_dir.as_deref(); + for spec in &req.files { + for path in [&spec.from, &spec.to] { + if let Err(e) = resolver.require_writable_opt(base_dir, path) { + if is_jail_scope_error(&e) { + return Err(err_to_string(e)); + } + } + } + } let mut results = Vec::with_capacity(req.files.len()); for spec in req.files { results.push(move_one(&resolver, base_dir, spec)); @@ -125,6 +138,13 @@ pub async fn handle( Ok(MoveFileOutput { results }) } +fn is_jail_scope_error(e: &CoderError) -> bool { + matches!( + e, + CoderError::OutsideBase(_) | CoderError::OutsideSession(_) + ) +} + // --------------------------------------------------------------------------- // Per-entry logic // --------------------------------------------------------------------------- @@ -416,6 +436,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -454,6 +475,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -483,6 +505,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -533,6 +556,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -565,6 +589,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -626,6 +651,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -670,6 +696,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -708,6 +735,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -736,6 +764,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -774,6 +803,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -788,6 +818,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -832,6 +863,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -858,6 +890,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -885,6 +918,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -922,6 +956,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -959,6 +994,7 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -1000,6 +1036,7 @@ mod tests { parents: false, }], base_dir: None, + extra_roots: None, }, ) .await @@ -1038,6 +1075,7 @@ mod tests { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -1054,10 +1092,10 @@ mod tests { // Echo rules: canonical when resolved, verbatim when not // ------------------------------------------------------------------ #[tokio::test] - async fn echo_verbatim_on_resolution_failure() { + async fn jail_escape_aborts_batch_with_top_level_c215() { let (_tmp, r) = setup_single(); // A path that escapes the jail will fail resolution (C215). - let out = handle( + let err = handle( r, MoveFileInput { files: vec![MoveFileSpec { @@ -1067,16 +1105,15 @@ mod tests { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await - .unwrap(); - assert!(!out.results[0].success); - // The jail escape must surface as a per-item C215 (the write-side - // jail contract), not some other code or a swallowed success. - assert_eq!(out.results[0].error.as_ref().unwrap().code, "C215"); - // The `from` echo must be the caller's verbatim input, not a resolved path. - assert_eq!(out.results[0].from, "/etc/passwd"); + .unwrap_err(); + // Jail-scope errors must be visible to the hook as whole-call errors. + assert!(err.contains("\"code\":\"C215\"")); + assert!(err.contains("/etc/passwd")); + assert!(err.contains("grant_hint=")); } // ------------------------------------------------------------------ @@ -1106,6 +1143,7 @@ mod tests { }, ], base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/src/code/functions/read_file.rs b/shell/src/code/functions/read_file.rs index 4dac949ff..799d8259e 100644 --- a/shell/src/code/functions/read_file.rs +++ b/shell/src/code/functions/read_file.rs @@ -223,6 +223,10 @@ pub struct ReadFileInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } // examples are wire-contract; goldens pin them. @@ -419,6 +423,15 @@ fn inner( } // Batch mode (None, Some(targets)) => { + for target in targets { + if let Err(e) = + resolver.require_writable_opt(req.base_dir.as_deref(), target.path()) + { + if is_jail_scope_error(&e) { + return Err(e); + } + } + } let results = batch_read(resolver, cfg, req.base_dir.as_deref(), targets); Ok(ReadFileOutput { path: None, @@ -436,6 +449,13 @@ fn inner( } } +fn is_jail_scope_error(e: &CoderError) -> bool { + matches!( + e, + CoderError::OutsideBase(_) | CoderError::OutsideSession(_) + ) +} + // --------------------------------------------------------------------------- // Single-path mode (T7 + full reads) // --------------------------------------------------------------------------- diff --git a/shell/src/code/functions/search.rs b/shell/src/code/functions/search.rs index e2a82b06c..032138c84 100644 --- a/shell/src/code/functions/search.rs +++ b/shell/src/code/functions/search.rs @@ -93,6 +93,10 @@ pub struct SearchInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } fn default_true() -> bool { @@ -556,6 +560,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -601,6 +606,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -650,6 +656,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -681,6 +688,7 @@ mod tests { search_content: false, search_paths: true, base_dir: None, + extra_roots: None, }, ) .await @@ -713,6 +721,7 @@ mod tests { search_content: true, search_paths: true, base_dir: None, + extra_roots: None, }, ) .await @@ -751,6 +760,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -794,6 +804,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -823,6 +834,7 @@ mod tests { search_content: true, search_paths: true, base_dir: None, + extra_roots: None, }, ) .await @@ -855,6 +867,7 @@ mod tests { search_content: false, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -884,6 +897,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -913,6 +927,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -943,6 +958,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, }, ) .await @@ -977,6 +993,7 @@ mod tests { search_content: true, search_paths: false, base_dir: None, + extra_roots: None, } } diff --git a/shell/src/code/functions/tree.rs b/shell/src/code/functions/tree.rs index e5cdcb886..aef84a71a 100644 --- a/shell/src/code/functions/tree.rs +++ b/shell/src/code/functions/tree.rs @@ -50,6 +50,10 @@ pub struct TreeInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } fn default_path() -> String { @@ -349,6 +353,7 @@ mod tests { per_folder_limit: None, use_default_excludes: true, base_dir: None, + extra_roots: None, } } diff --git a/shell/src/code/functions/update_file.rs b/shell/src/code/functions/update_file.rs index 0538fcfee..04899c2c3 100644 --- a/shell/src/code/functions/update_file.rs +++ b/shell/src/code/functions/update_file.rs @@ -40,6 +40,10 @@ pub struct UpdateFileInput { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -211,24 +215,31 @@ pub async fn handle( ))); } let base_dir = req.base_dir.as_deref(); - let mut results = Vec::with_capacity(req.files.len()); + let mut entries = Vec::with_capacity(req.files.len()); for spec in req.files { - results.push(update_one(&resolver, &cfg, base_dir, spec)); + match resolver.require_writable_opt(base_dir, &spec.path) { + Ok(abs) => entries.push((spec, Ok(abs))), + Err(e) if is_jail_scope_error(&e) => return Err(err_to_string(e)), + Err(e) => entries.push((spec, Err(e))), + } } + let results = entries + .into_iter() + .map(|(spec, resolved)| update_one(&cfg, spec, resolved)) + .collect(); Ok(UpdateFileOutput { results }) } fn update_one( - resolver: &PathResolver, cfg: &CoderConfig, - base_dir: Option<&str>, spec: UpdateFileSpec, + resolved: Result, ) -> UpdateFileResult { // Resolve up front: the edit pipeline operates ONLY on the // resolver-returned path, and the result echoes that canonical // absolute path. When resolution fails there is no canonical path, // so the caller's input is echoed verbatim. - let abs = match resolver.require_writable_opt(base_dir, &spec.path) { + let abs = match resolved { Ok(abs) => abs, Err(e) => { return UpdateFileResult { @@ -265,6 +276,13 @@ fn update_one( } } +fn is_jail_scope_error(e: &CoderError) -> bool { + matches!( + e, + CoderError::OutsideBase(_) | CoderError::OutsideSession(_) + ) +} + fn try_update_one( cfg: &CoderConfig, abs: &Path, @@ -1882,12 +1900,11 @@ mod handler_tests { } #[tokio::test] - async fn jail_escape_reports_c215_per_item() { - // A path that escapes the jail must fail resolution and surface as a - // per-item C215 rather than touching anything on disk — the - // write-side jail contract for update-file. + async fn jail_escape_aborts_batch_with_top_level_c215() { + // A path that escapes the jail must fail resolution before any file + // I/O and surface as a top-level C215 for the grant hook. let (_tmp, r, c) = setup(); - let out = handle( + let err = handle( r, c, UpdateFileInput { @@ -1899,12 +1916,14 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await - .unwrap(); - assert!(!out.results[0].success); - assert_eq!(out.results[0].error.as_ref().unwrap().code, "C215"); + .unwrap_err(); + assert!(err.contains("\"code\":\"C215\"")); + assert!(err.contains("../escape.txt")); + assert!(err.contains("grant_hint=")); } #[tokio::test] @@ -1924,6 +1943,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -1957,6 +1977,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -1993,6 +2014,7 @@ mod handler_tests { ], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2024,6 +2046,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2054,6 +2077,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2081,6 +2105,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2118,6 +2143,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2163,6 +2189,7 @@ mod handler_tests { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -2200,6 +2227,7 @@ mod handler_tests { UpdateFileInput { files: vec![spec("missing.txt"), spec(".env")], base_dir: None, + extra_roots: None, }, ) .await @@ -2255,6 +2283,7 @@ mod handler_tests { ], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2275,6 +2304,7 @@ mod handler_tests { UpdateFileInput { files: vec![], base_dir: None, + extra_roots: None, }, ) .await @@ -2299,6 +2329,7 @@ mod handler_tests { ops, }], base_dir: None, + extra_roots: None, }, ) .await @@ -2660,6 +2691,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2711,6 +2743,7 @@ mod handler_tests { ], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2762,6 +2795,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2807,6 +2841,7 @@ mod handler_tests { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -2862,6 +2897,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -2915,6 +2951,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3006,6 +3043,7 @@ mod handler_tests { ], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3074,6 +3112,7 @@ mod handler_tests { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -3138,6 +3177,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3175,6 +3215,7 @@ mod handler_tests { ops: vec![op("$1a")], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3202,6 +3243,7 @@ mod handler_tests { ops: vec![op("${1}a")], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3234,6 +3276,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -3273,6 +3316,7 @@ mod handler_tests { }], }], base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/src/code/mod.rs b/shell/src/code/mod.rs index 4381b4540..8ca76706b 100644 --- a/shell/src/code/mod.rs +++ b/shell/src/code/mod.rs @@ -21,4 +21,3 @@ pub mod path; pub mod state; pub use functions::register_all; -pub use state::ConfigCell; diff --git a/shell/src/code/path.rs b/shell/src/code/path.rs index 1262fef0d..0c4ec0ce4 100644 --- a/shell/src/code/path.rs +++ b/shell/src/code/path.rs @@ -52,6 +52,7 @@ pub struct PathResolver { /// — on other entry kinds the companions would wrongly drop a file /// or symlink merely NAMED like an excluded directory. default_exclude_dirs: GlobSet, + grant_roots_canon: Vec, } /// Effective roots when `base_paths` is empty: the engine workspace cwd @@ -128,6 +129,7 @@ impl PathResolver { non_accessible, default_exclude, default_exclude_dirs, + grant_roots_canon: Vec::new(), }) } @@ -147,27 +149,47 @@ impl PathResolver { /// session root is additive and only used for calls whose request still /// carries `base_dir`, so [`resolve_in`] continues to enforce the stricter /// "all paths stay under base_dir" containment check. - pub fn session_scoped(self: &Arc, base_dir: Option<&str>) -> Arc { - let Some(base_dir) = base_dir else { - return self.clone(); - }; - let base_path = Path::new(base_dir); - if !base_path.is_absolute() { - return self.clone(); + pub fn session_scoped( + self: &Arc, + base_dir: Option<&str>, + extra_roots: Option<&[String]>, + ) -> Arc { + let mut roots_canon = self.roots_canon.clone(); + let mut grant_roots_canon = self.grant_roots_canon.clone(); + let mut changed = false; + + if let Some(base_dir) = base_dir { + let base_path = Path::new(base_dir); + if base_path.is_absolute() { + if let Ok(base_canon) = self.canonicalize_wire(base_dir, base_path) { + if base_canon.is_dir() && !roots_canon.contains(&base_canon) { + roots_canon.push(base_canon); + changed = true; + } + } + } } - let Ok(base_canon) = self.canonicalize_wire(base_dir, base_path) else { - return self.clone(); - }; - if !base_canon.is_dir() || self.roots_canon.contains(&base_canon) { + + for extra in confine_extra_roots(extra_roots) { + if !roots_canon.contains(&extra) { + roots_canon.push(extra.clone()); + changed = true; + } + if !grant_roots_canon.contains(&extra) { + grant_roots_canon.push(extra); + changed = true; + } + } + + if !changed { return self.clone(); } - let mut roots_canon = self.roots_canon.clone(); - roots_canon.push(base_canon); Arc::new(Self { roots_canon, non_accessible: self.non_accessible.clone(), default_exclude: self.default_exclude.clone(), default_exclude_dirs: self.default_exclude_dirs.clone(), + grant_roots_canon, }) } @@ -242,8 +264,9 @@ impl PathResolver { // consts are parsed back out by the recovery-pair test. return Err(CoderError::OutsideBase(format!( "path is outside every allowed root: {path}. \ - {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}", - roots = self.roots_list() + {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}{hint}", + roots = self.roots_list(), + hint = crate::grant::hint_suffix("C215", path, &canon), ))); } else { // Relative path that escaped the primary root (e.g. via `..`). @@ -251,7 +274,8 @@ impl PathResolver { return Err(CoderError::OutsideBase(format!( "path escapes the primary allowed root {primary}: {path}. \ Relative paths resolve against {primary}; \ - use an absolute path inside an allowed root instead." + use an absolute path inside an allowed root instead.{hint}", + hint = crate::grant::hint_suffix("C215", path, &canon), ))); } } @@ -334,8 +358,9 @@ impl PathResolver { // caller knows where they are allowed to work. The marker // consts are parsed back out by the recovery-pair test. CoderError::OutsideBase(format!( - "{path}: {msg}. {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}", - roots = self.roots_list() + "{path}: {msg}. {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}{hint}", + roots = self.roots_list(), + hint = crate::grant::hint_suffix("C215", path, joined), )) } else if e.kind() == std::io::ErrorKind::InvalidInput || e.kind() == std::io::ErrorKind::NotFound @@ -406,21 +431,25 @@ impl PathResolver { // "outside every allowed root" wording, which would contradict // coder::info's allowed-roots list for a path that genuinely lives // in a root. - if !canon.starts_with(&base_canon) { + if !canon.starts_with(&base_canon) + && !self.grant_roots_canon.iter().any(|r| canon.starts_with(r)) + { let base = base_canon.display(); if self.containing_root(&canon).is_some() { return Err(CoderError::OutsideSession(format!( "this session is scoped to {base}; {path} is inside an \ allowed root but outside the session directory — use a \ - path under {base}." + path under {base}.{hint}", + hint = crate::grant::hint_suffix("C218", path, &canon), ))); } // Outside the session AND outside every allowed root: the // generic C215 wording is correct and most actionable here. return Err(CoderError::OutsideBase(format!( "path is outside the session directory {base} and outside \ - every allowed root: {path}. {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}", - roots = self.roots_list() + every allowed root: {path}. {C215_ROOTS_PREFIX}{roots}. {SHELL_FS_HINT}{hint}", + roots = self.roots_list(), + hint = crate::grant::hint_suffix("C215", path, &canon), ))); } Ok(canon) @@ -464,6 +493,32 @@ impl PathResolver { } } +fn confine_extra_roots(extra_roots: Option<&[String]>) -> Vec { + let mut out = Vec::new(); + let Some(extra_roots) = extra_roots else { + return out; + }; + for raw in extra_roots { + if raw.is_empty() { + continue; + } + let p = Path::new(raw); + if !p.is_absolute() { + continue; + } + let Ok(canon) = canonicalize_with_fallback(p) else { + continue; + }; + if !canon.is_dir() { + continue; + } + if !out.iter().any(|r| r == &canon) { + out.push(canon); + } + } + out +} + fn compile_globset(patterns: &[String], key: &str) -> Result { let mut builder = GlobSetBuilder::new(); for pat in patterns { @@ -1177,7 +1232,7 @@ mod tests { "unscoped resolver must still reject a base_dir outside configured roots" ); - let scoped = r.session_scoped(Some(&base)); + let scoped = r.session_scoped(Some(&base), None); let got = scoped.resolve_in(&base, "sub/a.txt").unwrap(); assert_eq!(got, canon(&selected.path().join("sub/a.txt"))); } @@ -1192,11 +1247,36 @@ mod tests { ); let base = selected.path().display().to_string(); - let scoped = r.session_scoped(Some(&base)); + let scoped = r.session_scoped(Some(&base), None); let err = scoped.require_writable_in(&base, ".env").unwrap_err(); assert_eq!(err.code(), "C211"); } + #[test] + fn session_scoped_extra_roots_allow_absolute_grants_without_moving_relative_anchor() { + let jail = tempdir().unwrap(); + let session = jail.path().join("session"); + std::fs::create_dir(&session).unwrap(); + std::fs::write(session.join("same-name.txt"), b"session").unwrap(); + let granted = tempdir().unwrap(); + std::fs::write(granted.path().join("same-name.txt"), b"grant").unwrap(); + let r = Arc::new(PathResolver::new(&cfg_with(jail.path().to_path_buf(), vec![])).unwrap()); + let base = session.display().to_string(); + let extras = vec![granted.path().display().to_string()]; + + let scoped = r.session_scoped(Some(&base), Some(&extras)); + let absolute_grant = granted.path().join("same-name.txt").display().to_string(); + let got = scoped.resolve_in(&base, &absolute_grant).unwrap(); + assert_eq!(got, canon(&granted.path().join("same-name.txt"))); + + let relative = scoped.resolve_in(&base, "same-name.txt").unwrap(); + assert_eq!( + relative, + canon(&session.join("same-name.txt")), + "relative paths must keep anchoring at base_dir, not an extra root" + ); + } + /// A `..` escape from base_dir that lands OUTSIDE every allowed root is /// rejected (fails closed — same guarantee as resolve()). #[test] diff --git a/shell/src/code/state.rs b/shell/src/code/state.rs index bd16e5317..14dc3b43b 100644 --- a/shell/src/code/state.rs +++ b/shell/src/code/state.rs @@ -5,15 +5,23 @@ //! handler take a `read().await` and cheaply `clone()` the inner `Arc` out //! without holding the lock across its work, while a config reload //! whole-snapshot replaces the inner `Arc` under the write lock. The -//! `PathResolver` is NOT stored here — it is the security jail, built once at -//! boot and swapped only under the worker's `reload_lock` (see -//! `configuration.rs`), never per call. +//! `PathResolver` is the security jail and is hot-swapped with the matching +//! config under the worker's `reload_lock` (see `configuration.rs`). Handlers +//! clone both Arcs per call so they never hold a lock across filesystem work. use std::sync::Arc; use tokio::sync::RwLock; use crate::code::config::CoderConfig; +use crate::code::path::PathResolver; /// Hot-swappable snapshot shared with every cfg-taking code handler. pub type ConfigCell = Arc>>; +pub type ResolverCell = Arc>>; + +#[derive(Clone)] +pub struct CodeCells { + pub config: ConfigCell, + pub resolver: ResolverCell, +} diff --git a/shell/src/configuration.rs b/shell/src/configuration.rs index f14514714..5d85bb605 100644 --- a/shell/src/configuration.rs +++ b/shell/src/configuration.rs @@ -11,6 +11,8 @@ use iii_sdk::{IIIClient, RegisterFunction}; use serde_json::{json, Value}; use tokio::sync::{Mutex, RwLock}; +use crate::code::path::PathResolver; +use crate::code::state::CodeCells; use crate::config::ShellConfig; use crate::fs::host::{HostFsBackend, HostFsConfig, IiiChannelMaker}; use crate::fs::FsBackend; @@ -34,6 +36,7 @@ pub struct ShellRuntime { #[derive(Clone)] pub struct AppState { pub runtime: Arc>, + pub code_cells: Option, pub iii: IIIClient, /// Serializes hot-reloads: held across the authoritative fetch + build + swap /// so an older event's slow build can never clobber a newer applied config. @@ -43,6 +46,34 @@ pub struct AppState { pub reload_status: Arc>, } +pub fn build_code_cells(cfg: &ShellConfig) -> Result { + let (code_cfg, resolver) = build_code_snapshot(cfg)?; + Ok(CodeCells { + config: Arc::new(RwLock::new(code_cfg)), + resolver: Arc::new(RwLock::new(resolver)), + }) +} + +fn build_code_snapshot( + cfg: &ShellConfig, +) -> Result< + ( + Arc, + Arc, + ), + String, +> { + if !cfg.fs.is_jailed() { + return Err("coder surface requires a jailed fs.host_roots config".to_string()); + } + let code_cfg = Arc::new(cfg.code_resolver_config()); + let resolver = Arc::new( + PathResolver::new(&code_cfg) + .map_err(|e| format!("failed to build code PathResolver (coder::*): {e}"))?, + ); + Ok((code_cfg, resolver)) +} + /// Outcome of the most recent hot-reload attempt, exposed via `shell::config-status`. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] @@ -244,6 +275,19 @@ async fn try_get_config_value(iii: &IIIClient) -> Result, String> /// module guards against). Kept private so the reload lock is the only entry point. async fn apply_config(state: &AppState, cfg: ShellConfig) -> Result<(), String> { let new_runtime = build_runtime(&cfg, &state.iii)?; + let code_snapshot = state + .code_cells + .as_ref() + .and_then(|_| match build_code_snapshot(&cfg) { + Ok(snapshot) => Some(snapshot), + Err(e) => { + tracing::error!( + error = %e, + "keeping last-good coder config/resolver after rejected code-surface reload" + ); + None + } + }); let mut guard = state.runtime.write().await; let was_jailed = guard.config.fs.is_jailed(); let now_jailed = new_runtime.config.fs.is_jailed(); @@ -254,6 +298,11 @@ async fn apply_config(state: &AppState, cfg: ShellConfig) -> Result<(), String> ); } *guard = new_runtime; + drop(guard); + if let (Some(cells), Some((code_cfg, resolver))) = (&state.code_cells, code_snapshot) { + *cells.resolver.write().await = resolver; + *cells.config.write().await = code_cfg; + } Ok(()) } @@ -542,6 +591,7 @@ mod tests { let initial = build_runtime(&base, &iii).expect("initial runtime"); let state = AppState { runtime: Arc::new(RwLock::new(initial)), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -597,6 +647,7 @@ mod tests { base.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&base, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -634,6 +685,7 @@ mod tests { good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -670,6 +722,7 @@ mod tests { good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -717,6 +770,7 @@ mod tests { b.fs.host_roots = vec![dir_b.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&a, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -734,6 +788,46 @@ mod tests { ); } + #[tokio::test] + async fn reload_applies_coder_config_and_resolver_cells() { + let dir_a = std::env::temp_dir().join("shell-reload-code-a-9b3c"); + let dir_b = std::env::temp_dir().join("shell-reload-code-b-9b3c"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + std::fs::write(dir_b.join("new.txt"), "ok").unwrap(); + let iii = iii_sdk::register_worker("ws://127.0.0.1:59591", iii_sdk::InitOptions::default()); + let mut a = ShellConfig::default(); + a.fs.host_roots = vec![dir_a.clone()]; + let mut b = ShellConfig::default(); + b.fs.host_roots = vec![dir_b.clone()]; + let cells = build_code_cells(&a).expect("initial code cells"); + let state = AppState { + runtime: Arc::new(RwLock::new(build_runtime(&a, &iii).expect("initial"))), + code_cells: Some(cells.clone()), + iii: iii.clone(), + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + }; + + let before = cells.resolver.read().await.clone(); + assert_eq!(before.roots(), &[std::fs::canonicalize(&dir_a).unwrap()]); + + let res = reload_serialized(&state, { + let b = b.clone(); + move || async move { Ok::<_, String>(b.to_json()) } + }) + .await; + assert!(matches!(res, Ok(ReloadOutcome::Applied))); + + let after = cells.resolver.read().await.clone(); + assert_eq!(after.roots(), &[std::fs::canonicalize(&dir_b).unwrap()]); + assert!( + after.resolve("new.txt").is_ok(), + "coder resolver must see the newly reloaded shell root" + ); + assert_eq!(cells.config.read().await.base_paths, vec![dir_b]); + } + #[tokio::test] async fn reload_status_tracks_rejection_then_recovery() { // Finding 2: a rejected (unbuildable) config keeps last-good but must be @@ -746,6 +840,7 @@ mod tests { good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), @@ -826,6 +921,7 @@ mod tests { good.fs.host_roots = vec![dir.clone()]; let state = AppState { runtime: Arc::new(RwLock::new(build_runtime(&good, &iii).expect("initial"))), + code_cells: None, iii: iii.clone(), reload_lock: Arc::new(Mutex::new(())), reload_status: Arc::new(RwLock::new(ReloadStatus::default())), diff --git a/shell/src/exec/host.rs b/shell/src/exec/host.rs index 71f3098a1..b06c19c76 100644 --- a/shell/src/exec/host.rs +++ b/shell/src/exec/host.rs @@ -367,8 +367,9 @@ mod tests { let mut cfg = test_cfg(); cfg.fs.host_roots = vec![root.clone()]; - let overrides = crate::exec::policy::build_overrides(Some("workdir"), None, None, &cfg) - .expect("workdir is inside the jail"); + let overrides = + crate::exec::policy::build_overrides(Some("workdir"), None, None, None, &cfg) + .expect("workdir is inside the jail"); let out = run_to_completion(&["pwd".into()], &cfg, 5000, &overrides) .await .unwrap(); @@ -390,7 +391,7 @@ mod tests { cfg.fs.host_roots = vec![root.clone()]; let base = root.join("session").to_string_lossy().into_owned(); - let overrides = crate::exec::policy::build_overrides(None, None, Some(&base), &cfg) + let overrides = crate::exec::policy::build_overrides(None, None, Some(&base), None, &cfg) .expect("session is inside the jail"); let out = run_to_completion(&["pwd".into()], &cfg, 5000, &overrides) .await @@ -485,7 +486,7 @@ mod tests { let mut env = std::collections::BTreeMap::new(); env.insert("NODE_ENV".to_string(), "from-override".to_string()); - let overrides = crate::exec::policy::build_overrides(None, Some(&env), None, &cfg) + let overrides = crate::exec::policy::build_overrides(None, Some(&env), None, None, &cfg) .expect("NODE_ENV is allowlisted"); let out = run_to_completion( @@ -507,7 +508,7 @@ mod tests { cfg.env.allow = vec!["NODE_ENV".into()]; let mut env = std::collections::BTreeMap::new(); env.insert("SECRET_TOKEN".to_string(), "x".to_string()); - let err = crate::exec::policy::build_overrides(None, Some(&env), None, &cfg) + let err = crate::exec::policy::build_overrides(None, Some(&env), None, None, &cfg) .expect_err("non-allowlisted key must reject"); assert_eq!(err.code, "S210"); assert!( @@ -526,7 +527,7 @@ mod tests { cfg.env.allow = vec!["LD_PRELOAD".into(), "NODE_ENV".into()]; let mut env = std::collections::BTreeMap::new(); env.insert("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()); - let err = crate::exec::policy::build_overrides(None, Some(&env), None, &cfg) + let err = crate::exec::policy::build_overrides(None, Some(&env), None, None, &cfg) .expect_err("LD_PRELOAD must reject even when allowlisted"); assert_eq!(err.code, "S210"); assert!(err.message.contains("LD_PRELOAD")); @@ -539,7 +540,7 @@ mod tests { std::fs::create_dir_all(&root).unwrap(); let mut cfg = test_cfg(); cfg.fs.host_roots = vec![root.clone()]; - let err = crate::exec::policy::build_overrides(Some("../../etc"), None, None, &cfg) + let err = crate::exec::policy::build_overrides(Some("../../etc"), None, None, None, &cfg) .expect_err("escape must reject"); assert_eq!(err.code, "S215"); std::fs::remove_dir_all(&root).ok(); diff --git a/shell/src/exec/policy.rs b/shell/src/exec/policy.rs index f64a1bc16..3034d3bfd 100644 --- a/shell/src/exec/policy.rs +++ b/shell/src/exec/policy.rs @@ -218,12 +218,14 @@ fn confine_cwd( cwd: &str, host_roots_canon: &[PathBuf], base_dir_canon: Option<&std::path::Path>, + extra_roots_canon: &[PathBuf], denylist_canon: &[PathBuf], ) -> Result { let canon = crate::fs::host::confine_path_with_base_dir( cwd, host_roots_canon, base_dir_canon, + extra_roots_canon, denylist_canon, ) // FsError and ExecError carry the same { code, message } shape; the @@ -314,18 +316,21 @@ pub fn build_overrides( cwd: Option<&str>, env: Option<&BTreeMap>, base_dir: Option<&str>, + extra_roots: Option<&[String]>, cfg: &ShellConfig, ) -> Result { let (host_roots_canon, denylist_canon) = jail_inputs(cfg)?; // Resolve the session directory first: it gates the cwd confinement below // and, when no cwd is supplied, becomes the working directory itself. let base_dir_canon = confine_base_dir(base_dir, &denylist_canon)?; + let extra_roots_canon = crate::fs::host::confine_extra_roots(extra_roots, &denylist_canon); let cwd = match cwd { Some(c) => Some(confine_cwd( c, &host_roots_canon, base_dir_canon.as_deref(), + &extra_roots_canon, &denylist_canon, )?), // No explicit cwd: a session base_dir (already validated as an existing @@ -495,7 +500,7 @@ mod tests { /// four-arg `confine_cwd`. fn confine_cwd_via_cfg(cwd: &str, cfg: &ShellConfig) -> Result { let (host_roots_canon, denylist_canon) = jail_inputs(cfg)?; - confine_cwd(cwd, &host_roots_canon, None, &denylist_canon) + confine_cwd(cwd, &host_roots_canon, None, &[], &denylist_canon) } #[test] @@ -546,7 +551,7 @@ mod tests { #[test] fn build_overrides_both_none_is_empty() { let c = ShellConfig::default(); - let ov = build_overrides(None, None, None, &c).expect("ok"); + let ov = build_overrides(None, None, None, None, &c).expect("ok"); assert!(ov.is_empty()); } @@ -562,7 +567,7 @@ mod tests { let base = root.join("session").to_string_lossy().into_owned(); // cwd omitted ⇒ working dir is base_dir. - let ov = build_overrides(None, None, Some(&base), &c).expect("base_dir is valid"); + let ov = build_overrides(None, None, Some(&base), None, &c).expect("base_dir is valid"); assert_eq!( ov.cwd.as_deref(), Some(root.join("session").canonicalize().unwrap().as_path()), @@ -570,8 +575,8 @@ mod tests { ); // relative cwd anchors at base_dir, not the jail root. - let ov = - build_overrides(Some("inner"), None, Some(&base), &c).expect("inner is under base_dir"); + let ov = build_overrides(Some("inner"), None, Some(&base), None, &c) + .expect("inner is under base_dir"); assert_eq!( ov.cwd.as_deref(), Some(root.join("session/inner").canonicalize().unwrap().as_path()), @@ -590,7 +595,7 @@ mod tests { let c = cfg_jailed(&root); let outside = root.join("other").canonicalize().unwrap(); let base = root.join("session").to_string_lossy().into_owned(); - let err = build_overrides(Some(outside.to_str().unwrap()), None, Some(&base), &c) + let err = build_overrides(Some(outside.to_str().unwrap()), None, Some(&base), None, &c) .expect_err("abs cwd outside base_dir must reject"); assert_eq!(err.code, "S220", "must be the distinct base_dir code"); let session_canon = root.join("session").canonicalize().unwrap(); @@ -619,13 +624,13 @@ mod tests { std::fs::create_dir_all(selected.join("inner")).unwrap(); let c = cfg_jailed(&root); let selected_raw = selected.to_string_lossy().into_owned(); - let ov = build_overrides(None, None, Some(&selected_raw), &c) + let ov = build_overrides(None, None, Some(&selected_raw), None, &c) .expect("absolute selected base_dir is valid"); assert_eq!( ov.cwd.as_deref(), Some(selected.canonicalize().unwrap().as_path()) ); - let ov = build_overrides(Some("inner"), None, Some(&selected_raw), &c) + let ov = build_overrides(Some("inner"), None, Some(&selected_raw), None, &c) .expect("relative cwd anchors at selected base_dir"); assert_eq!( ov.cwd.as_deref(), @@ -642,7 +647,7 @@ mod tests { let root = std::env::temp_dir().join(format!("shell-policy-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).unwrap(); let c = cfg_jailed(&root); - let err = build_overrides(None, None, Some("../../etc"), &c) + let err = build_overrides(None, None, Some("../../etc"), None, &c) .expect_err("relative base_dir is not part of the trusted contract"); assert_eq!(err.code, "S210"); std::fs::remove_dir_all(&root).ok(); @@ -657,14 +662,14 @@ mod tests { let c = cfg_jailed(&root); // Omitted cwd + omitted base_dir ⇒ no cwd override (cfg.working_dir wins). - let ov = build_overrides(None, None, None, &c).expect("ok"); + let ov = build_overrides(None, None, None, None, &c).expect("ok"); assert!( ov.cwd.is_none(), "no base_dir, no cwd ⇒ None (prior behaviour)" ); // Relative cwd still anchors at the primary jail root when base_dir is absent. - let ov = build_overrides(Some("sub"), None, None, &c).expect("ok"); + let ov = build_overrides(Some("sub"), None, None, None, &c).expect("ok"); assert_eq!( ov.cwd.as_deref(), Some(root.join("sub").canonicalize().unwrap().as_path()), diff --git a/shell/src/fs/host.rs b/shell/src/fs/host.rs index 00470f2e6..49bdaaf8d 100644 --- a/shell/src/fs/host.rs +++ b/shell/src/fs/host.rs @@ -243,8 +243,13 @@ impl HostFsBackend { path_is_non_accessible(canon, &self.host_roots_canon, &self.non_accessible) } - fn is_non_accessible_scoped(&self, canon: &Path, base_dir_canon: Option<&Path>) -> bool { - let roots = access_roots(&self.host_roots_canon, base_dir_canon); + fn is_non_accessible_scoped( + &self, + canon: &Path, + base_dir_canon: Option<&Path>, + extra_roots_canon: &[PathBuf], + ) -> bool { + let roots = access_roots(&self.host_roots_canon, base_dir_canon, extra_roots_canon); path_is_non_accessible(canon, &roots, &self.non_accessible) } @@ -268,6 +273,10 @@ impl HostFsBackend { confine_base_dir(base_dir, &self.denylist_canon) } + fn confine_extra_roots(&self, extra_roots: Option<&[String]>) -> Vec { + confine_extra_roots(extra_roots, &self.denylist_canon) + } + /// base_dir-aware form of [`Self::validate_path`]. When `base_dir_canon` /// is `Some`, the path is scoped to that session directory (relative /// anchors there; an absolute path outside it is S220). When `None`, it @@ -276,18 +285,25 @@ impl HostFsBackend { &self, path: &str, base_dir_canon: Option<&Path>, + extra_roots_canon: &[PathBuf], ) -> Result { let canon = match base_dir_canon { // validate_path already applies the non_accessible gate. - None => return self.validate_path(path), + None if extra_roots_canon.is_empty() => return self.validate_path(path), + None => confine_path( + path, + &effective_roots(&self.host_roots_canon, None, extra_roots_canon), + &self.denylist_canon, + )?, Some(_) => confine_path_with_base_dir( path, &self.host_roots_canon, base_dir_canon, + extra_roots_canon, &self.denylist_canon, )?, }; - if self.is_non_accessible_scoped(&canon, base_dir_canon) { + if self.is_non_accessible_scoped(&canon, base_dir_canon, extra_roots_canon) { return Err(FsError::new( "S215", format!("path is protected (non_accessible): {path}"), @@ -310,7 +326,12 @@ impl HostFsBackend { /// (the directory `validate_path_scoped` validated against) so the /// validated and operated-on paths cannot diverge. `None` ⇒ delegates to /// [`Self::lexical_operand`] (the unchanged primary-root-anchored operand). - fn lexical_operand_scoped(&self, path: &str, base_dir_canon: Option<&Path>) -> PathBuf { + fn lexical_operand_scoped( + &self, + path: &str, + base_dir_canon: Option<&Path>, + _extra_roots_canon: &[PathBuf], + ) -> PathBuf { match base_dir_canon { None => self.lexical_operand(path), Some(base) => lexical_operand_with(path, Some(base)), @@ -369,11 +390,12 @@ pub(crate) fn confine_path( if !host_roots_canon.iter().any(|r| canon.starts_with(r)) { // Name the jail roots so a caller (human or agent) can // self-correct in one step instead of guessing paths. + let hint = crate::grant::hint_suffix("S215", path, &canon); return Err(FsError::new( "S215", format!( - "path escapes the fs jail roots [{}]: {path}", - display_roots(host_roots_canon) + "path escapes the fs jail roots [{}]: {path}{hint}", + display_roots(host_roots_canon), ), )); } @@ -416,13 +438,34 @@ fn path_is_non_accessible( false } -fn access_roots(host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>) -> Vec { - let mut roots = host_roots_canon.to_vec(); +fn access_roots( + host_roots_canon: &[PathBuf], + base_dir_canon: Option<&Path>, + extra_roots_canon: &[PathBuf], +) -> Vec { + effective_roots(host_roots_canon, base_dir_canon, extra_roots_canon) +} + +fn effective_roots( + host_roots_canon: &[PathBuf], + base_dir_canon: Option<&Path>, + extra_roots_canon: &[PathBuf], +) -> Vec { + let mut roots = if let Some(base) = base_dir_canon { + vec![base.to_path_buf()] + } else { + host_roots_canon.to_vec() + }; if let Some(base) = base_dir_canon { if !roots.iter().any(|r| r == base) { roots.push(base.to_path_buf()); } } + for extra in extra_roots_canon { + if !roots.iter().any(|r| r == extra) { + roots.push(extra.clone()); + } + } roots } @@ -498,6 +541,38 @@ fn confine_base_dir( Ok(Some(canon)) } +pub(crate) fn confine_extra_roots( + extra_roots: Option<&[String]>, + denylist_canon: &[PathBuf], +) -> Vec { + let mut out = Vec::new(); + let Some(extra_roots) = extra_roots else { + return out; + }; + for raw in extra_roots { + if raw.is_empty() { + continue; + } + let p = Path::new(raw); + if !p.is_absolute() { + continue; + } + let Ok(canon) = canonicalize_with_fallback(p) else { + continue; + }; + if !canon.is_dir() { + continue; + } + if denylist_canon.iter().any(|d| canon.starts_with(d)) { + continue; + } + if !out.iter().any(|r| r == &canon) { + out.push(canon); + } + } + out +} + /// base_dir-aware jail confinement, LAYERED on top of [`confine_path`]. When /// `base_dir_canon` is `Some`, the call is scoped to that session directory: /// relative paths anchor at `base_dir` (not the primary jail root) and the resolved path @@ -519,17 +594,26 @@ pub(crate) fn confine_path_with_base_dir( path: &str, host_roots_canon: &[PathBuf], base_dir_canon: Option<&Path>, + extra_roots_canon: &[PathBuf], denylist_canon: &[PathBuf], ) -> Result { let Some(base) = base_dir_canon else { - // No session scope: identical to the unscoped jail check. - return confine_path(path, host_roots_canon, denylist_canon); + // No session scope: identical to the unscoped jail check unless the + // harness stamped grants, in which case those become additional roots. + if extra_roots_canon.is_empty() { + return confine_path(path, host_roots_canon, denylist_canon); + } + return confine_path( + path, + &effective_roots(host_roots_canon, None, extra_roots_canon), + denylist_canon, + ); }; // Scope the confinement to base_dir: relative paths anchor at base_dir and // the canonical result must start_with(base_dir). Since base_dir ⊆ some // allowed root (validated by confine_base_dir), this is strictly tighter // than the host-roots check. base_dir becomes the sole effective root. - let scoped_roots = [base.to_path_buf()]; + let scoped_roots = effective_roots(host_roots_canon, Some(base), extra_roots_canon); match confine_path(path, &scoped_roots, denylist_canon) { Ok(canon) => Ok(canon), Err(e) => { @@ -542,8 +626,11 @@ pub(crate) fn confine_path_with_base_dir( if e.code == "S215" && Path::new(path).is_absolute() { if let Ok(canon) = canonicalize_with_fallback(Path::new(path)) { let inside_jail_root = host_roots_canon.iter().any(|hr| canon.starts_with(hr)); + let inside_extra_root = extra_roots_canon.iter().any(|r| canon.starts_with(r)); let denied = denylist_canon.iter().any(|d| canon.starts_with(d)); - if inside_jail_root && !canon.starts_with(base) && !denied { + if inside_jail_root && !canon.starts_with(base) && !inside_extra_root && !denied + { + let hint = crate::grant::hint_suffix("S220", path, &canon); return Err(FsError::new( "S220", format!( @@ -551,7 +638,7 @@ pub(crate) fn confine_path_with_base_dir( root but outside the session directory — use a path under {}", base.display(), base.display() - ), + ) + &hint, )); } } @@ -858,7 +945,8 @@ impl FsBackend for HostFsBackend { // containment ceiling to the session directory; None ⇒ the unchanged // configured jail. let base = self.confine_base_dir(req.base_dir.as_deref())?; - let p = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let p = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; // The symlink_metadata stat, read_dir, and the per-entry // symlink_metadata loop are all blocking std::fs work that scales with // directory size; move it off the executor (mirrors grep/sed). @@ -892,7 +980,8 @@ impl FsBackend for HostFsBackend { async fn stat(&self, req: StatArgs) -> FsCallResult { let base = self.confine_base_dir(req.base_dir.as_deref())?; - let p = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let p = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; let md = std::fs::symlink_metadata(&p).map_err(|e| FsError::from_io(&req.path, e))?; let name = p .file_name() @@ -903,7 +992,8 @@ impl FsBackend for HostFsBackend { async fn mkdir(&self, req: crate::fs::MkdirArgs) -> FsCallResult { let base = self.confine_base_dir(req.base_dir.as_deref())?; - let p = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let p = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; let bits = crate::fs::error::parse_mode(&req.mode)?; check_special_bits(bits, self.cfg.allow_special_bits)?; if p.exists() { @@ -954,8 +1044,9 @@ impl FsBackend for HostFsBackend { // validation and the operand anchor at the session base_dir when set, // so they cannot diverge. let base = self.confine_base_dir(req.base_dir.as_deref())?; - self.validate_path_scoped(&req.path, base.as_deref())?; - let p = self.lexical_operand_scoped(&req.path, base.as_deref()); + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; + let p = self.lexical_operand_scoped(&req.path, base.as_deref(), &extra); // The symlink_metadata stat, recursive remove_dir_all, the non-recursive // read_dir emptiness probe, and the unlink are all blocking std::fs work @@ -998,8 +1089,9 @@ impl FsBackend for HostFsBackend { // Jail validation + mode parsing run here, on the async fn, BEFORE the // blocking work. base_dir (when set) scopes the validation + operand. let base = self.confine_base_dir(req.base_dir.as_deref())?; - self.validate_path_scoped(&req.path, base.as_deref())?; - let p = self.lexical_operand_scoped(&req.path, base.as_deref()); + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; + let p = self.lexical_operand_scoped(&req.path, base.as_deref(), &extra); let bits = crate::fs::error::parse_mode(&req.mode)?; check_special_bits(bits, self.cfg.allow_special_bits)?; @@ -1075,10 +1167,11 @@ impl FsBackend for HostFsBackend { async fn mv(&self, req: crate::fs::MvArgs) -> FsCallResult { // A single session base_dir scopes BOTH operands. let base = self.confine_base_dir(req.base_dir.as_deref())?; - self.validate_path_scoped(&req.src, base.as_deref())?; - self.validate_path_scoped(&req.dst, base.as_deref())?; - let src_p = self.lexical_operand_scoped(&req.src, base.as_deref()); - let dst_p = self.lexical_operand_scoped(&req.dst, base.as_deref()); + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + self.validate_path_scoped(&req.src, base.as_deref(), &extra)?; + self.validate_path_scoped(&req.dst, base.as_deref(), &extra)?; + let src_p = self.lexical_operand_scoped(&req.src, base.as_deref(), &extra); + let dst_p = self.lexical_operand_scoped(&req.dst, base.as_deref(), &extra); if !src_p.exists() { return Err(FsError::new("S211", format!("src not found: {}", req.src))); } @@ -1135,7 +1228,8 @@ impl FsBackend for HostFsBackend { } async fn grep(&self, req: crate::fs::GrepArgs) -> FsCallResult { let base = self.confine_base_dir(req.base_dir.as_deref())?; - let root = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let root = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; // Cap before compiling: an unbounded pattern stalls compilation and // pins memory. check_pattern_len(&req.pattern)?; @@ -1172,7 +1266,7 @@ impl FsBackend for HostFsBackend { // D4: skip protected files during a directory walk (the gate that // validate_path_scoped applies to single-file/other ops). let host_roots_canon = self.host_roots_canon.clone(); - let access_roots = access_roots(&host_roots_canon, base.as_deref()); + let access_roots = access_roots(&host_roots_canon, base.as_deref(), &extra); let non_accessible = self.non_accessible.clone(); let join = tokio::task::spawn_blocking(move || -> Result<_, FsError> { @@ -1434,7 +1528,12 @@ impl FsBackend for HostFsBackend { // blocking closure confines + anchors every operand to it instead of the // global jail roots. None ⇒ unchanged jail behaviour. let base_dir_canon = self.confine_base_dir(req.base_dir.as_deref())?; - let access_roots = access_roots(&host_roots_canon, base_dir_canon.as_deref()); + let extra_roots_canon = self.confine_extra_roots(req.extra_roots.as_deref()); + let access_roots = access_roots( + &host_roots_canon, + base_dir_canon.as_deref(), + &extra_roots_canon, + ); // Per-file read cap: sed builds a same-size output String in memory, so // an unbounded file is an OOM vector (grep skips binary + bounds its // line read; sed did neither). Honor the backend's max_read_bytes; a 0 @@ -1459,6 +1558,7 @@ impl FsBackend for HostFsBackend { &root, &host_roots_canon, base_dir_canon.as_deref(), + &extra_roots_canon, &denylist_canon, )?; let anchor = base_dir_canon @@ -1496,6 +1596,7 @@ impl FsBackend for HostFsBackend { f, &host_roots_canon, base_dir_canon.as_deref(), + &extra_roots_canon, &denylist_canon, )?; // D4: a protected file is locked for modification, exactly like @@ -1680,7 +1781,8 @@ impl FsBackend for HostFsBackend { } async fn write(&self, req: crate::fs::WriteArgs) -> FsCallResult { let base = self.confine_base_dir(req.base_dir.as_deref())?; - let p = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let p = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; let bits = crate::fs::error::parse_mode(&req.mode)?; check_special_bits(bits, self.cfg.allow_special_bits)?; @@ -1690,14 +1792,13 @@ impl FsBackend for HostFsBackend { // so we keep the belt. The ceiling is the session base_dir when set, // else ALL host roots — so parents can never climb out of the session // directory (or, unscoped, out of every allowed root). - let parent_ceilings: Vec<&Path> = match base.as_deref() { - Some(b) => vec![b], - None => self.host_roots_canon.iter().map(PathBuf::as_path).collect(), - }; + let parent_ceilings = effective_roots(&self.host_roots_canon, base.as_deref(), &extra); if req.parents { if let Some(parent) = p.parent() { if !parent_ceilings.is_empty() - && !parent_ceilings.iter().any(|c| parent.starts_with(c)) + && !parent_ceilings + .iter() + .any(|c| parent.starts_with(c.as_path())) { let ceil_display = parent_ceilings .iter() @@ -1828,7 +1929,8 @@ impl FsBackend for HostFsBackend { use std::os::unix::fs::PermissionsExt; let base = self.confine_base_dir(req.base_dir.as_deref())?; - let p = self.validate_path_scoped(&req.path, base.as_deref())?; + let extra = self.confine_extra_roots(req.extra_roots.as_deref()); + let p = self.validate_path_scoped(&req.path, base.as_deref(), &extra)?; let md = tokio::fs::symlink_metadata(&p) .await .map_err(|e| FsError::from_io(&req.path, e))?; @@ -2092,6 +2194,77 @@ mod tests { ); } + #[test] + fn jail_escape_appends_machine_parseable_grant_hint_tail() { + let root = tmp(); + let outside = tmp(); + let outside_file = outside.join("nested/file.txt"); + fs::create_dir_all(outside_file.parent().unwrap()).unwrap(); + fs::write(&outside_file, "x").unwrap(); + let cfg = HostFsConfig { + host_roots: vec![root], + ..Default::default() + }; + let h = stub_backend(cfg); + + let err = h + .validate_path(&outside_file.display().to_string()) + .unwrap_err(); + assert_eq!(err.code, "S215"); + let marker = "grant_hint="; + let idx = err + .message + .rfind(marker) + .expect("jail rejection must append grant_hint"); + let hint: serde_json::Value = serde_json::from_str(&err.message[idx + marker.len()..]) + .expect("grant_hint tail must be valid JSON"); + assert_eq!(hint["v"], 1); + assert_eq!(hint["code"], "S215"); + assert_eq!(hint["path"], outside_file.display().to_string()); + assert_eq!( + hint["dir"], + std::fs::canonicalize(outside_file.parent().unwrap()) + .unwrap() + .display() + .to_string() + ); + } + + #[tokio::test] + async fn extra_roots_allow_absolute_path_outside_session_scope() { + let root = tmp(); + let session = root.join("session"); + fs::create_dir(&session).unwrap(); + let granted = tmp(); + fs::write(granted.join("allowed.txt"), "ok").unwrap(); + let cfg = HostFsConfig { + host_roots: vec![root], + ..Default::default() + }; + let h = stub_backend(cfg); + let path = granted.join("allowed.txt").display().to_string(); + + let denied = h + .stat(crate::fs::StatArgs { + path: path.clone(), + base_dir: Some(session.display().to_string()), + extra_roots: None, + }) + .await + .unwrap_err(); + assert_eq!(denied.code, "S215"); + + let allowed = h + .stat(crate::fs::StatArgs { + path, + base_dir: Some(session.display().to_string()), + extra_roots: Some(vec![granted.display().to_string()]), + }) + .await + .unwrap(); + assert_eq!(allowed.0.name, "allowed.txt"); + } + #[test] fn empty_path_rejected_when_unjailed() { let h = stub_backend(HostFsConfig::default()); @@ -2114,6 +2287,7 @@ mod tests { let res = b .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: "victim.txt".into(), recursive: false, }) @@ -2135,6 +2309,7 @@ mod tests { let res = b .mv(crate::fs::MvArgs { base_dir: None, + extra_roots: None, src: "a.txt".into(), dst: "b.txt".into(), overwrite: false, @@ -2159,6 +2334,7 @@ mod tests { let res = b .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: "f.txt".into(), mode: "0600".into(), uid: None, @@ -2188,6 +2364,7 @@ mod tests { let res = b .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec!["s.txt".into()], path: None, recursive: true, @@ -2246,6 +2423,7 @@ mod tests { let err = b .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![".env".into()], path: None, recursive: true, @@ -2290,6 +2468,7 @@ mod tests { max_matches: 0, max_line_bytes: 0, base_dir: None, + extra_roots: None, }) .await .unwrap(); @@ -2314,6 +2493,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "/etc/shell-escape/nested".into(), mode: "0644".into(), parents: true, @@ -2398,6 +2578,7 @@ mod tests { let resp = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), }) .await @@ -2414,6 +2595,7 @@ mod tests { let err = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/nope/never/exists/iii".into(), }) .await @@ -2430,6 +2612,7 @@ mod tests { let resp = h .stat(StatArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), }) .await @@ -2447,6 +2630,7 @@ mod tests { let err = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), }) .await @@ -2465,6 +2649,7 @@ mod tests { let resp = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), }) .await @@ -2486,6 +2671,7 @@ mod tests { let resp = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), }) .await @@ -2504,6 +2690,7 @@ mod tests { let resp = h .ls(LsArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), }) .await @@ -2520,6 +2707,7 @@ mod tests { let resp = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: p.to_str().unwrap().into(), mode: "0755".into(), parents: false, @@ -2537,6 +2725,7 @@ mod tests { let err = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), mode: "0755".into(), parents: false, @@ -2553,6 +2742,7 @@ mod tests { let resp = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), mode: "0755".into(), parents: true, @@ -2569,6 +2759,7 @@ mod tests { let err = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: root.join("x").to_str().unwrap().into(), mode: "garbage".into(), parents: false, @@ -2587,6 +2778,7 @@ mod tests { let resp = h .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), recursive: false, }) @@ -2602,6 +2794,7 @@ mod tests { let err = h .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: "/nope/never/iii-rm-test".into(), recursive: false, }) @@ -2620,6 +2813,7 @@ mod tests { let err = h .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: sub.to_str().unwrap().into(), recursive: false, }) @@ -2640,6 +2834,7 @@ mod tests { let resp = h .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: link.to_str().unwrap().into(), recursive: false, }) @@ -2658,6 +2853,7 @@ mod tests { let resp = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: deep.to_str().unwrap().into(), mode: "0755".into(), parents: true, @@ -2681,6 +2877,7 @@ mod tests { let resp = h .rm(crate::fs::RmArgs { base_dir: None, + extra_roots: None, path: tree.to_str().unwrap().into(), recursive: true, }) @@ -2699,6 +2896,7 @@ mod tests { let resp = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), mode: "0600".into(), uid: None, @@ -2718,6 +2916,7 @@ mod tests { let err = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: "/nope/never/iii-chmod".into(), mode: "0600".into(), uid: None, @@ -2738,6 +2937,7 @@ mod tests { let err = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), mode: "garbage".into(), uid: None, @@ -2760,6 +2960,7 @@ mod tests { let resp = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: tree.to_str().unwrap().into(), mode: "0700".into(), uid: None, @@ -2787,6 +2988,7 @@ mod tests { let resp = h .mv(crate::fs::MvArgs { base_dir: None, + extra_roots: None, src: a.to_str().unwrap().into(), dst: b.to_str().unwrap().into(), overwrite: false, @@ -2805,6 +3007,7 @@ mod tests { let err = h .mv(crate::fs::MvArgs { base_dir: None, + extra_roots: None, src: root.join("nope").to_str().unwrap().into(), dst: root.join("dst").to_str().unwrap().into(), overwrite: false, @@ -2826,6 +3029,7 @@ mod tests { let err = h .mv(crate::fs::MvArgs { base_dir: None, + extra_roots: None, src: a.to_str().unwrap().into(), dst: b.to_str().unwrap().into(), overwrite: false, @@ -2847,6 +3051,7 @@ mod tests { let resp = h .mv(crate::fs::MvArgs { base_dir: None, + extra_roots: None, src: a.to_str().unwrap().into(), dst: b.to_str().unwrap().into(), overwrite: true, @@ -2867,6 +3072,7 @@ mod tests { let resp = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: f.to_str().unwrap().into(), mode: "0644".into(), uid: Some(0), @@ -2887,6 +3093,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "rel/path".into(), mode: "0644".into(), parents: false, @@ -2903,6 +3110,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "/tmp/shell-write-bad-mode".into(), mode: "not-octal".into(), parents: false, @@ -2924,6 +3132,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "/etc/shell-escape".into(), mode: "0644".into(), parents: false, @@ -2948,6 +3157,7 @@ mod tests { let resp = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "hello.txt".into(), mode: "0644".into(), parents: false, @@ -2982,6 +3192,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "big.txt".into(), mode: "0644".into(), parents: false, @@ -3007,6 +3218,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "../../etc/evil".into(), mode: "0644".into(), parents: false, @@ -3029,6 +3241,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: target, mode: "0644".into(), parents: false, @@ -3052,6 +3265,7 @@ mod tests { let err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: missing, }) .await @@ -3071,6 +3285,7 @@ mod tests { let err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: dir, }) .await @@ -3092,6 +3307,7 @@ mod tests { let err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: f.to_string_lossy().to_string(), }) .await @@ -3110,6 +3326,7 @@ mod tests { let err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: "/etc/shell-escape".into(), }) .await @@ -3130,6 +3347,7 @@ mod tests { let err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: f.to_string_lossy().to_string(), }) .await @@ -3145,6 +3363,7 @@ mod tests { let resp = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "alpha".into(), recursive: true, @@ -3168,6 +3387,7 @@ mod tests { let err = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "x".into(), recursive: false, @@ -3190,6 +3410,7 @@ mod tests { let err = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "[unclosed".into(), recursive: true, @@ -3212,6 +3433,7 @@ mod tests { let resp = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "x".into(), recursive: true, @@ -3236,6 +3458,7 @@ mod tests { let resp = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "x".into(), recursive: true, @@ -3259,6 +3482,7 @@ mod tests { let resp = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "match".into(), recursive: true, @@ -3283,6 +3507,7 @@ mod tests { let resp = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3311,6 +3536,7 @@ mod tests { let resp = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3334,6 +3560,7 @@ mod tests { let err = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![], path: None, recursive: false, @@ -3357,6 +3584,7 @@ mod tests { let err = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec!["/x".into()], path: Some(root.to_str().unwrap().into()), recursive: true, @@ -3380,6 +3608,7 @@ mod tests { let err = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![], path: Some(root.to_str().unwrap().into()), recursive: false, @@ -3405,6 +3634,7 @@ mod tests { let resp = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3437,6 +3667,7 @@ mod tests { let resp = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3504,6 +3735,7 @@ mod tests { let err = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: "c.txt".into(), mode: "4755".into(), uid: None, @@ -3535,6 +3767,7 @@ mod tests { let resp = h .chmod(crate::fs::ChmodArgs { base_dir: None, + extra_roots: None, path: "c.txt".into(), mode: "4755".into(), uid: None, @@ -3559,6 +3792,7 @@ mod tests { let err = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: "newdir".into(), mode: "2755".into(), parents: false, @@ -3582,6 +3816,7 @@ mod tests { let resp = h .mkdir(crate::fs::MkdirArgs { base_dir: None, + extra_roots: None, path: "newdir".into(), mode: "2755".into(), parents: false, @@ -3608,6 +3843,7 @@ mod tests { let err = b .write(crate::fs::WriteArgs { base_dir: None, + extra_roots: None, path: "f.txt".into(), mode: "1644".into(), parents: false, @@ -3632,6 +3868,7 @@ mod tests { let err = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: huge, recursive: true, @@ -3658,6 +3895,7 @@ mod tests { let resp = h .grep(crate::fs::GrepArgs { base_dir: None, + extra_roots: None, path: root.to_str().unwrap().into(), pattern: "alpha".into(), recursive: true, @@ -3684,6 +3922,7 @@ mod tests { let err = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3718,6 +3957,7 @@ mod tests { let err = h .sed(crate::fs::SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_str().unwrap().into()], path: None, recursive: false, @@ -3759,6 +3999,7 @@ mod tests { let read_err = b .read(crate::fs::ReadArgs { base_dir: None, + extra_roots: None, path: "escape/hostname".into(), }) .await @@ -3768,6 +4009,7 @@ mod tests { let stat_err = b .stat(StatArgs { base_dir: None, + extra_roots: None, path: "escape/hostname".into(), }) .await @@ -3777,6 +4019,7 @@ mod tests { let ls_err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "escape".into(), }) .await @@ -3810,6 +4053,7 @@ mod tests { parents: false, content: crate::fs::WriteContent::Inline("scoped\n".into()), base_dir: Some(base), + extra_roots: None, }) .await .expect("relative write under base_dir succeeds"); @@ -3838,6 +4082,7 @@ mod tests { path: "victim.txt".into(), recursive: false, base_dir: Some(base), + extra_roots: None, }) .await .expect("rm under base_dir succeeds"); @@ -3866,6 +4111,7 @@ mod tests { dst: "b.txt".into(), overwrite: false, base_dir: Some(base), + extra_roots: None, }) .await .expect("mv under base_dir succeeds"); @@ -3896,6 +4142,7 @@ mod tests { .read(crate::fs::ReadArgs { path: abs.to_string_lossy().into_owned(), base_dir: Some(base), + extra_roots: None, }) .await .expect_err("abs path outside base_dir must reject"); @@ -3927,6 +4174,7 @@ mod tests { .ls(LsArgs { path: ".".into(), base_dir: Some(selected.join("project").to_string_lossy().into_owned()), + extra_roots: None, }) .await .expect("selected base_dir outside host roots should be honored"); @@ -3948,6 +4196,7 @@ mod tests { .read(crate::fs::ReadArgs { path: ".env".into(), base_dir: Some(selected.to_string_lossy().into_owned()), + extra_roots: None, }) .await .expect_err("protected file under selected base_dir must stay locked"); @@ -3962,6 +4211,7 @@ mod tests { .ls(LsArgs { path: ".".into(), base_dir: Some("../../etc".into()), + extra_roots: None, }) .await .expect_err("relative base_dir is not part of the trusted contract"); @@ -3981,6 +4231,7 @@ mod tests { .read(crate::fs::ReadArgs { path: "/etc/hostname".into(), base_dir: Some(base), + extra_roots: None, }) .await .expect_err("abs path outside the jail must reject"); @@ -4000,6 +4251,7 @@ mod tests { parents: false, content: crate::fs::WriteContent::Inline("legacy\n".into()), base_dir: None, + extra_roots: None, }) .await .expect("relative write with base_dir=None anchors at the jail root"); diff --git a/shell/src/fs/mod.rs b/shell/src/fs/mod.rs index 024e8875f..73dd4b87b 100644 --- a/shell/src/fs/mod.rs +++ b/shell/src/fs/mod.rs @@ -77,6 +77,8 @@ pub struct LsArgs { pub path: String, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -84,6 +86,8 @@ pub struct StatArgs { pub path: String, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -95,6 +99,8 @@ pub struct MkdirArgs { pub parents: bool, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -104,6 +110,8 @@ pub struct RmArgs { pub recursive: bool, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -118,6 +126,8 @@ pub struct ChmodArgs { pub recursive: bool, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -128,6 +138,8 @@ pub struct MvArgs { pub overwrite: bool, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -148,6 +160,8 @@ pub struct GrepArgs { pub max_line_bytes: u64, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } fn default_max_matches() -> u64 { 10_000 @@ -184,6 +198,8 @@ pub struct SedArgs { pub ignore_case: bool, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } fn default_true() -> bool { true @@ -205,6 +221,7 @@ pub struct WriteArgs { pub parents: bool, pub content: WriteContent, pub base_dir: Option, + pub extra_roots: Option>, } #[derive(Debug, Deserialize)] @@ -212,6 +229,8 @@ pub struct ReadArgs { pub path: String, #[serde(default)] pub base_dir: Option, + #[serde(default)] + pub extra_roots: Option>, } // Registration-boundary requests — each carries `target` plus the matching @@ -230,6 +249,10 @@ pub struct LsRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl LsRequest { pub fn split(self) -> (Target, LsArgs) { @@ -238,6 +261,7 @@ impl LsRequest { LsArgs { path: self.path, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -254,6 +278,10 @@ pub struct StatRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl StatRequest { pub fn split(self) -> (Target, StatArgs) { @@ -262,6 +290,7 @@ impl StatRequest { StatArgs { path: self.path, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -284,6 +313,10 @@ pub struct MkdirRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl MkdirRequest { pub fn split(self) -> (Target, MkdirArgs) { @@ -294,6 +327,7 @@ impl MkdirRequest { mode: self.mode, parents: self.parents, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -313,6 +347,10 @@ pub struct RmRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl RmRequest { pub fn split(self) -> (Target, RmArgs) { @@ -322,6 +360,7 @@ impl RmRequest { path: self.path, recursive: self.recursive, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -349,6 +388,10 @@ pub struct ChmodRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl ChmodRequest { pub fn split(self) -> (Target, ChmodArgs) { @@ -361,6 +404,7 @@ impl ChmodRequest { gid: self.gid, recursive: self.recursive, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -382,6 +426,10 @@ pub struct MvRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl MvRequest { pub fn split(self) -> (Target, MvArgs) { @@ -392,6 +440,7 @@ impl MvRequest { dst: self.dst, overwrite: self.overwrite, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -428,6 +477,10 @@ pub struct GrepRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl GrepRequest { pub fn split(self) -> (Target, GrepArgs) { @@ -443,6 +496,7 @@ impl GrepRequest { max_matches: self.max_matches, max_line_bytes: self.max_line_bytes, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -485,6 +539,10 @@ pub struct SedRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl SedRequest { pub fn split(self) -> (Target, SedArgs) { @@ -502,6 +560,7 @@ impl SedRequest { first_only: self.first_only, ignore_case: self.ignore_case, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } @@ -578,6 +637,10 @@ pub struct WriteRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl WriteRequest { /// Normalize into `(target, specs, is_batch)`. `is_batch` is true when the @@ -609,6 +672,7 @@ impl WriteRequest { // base_dir is a per-CALL session scope, not a per-file attribute, so // every batch entry shares the request-level base_dir. let base_dir = self.base_dir; + let extra_roots = self.extra_roots; let specs = files .into_iter() .map(|f| WriteArgs { @@ -617,6 +681,7 @@ impl WriteRequest { parents: f.parents, content: f.content.into(), base_dir: base_dir.clone(), + extra_roots: extra_roots.clone(), }) .collect(); Ok((self.target, specs, true)) @@ -638,6 +703,7 @@ impl WriteRequest { parents: self.parents.unwrap_or(false), content: content.into(), base_dir: self.base_dir, + extra_roots: self.extra_roots, }], false, )) @@ -656,6 +722,10 @@ pub struct ReadRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, } impl ReadRequest { pub fn split(self) -> (Target, ReadArgs) { @@ -664,6 +734,7 @@ impl ReadRequest { ReadArgs { path: self.path, base_dir: self.base_dir, + extra_roots: self.extra_roots, }, ) } diff --git a/shell/src/fs/sandbox.rs b/shell/src/fs/sandbox.rs index e207d6764..59f76216a 100644 --- a/shell/src/fs/sandbox.rs +++ b/shell/src/fs/sandbox.rs @@ -196,6 +196,7 @@ mod tests { let resp = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/x".into(), }) .await @@ -220,6 +221,7 @@ mod tests { let err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/x".into(), }) .await @@ -237,6 +239,7 @@ mod tests { let err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/x".into(), }) .await @@ -254,6 +257,7 @@ mod tests { let err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/x".into(), }) .await @@ -277,6 +281,7 @@ mod tests { let resp = b .write(WriteArgs { base_dir: None, + extra_roots: None, path: "/sb/x".into(), mode: "0644".into(), parents: false, @@ -307,6 +312,7 @@ mod tests { let err = b .write(WriteArgs { base_dir: None, + extra_roots: None, path: "/sb/x".into(), mode: "0644".into(), parents: false, @@ -338,6 +344,7 @@ mod tests { let resp = b .read(ReadArgs { base_dir: None, + extra_roots: None, path: "/sb/y".into(), }) .await @@ -373,6 +380,7 @@ mod tests { let err = b .rm(RmArgs { base_dir: None, + extra_roots: None, path: "/x".into(), recursive: false, }) @@ -401,6 +409,7 @@ mod tests { let err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/x".into(), }) .await @@ -419,6 +428,7 @@ mod tests { let _ = b .grep(GrepArgs { base_dir: None, + extra_roots: None, path: "/x".into(), pattern: "p".into(), recursive: true, diff --git a/shell/src/functions/exec.rs b/shell/src/functions/exec.rs index be7cf0b00..f7848a297 100644 --- a/shell/src/functions/exec.rs +++ b/shell/src/functions/exec.rs @@ -38,8 +38,14 @@ pub async fn handle( // target so the harness-injected session dir does not surface as a host cwd // override and get rejected (see `base_dir_for_target`). let base_dir = base_dir_for_target(&req.target, req.base_dir.as_deref()); - let mut overrides = build_overrides(req.cwd.as_deref(), req.env.as_ref(), base_dir, &cfg) - .map_err(iii_sdk::errors::Error::from)?; + let mut overrides = build_overrides( + req.cwd.as_deref(), + req.env.as_ref(), + base_dir, + req.extra_roots.as_deref(), + &cfg, + ) + .map_err(iii_sdk::errors::Error::from)?; // stdin needs no gating (opaque input bytes); it is host-only, enforced by // the sandbox backend's is_empty() rejection of any populated override. overrides.stdin = req.stdin; diff --git a/shell/src/functions/exec_bg.rs b/shell/src/functions/exec_bg.rs index 5fae5cc81..fbd37043c 100644 --- a/shell/src/functions/exec_bg.rs +++ b/shell/src/functions/exec_bg.rs @@ -48,8 +48,14 @@ pub async fn handle( // target so the harness-injected session dir does not surface as a host cwd // override and trip the host-only rejection below (see `base_dir_for_target`). let base_dir = base_dir_for_target(&req.target, req.base_dir.as_deref()); - let mut overrides = build_overrides(req.cwd.as_deref(), req.env.as_ref(), base_dir, &cfg) - .map_err(|e| format!("{}: {}", e.code, e.message))?; + let mut overrides = build_overrides( + req.cwd.as_deref(), + req.env.as_ref(), + base_dir, + req.extra_roots.as_deref(), + &cfg, + ) + .map_err(|e| format!("{}: {}", e.code, e.message))?; // stdin needs no gating (opaque input bytes); host-only via is_empty(). overrides.stdin = req.stdin; diff --git a/shell/src/functions/types.rs b/shell/src/functions/types.rs index 6aca08af0..fa7262594 100644 --- a/shell/src/functions/types.rs +++ b/shell/src/functions/types.rs @@ -117,6 +117,10 @@ pub struct ExecRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, /// Optional per-call environment values (host target only). A key may be /// set ONLY if the operator listed it in `env.allow`, and NEVER for an /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup @@ -167,6 +171,10 @@ pub struct ExecBgRequest { #[serde(default)] #[schemars(skip)] pub base_dir: Option, + /// Internal harness-granted roots; omitted from published schema. + #[serde(default)] + #[schemars(skip)] + pub extra_roots: Option>, /// Optional per-call environment values (host target only). Same gating as /// [`ExecRequest::env`]: a key must be in `env.allow` and must not be an /// exec-hijacking key (PATH, IFS, HOME, LD_*/DYLD_*, and other loader/lookup diff --git a/shell/src/grant.rs b/shell/src/grant.rs new file mode 100644 index 000000000..7765ca701 --- /dev/null +++ b/shell/src/grant.rs @@ -0,0 +1,68 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrantHint { + pub v: u8, + pub dir: String, + pub path: String, + pub code: String, +} + +impl GrantHint { + pub fn new(code: &str, raw_path: &str, resolved_path: &Path) -> Self { + Self { + v: 1, + dir: nearest_existing_dir(resolved_path).display().to_string(), + path: raw_path.to_string(), + code: code.to_string(), + } + } + + pub fn suffix(&self) -> String { + let json = serde_json::to_string(self).expect("GrantHint serializes"); + format!(" grant_hint={json}") + } +} + +pub fn hint_suffix(code: &str, raw_path: &str, resolved_path: &Path) -> String { + GrantHint::new(code, raw_path, resolved_path).suffix() +} + +fn nearest_existing_dir(path: &Path) -> PathBuf { + if path.is_dir() { + return path.to_path_buf(); + } + for ancestor in path.ancestors().skip(1) { + if ancestor.is_dir() { + return ancestor.to_path_buf(); + } + } + path.parent().unwrap_or(path).to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nearest_existing_dir_uses_parent_for_file() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("dir/file.txt"); + std::fs::create_dir(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "x").unwrap(); + + assert_eq!(nearest_existing_dir(&file), file.parent().unwrap()); + } + + #[test] + fn nearest_existing_dir_uses_existing_ancestor_for_missing_tail() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("dir"); + std::fs::create_dir(&dir).unwrap(); + let missing = dir.join("missing/file.txt"); + + assert_eq!(nearest_existing_dir(&missing), dir); + } +} diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 77b56d0d1..d32d4ba8a 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -9,6 +9,7 @@ pub mod exec; pub mod exec_dispatch; pub mod fs; pub mod functions; +pub mod grant; pub mod jobs; pub mod path; pub mod scode; diff --git a/shell/src/main.rs b/shell/src/main.rs index 50648714a..ddd547f64 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -13,6 +13,7 @@ mod exec; mod exec_dispatch; mod fs; mod functions; +mod grant; mod jobs; mod path; mod scode; @@ -197,8 +198,19 @@ async fn main() -> Result<()> { ); } + let code_cells = if cfg.fs.is_jailed() { + Some( + configuration::build_code_cells(&cfg) + .map_err(anyhow::Error::msg) + .context("building initial code surface state (coder::*)")?, + ) + } else { + None + }; + let state = AppState { runtime: std::sync::Arc::new(tokio::sync::RwLock::new(runtime)), + code_cells: code_cells.clone(), iii: iii.clone(), reload_lock: std::sync::Arc::new(tokio::sync::Mutex::new(())), reload_status: std::sync::Arc::new(tokio::sync::RwLock::new( @@ -232,12 +244,8 @@ async fn main() -> Result<()> { // whole worker fails closed (no half-booted surface). The code surface // requires a jail: unjailed shells don't expose coder::*. if cfg.fs.is_jailed() { - let code_cfg = cfg.code_resolver_config(); - let resolver = code::path::PathResolver::new(&code_cfg) - .map_err(|e| anyhow::anyhow!("failed to build code PathResolver (coder::*): {e}"))?; - let cell: code::ConfigCell = - std::sync::Arc::new(tokio::sync::RwLock::new(std::sync::Arc::new(code_cfg))); - code::register_all(&iii, std::sync::Arc::new(resolver), cell); + let cells = code_cells.expect("code cells exist when fs is jailed"); + code::register_all(&iii, cells); tracing::info!("code surface (coder::*) registered over the unified fs jail"); } else { tracing::warn!( diff --git a/shell/tests/code_golden_errors.rs b/shell/tests/code_golden_errors.rs index 569561cf0..4914ffd97 100644 --- a/shell/tests/code_golden_errors.rs +++ b/shell/tests/code_golden_errors.rs @@ -95,6 +95,23 @@ impl Jail { for (raw, token) in subs { out = out.replace(&raw, token); } + for (root, token) in [ + (&self.root0, ""), + (&self.root1, ""), + ] { + if let Some(parent) = root.parent() { + out = out.replace( + &format!("\"dir\":\"{}\"", parent.display()), + &format!("\"dir\":\"{token}\""), + ); + } + } + // `/etc/passwd` is a stable caller path in the case input, but the + // canonical existing directory differs on macOS (`/private/etc`) vs + // Linux (`/etc`). Normalize only the hint dir, not the echoed path. + for etc_dir in ["/private/etc", "/etc"] { + out = out.replace(&format!("\"dir\":\"{etc_dir}\""), "\"dir\":\"\""); + } // DEFENSIVE: the substitution table only carries the CANONICAL root // forms. A future C2xx message that embedded the RAW (non-canonical) // base_path — e.g. /var/folders/... vs /private/var/folders/... on @@ -152,6 +169,7 @@ async fn create_err( create_file::CreateFileInput { files: vec![spec], base_dir: None, + extra_roots: None, }, ) .await @@ -244,6 +262,7 @@ async fn error_message_formats_match_golden() { paths: vec!["blocked-dir".into()], recursive: true, base_dir: None, + extra_roots: None, }, ) .await @@ -422,6 +441,7 @@ async fn error_message_formats_match_golden() { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -452,6 +472,7 @@ async fn error_message_formats_match_golden() { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -481,6 +502,7 @@ async fn error_message_formats_match_golden() { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await @@ -526,6 +548,7 @@ async fn error_message_formats_match_golden() { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -553,6 +576,7 @@ async fn error_message_formats_match_golden() { parents: true, }], base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/tests/code_path_jail.rs b/shell/tests/code_path_jail.rs index 486b2ed52..49e290ddb 100644 --- a/shell/tests/code_path_jail.rs +++ b/shell/tests/code_path_jail.rs @@ -167,6 +167,7 @@ async fn scoped_base_dir_blocks_sibling_escape_across_handlers() { ReadFileInput { path: Some("../sibling.txt".into()), base_dir: Some(base_dir.clone()), + extra_roots: None, ..ReadFileInput::default() }, ) @@ -174,7 +175,7 @@ async fn scoped_base_dir_blocks_sibling_escape_across_handlers() { .expect_err("scoped read must not escape the session dir"); assert_eq!(wire_err_code(&read_err), "C218"); - let create = create_handle( + let create_err = create_handle( r.clone(), c, CreateFileInput { @@ -186,29 +187,29 @@ async fn scoped_base_dir_blocks_sibling_escape_across_handlers() { overwrite: false, }], base_dir: Some(base_dir.clone()), + extra_roots: None, }, ) .await - .unwrap(); - let create_error = create.results[0].error.as_ref().expect("create rejected"); - assert_eq!(create_error.code, "C218"); + .expect_err("scoped create must not escape the session dir"); + assert_eq!(wire_err_code(&create_err), "C218"); assert!( !tmp.path().join("created-outside.txt").exists(), "rejected create must not write into a sibling of the session dir" ); - let delete = delete_handle( + let delete_err = delete_handle( r, DeleteFileInput { paths: vec!["../sibling.txt".into()], recursive: false, base_dir: Some(base_dir), + extra_roots: None, }, ) .await - .unwrap(); - let delete_error = delete.results[0].error.as_ref().expect("delete rejected"); - assert_eq!(delete_error.code, "C218"); + .expect_err("scoped delete must not escape the session dir"); + assert_eq!(wire_err_code(&delete_err), "C218"); assert!( tmp.path().join("sibling.txt").exists(), "rejected delete must leave sibling files untouched" diff --git a/shell/tests/code_unified_protection.rs b/shell/tests/code_unified_protection.rs index 607d85e71..52c707a02 100644 --- a/shell/tests/code_unified_protection.rs +++ b/shell/tests/code_unified_protection.rs @@ -58,6 +58,7 @@ async fn code_non_accessible_globs_block_the_fs_read() { .read(ReadArgs { path: abs_env, base_dir: None, + extra_roots: None, }) .await .expect_err("a glob declared under code.non_accessible_globs must block the fs read too"); @@ -85,6 +86,7 @@ async fn fs_surface_not_protected_without_code_glob() { .stat(StatArgs { path: abs_env.clone(), base_dir: None, + extra_roots: None, }) .await .expect_err("stat must hit the same protection gate as read"); @@ -97,6 +99,7 @@ async fn fs_surface_not_protected_without_code_glob() { .stat(StatArgs { path: abs_env, base_dir: None, + extra_roots: None, }) .await; assert!( diff --git a/shell/tests/code_update_ops.rs b/shell/tests/code_update_ops.rs index c56832345..f1da37501 100644 --- a/shell/tests/code_update_ops.rs +++ b/shell/tests/code_update_ops.rs @@ -53,6 +53,7 @@ async fn bottom_up_application_e2e() { ], }], base_dir: None, + extra_roots: None, }, ) .await @@ -108,6 +109,7 @@ async fn batch_with_mix_of_success_and_failure_preserves_originals() { }, ], base_dir: None, + extra_roots: None, }, ) .await @@ -152,6 +154,7 @@ async fn crlf_line_endings_preserved_after_update() { }], }], base_dir: None, + extra_roots: None, }, ) .await @@ -180,6 +183,7 @@ async fn regex_replace_e2e() { }], }], base_dir: None, + extra_roots: None, }, ) .await diff --git a/shell/tests/features/coder/path_security.feature b/shell/tests/features/coder/path_security.feature index 471304a28..4d8bee610 100644 --- a/shell/tests/features/coder/path_security.feature +++ b/shell/tests/features/coder/path_security.feature @@ -7,7 +7,7 @@ Feature: coder path security """ {"files":[{"path":"{{outside}}/escape.txt","content":"escape","overwrite":true}]} """ - Then the result for "{{outside}}/escape.txt" failed with code "C215" + Then the call failed with code "C215" Scenario: protected globs are listable but not readable or writable Given a jailed code surface diff --git a/shell/tests/golden/errors.json b/shell/tests/golden/errors.json index 90930649f..4ea06dcbc 100644 --- a/shell/tests/golden/errors.json +++ b/shell/tests/golden/errors.json @@ -53,15 +53,15 @@ }, "C215_absolute_outside_all_roots": { "code": "C215", - "message": "path is outside every allowed root: /etc/passwd. Allowed roots: , . Use a path inside an allowed root, or the shell worker's shell::fs::* for other host paths." + "message": "path is outside every allowed root: /etc/passwd. Allowed roots: , . Use a path inside an allowed root, or the shell worker's shell::fs::* for other host paths. grant_hint={\"v\":1,\"dir\":\"\",\"path\":\"/etc/passwd\",\"code\":\"C215\"}" }, "C215_dangling_symlink": { "code": "C215", - "message": "dangle/child.txt: dangling symlink in path: /dangle. Allowed roots: , . Use a path inside an allowed root, or the shell worker's shell::fs::* for other host paths." + "message": "dangle/child.txt: dangling symlink in path: /dangle. Allowed roots: , . Use a path inside an allowed root, or the shell worker's shell::fs::* for other host paths. grant_hint={\"v\":1,\"dir\":\"\",\"path\":\"dangle/child.txt\",\"code\":\"C215\"}" }, "C215_relative_dotdot_escape": { "code": "C215", - "message": "path escapes the primary allowed root : ../escape.txt. Relative paths resolve against ; use an absolute path inside an allowed root instead." + "message": "path escapes the primary allowed root : ../escape.txt. Relative paths resolve against ; use an absolute path inside an allowed root instead. grant_hint={\"v\":1,\"dir\":\"\",\"path\":\"../escape.txt\",\"code\":\"C215\"}" }, "C216_io_passthrough": { "code": "C216", diff --git a/shell/tests/host_fs_branches.rs b/shell/tests/host_fs_branches.rs index aa7f56084..fe04a32eb 100644 --- a/shell/tests/host_fs_branches.rs +++ b/shell/tests/host_fs_branches.rs @@ -41,6 +41,7 @@ async fn mkdir_parents_true_creates_deeply_nested_path() { let r = backend() .mkdir(MkdirArgs { base_dir: None, + extra_roots: None, path: deep.to_string_lossy().into_owned(), mode: "0755".into(), parents: true, @@ -52,6 +53,7 @@ async fn mkdir_parents_true_creates_deeply_nested_path() { let again = backend() .mkdir(MkdirArgs { base_dir: None, + extra_roots: None, path: deep.to_string_lossy().into_owned(), mode: "0755".into(), parents: true, @@ -75,6 +77,7 @@ async fn mv_overwrite_true_replaces_existing_dst() { let r = backend() .mv(MvArgs { base_dir: None, + extra_roots: None, src: src.to_string_lossy().into_owned(), dst: dst.to_string_lossy().into_owned(), overwrite: true, @@ -96,6 +99,7 @@ async fn chmod_recursive_walks_subtree_and_counts() { let r = backend() .chmod(ChmodArgs { base_dir: None, + extra_roots: None, path: root.to_string_lossy().into_owned(), // 0750 preserves +x on dirs so walkdir can descend. mode: "0750".into(), @@ -120,6 +124,7 @@ async fn grep_include_glob_filters_paths() { let r = backend() .grep(GrepArgs { base_dir: None, + extra_roots: None, path: root.to_string_lossy().into_owned(), pattern: "needle".into(), recursive: true, @@ -148,6 +153,7 @@ async fn grep_exclude_glob_skips_paths() { let r = backend() .grep(GrepArgs { base_dir: None, + extra_roots: None, path: root.to_string_lossy().into_owned(), pattern: "needle".into(), recursive: true, @@ -176,6 +182,7 @@ async fn sed_walks_directory_with_include_exclude_globs() { let r = backend() .sed(SedArgs { base_dir: None, + extra_roots: None, files: vec![], path: Some(root.to_string_lossy().into_owned()), recursive: true, @@ -205,6 +212,7 @@ async fn sed_recursive_false_on_directory_rejected_with_s210() { let err = backend() .sed(SedArgs { base_dir: None, + extra_roots: None, files: vec![], path: Some(root.to_string_lossy().into_owned()), recursive: false, @@ -229,6 +237,7 @@ async fn sed_first_only_replaces_just_the_first_match_per_line() { let r = backend() .sed(SedArgs { base_dir: None, + extra_roots: None, files: vec![f.to_string_lossy().into_owned()], path: None, recursive: false, @@ -257,6 +266,7 @@ async fn rm_recursive_true_removes_non_empty_dir() { let r = backend() .rm(RmArgs { base_dir: None, + extra_roots: None, path: target.to_string_lossy().into_owned(), recursive: true, }) @@ -277,6 +287,7 @@ async fn stat_reports_is_symlink_for_symlink_target() { let s = backend() .stat(StatArgs { base_dir: None, + extra_roots: None, path: link.to_string_lossy().into_owned(), }) .await @@ -300,6 +311,7 @@ async fn mkdir_parents_over_existing_file_errors() { let err = backend() .mkdir(MkdirArgs { base_dir: None, + extra_roots: None, path: f.to_string_lossy().into_owned(), mode: "0755".into(), parents: true, @@ -319,6 +331,7 @@ async fn host_responses_populate_new_path_fields() { let mk = backend() .mkdir(MkdirArgs { base_dir: None, + extra_roots: None, path: d.to_string_lossy().into_owned(), mode: "0755".into(), parents: false, @@ -331,6 +344,7 @@ async fn host_responses_populate_new_path_fields() { let ch = backend() .chmod(ChmodArgs { base_dir: None, + extra_roots: None, path: d.to_string_lossy().into_owned(), mode: "0700".into(), uid: None, @@ -348,6 +362,7 @@ async fn host_responses_populate_new_path_fields() { let mv = backend() .mv(MvArgs { base_dir: None, + extra_roots: None, src: src.to_string_lossy().into_owned(), dst: dst.to_string_lossy().into_owned(), overwrite: false, @@ -361,6 +376,7 @@ async fn host_responses_populate_new_path_fields() { let r = backend() .rm(RmArgs { base_dir: None, + extra_roots: None, path: dst.to_string_lossy().into_owned(), recursive: false, }) diff --git a/shell/tests/sandbox_dispatch.rs b/shell/tests/sandbox_dispatch.rs index d7eb86501..cb0732292 100644 --- a/shell/tests/sandbox_dispatch.rs +++ b/shell/tests/sandbox_dispatch.rs @@ -72,6 +72,7 @@ async fn stat_forwards_path_with_sandbox_id() { let resp = backend(stub.clone()) .stat(StatArgs { base_dir: None, + extra_roots: None, path: "/sb/x.txt".into(), }) .await @@ -89,6 +90,7 @@ async fn mkdir_forwards_mode_and_parents_flag() { let resp = backend(stub.clone()) .mkdir(MkdirArgs { base_dir: None, + extra_roots: None, path: "/sb/dir".into(), mode: "0700".into(), parents: true, @@ -107,6 +109,7 @@ async fn rm_forwards_recursive_flag() { backend(stub.clone()) .rm(RmArgs { base_dir: None, + extra_roots: None, path: "/sb/d".into(), recursive: true, }) @@ -122,6 +125,7 @@ async fn chmod_forwards_uid_gid_recursive() { let resp = backend(stub.clone()) .chmod(ChmodArgs { base_dir: None, + extra_roots: None, path: "/sb/d".into(), mode: "0644".into(), uid: Some(1000), @@ -143,6 +147,7 @@ async fn mv_forwards_overwrite_flag() { backend(stub.clone()) .mv(MvArgs { base_dir: None, + extra_roots: None, src: "/sb/a".into(), dst: "/sb/b".into(), overwrite: true, @@ -164,6 +169,7 @@ async fn grep_forwards_full_payload() { backend(stub.clone()) .grep(GrepArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), pattern: "needle".into(), recursive: true, @@ -193,6 +199,7 @@ async fn sed_forwards_full_payload() { backend(stub.clone()) .sed(SedArgs { base_dir: None, + extra_roots: None, files: vec!["/sb/a".into()], path: None, recursive: false, @@ -224,6 +231,7 @@ async fn read_forwards_path_and_returns_engine_response() { let resp = backend(stub.clone()) .read(ReadArgs { base_dir: None, + extra_roots: None, path: "/sb/file".into(), }) .await @@ -247,6 +255,7 @@ async fn write_forwards_content_ref_verbatim() { let resp = backend(stub.clone()) .write(WriteArgs { base_dir: None, + extra_roots: None, path: "/sb/x".into(), mode: "0600".into(), parents: false, @@ -271,6 +280,7 @@ async fn remote_error_with_s_code_round_trips() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -288,6 +298,7 @@ async fn remote_error_with_unknown_s_code_collapses_to_s216() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -304,6 +315,7 @@ async fn remote_error_invocation_failed_with_s_code_in_message_recovers() { let err = backend(stub) .stat(StatArgs { base_dir: None, + extra_roots: None, path: "/sb/missing".into(), }) .await @@ -321,6 +333,7 @@ async fn remote_error_invocation_failed_without_s_code_falls_back_to_s216() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -336,6 +349,7 @@ async fn handler_error_with_json_payload_recovers_code() { let err = backend(stub) .rm(RmArgs { base_dir: None, + extra_roots: None, path: "/sb/d".into(), recursive: false, }) @@ -352,6 +366,7 @@ async fn handler_error_with_raw_s_code_in_string_recovers_via_scan() { let err = backend(stub) .grep(GrepArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), pattern: "[".into(), recursive: true, @@ -372,6 +387,7 @@ async fn handler_error_without_s_code_falls_back_to_s216() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -385,6 +401,7 @@ async fn engine_response_missing_required_field_is_s216() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -400,6 +417,7 @@ async fn disabled_sandbox_returns_s210_without_calling_engine() { let err = b .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await @@ -417,6 +435,7 @@ async fn scan_s_code_skips_codes_glued_to_an_identifier() { let err = backend(stub) .ls(LsArgs { base_dir: None, + extra_roots: None, path: "/sb".into(), }) .await