fix(review): reconcile sandbox and approval contracts

This commit is contained in:
Tianyi Cui
2026-07-11 21:37:38 +08:00
parent 6a13dcb364
commit b29a8eca71
61 changed files with 620 additions and 358 deletions

View File

@@ -41,7 +41,9 @@ export interface Config {
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
* — carries both Linux file-denial dialects as its denial signatures) —
* the runner chain and its probes are skipped,
* and a broken runner fails loudly at spawn time like any missing command.
* and a broken runner fails loudly at execution time. The operator also
* supplies {@link runnerFailureSignatures}, which distinguish the runner
* refusing its profile from the wrapped command failing normally.
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
* built-in platform chains — Linux `bwrap` then the Landlock launcher
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
@@ -49,6 +51,15 @@ export interface Config {
* for deterministic fake runners in keyless test tiers.
*/
runnerCommand?: string[]
/**
* Case-insensitive stderr substrings emitted when a configured
* {@link runnerCommand} refuses its profile before executing the wrapped
* command. Required and non-empty with `runnerCommand`; rejected without
* it. Missing/unexecutable runner errors are added automatically from
* `runnerCommand[0]`, while these signatures cover an executable runner's
* own failure dialect.
*/
runnerFailureSignatures?: string[]
/**
* Per-probe timeout in milliseconds for the chain's functional probes
* (default: 5000; must be a positive finite number — Node treats a 0
@@ -309,6 +320,7 @@ export class LocalSandboxProvider extends SandboxProvider {
// Inline schema call: the config catalog walks `static Config` statically.
static Config: z<Config> = z.object({
runnerCommand: z.array(z.string()).default([]),
runnerFailureSignatures: z.array(z.string()).default([]),
probeTimeoutMs: z.natural().default(5_000),
})
@@ -316,17 +328,29 @@ export class LocalSandboxProvider extends SandboxProvider {
internals: SandboxInternals = {}
private readonly runnerCommand: string[] | undefined
private readonly configuredRunnerFailureSignatures: string[]
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
constructor(ctx: Context, config: Config) {
super(ctx)
// The schema (static Config) defaults both fields — the casts record
// The schema (static Config) defaults every field — the casts record
// those runtime facts. An empty runnerCommand means "not configured":
// use the platform chain.
const runner = config.runnerCommand as string[]
const runnerFailureSignatures = config.runnerFailureSignatures as string[]
if (runner.length === 0 && runnerFailureSignatures.length > 0) {
throw new Error('sandbox-local: runnerFailureSignatures requires runnerCommand')
}
if (runner.length > 0 && runnerFailureSignatures.length === 0) {
throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry')
}
if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) {
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty')
}
this.runnerCommand = runner.length > 0 ? runner : undefined
this.configuredRunnerFailureSignatures = runnerFailureSignatures
this.probeTimeoutMs = config.probeTimeoutMs as number
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
}
@@ -351,17 +375,16 @@ export class LocalSandboxProvider extends SandboxProvider {
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
enforcement: 'full',
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
// The configured runner's own failure dialect is unknown (as is its
// kernel mechanism), but the consumer never spawns the wrap directly
// — it re-joins it through an outer `bash -c 'exec …'` — so a
// missing or unexecutable runner fails with the OUTER shell's
// argv0-scoped shapes, and those we do know. Scoping every shape to
// The operator names the configured runner's OWN pre-exec refusal
// dialect; the consumer additionally re-joins the wrap through an
// outer `bash -c 'exec …'`, so we can add the missing/unexecutable
// outer-shell shapes ourselves. Scoping every automatic shape to
// argv0 keeps in-command errors out (a bare `exec:`/`Permission
// denied` prefix would claim tool output; `exec: <argv0>: not
// found` cannot). The residual collision — a command invoking a
// file named exactly like the runner and hitting the same errno —
// is the classifier's documented conservative-inference trade.
// denied` prefix would claim tool output; `exec: <argv0>: not found`
// cannot). The residual text-collision trade is documented by the
// seam's conservative classifier contract.
runnerFailureSignatures: [
...this.configuredRunnerFailureSignatures,
`exec: ${argv0}: not found`,
`${argv0}: No such file or directory`,
`${argv0}: Permission denied`,

View File

@@ -102,7 +102,10 @@ describe('runnerCommand config', () => {
const probeBwrap = vi.fn(() => false)
const probeLandlock = vi.fn(() => 'unusable' as const)
const probeSeatbelt = vi.fn(() => false)
const { sandbox } = await setup({ runnerCommand: ['fake-runner', '--flag'] }, { probeBwrap, probeLandlock, probeSeatbelt })
const { sandbox } = await setup({
runnerCommand: ['fake-runner', '--flag'],
runnerFailureSignatures: ['fake-runner: profile rejected'],
}, { probeBwrap, probeLandlock, probeSeatbelt })
const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
expect(confined).toEqual({
argv: ['fake-runner', '--flag', ...bwrapProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
@@ -115,6 +118,7 @@ describe('runnerCommand config', () => {
// unexecutable runner fails with the OUTER shell's argv0-scoped
// shapes, and those classify as sandbox failures like any rung.
runnerFailureSignatures: [
'fake-runner: profile rejected',
'exec: fake-runner: not found',
'fake-runner: No such file or directory',
'fake-runner: Permission denied',
@@ -131,6 +135,24 @@ describe('runnerCommand config', () => {
expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
expect(probeBwrap).toHaveBeenCalledTimes(1)
})
it('requires an operator-owned failure dialect for every configured runner', async () => {
await expect(setup({ runnerCommand: ['fake-runner'] })).rejects.toThrow(
'runnerCommand requires at least one runnerFailureSignatures entry',
)
})
it('rejects runner failure signatures when no custom runner consumes them', async () => {
await expect(setup({ runnerFailureSignatures: ['profile rejected'] })).rejects.toThrow(
'runnerFailureSignatures requires runnerCommand',
)
})
it('rejects blank configured-runner failure signatures', async () => {
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow(
'runnerFailureSignatures entries must be non-empty',
)
})
})
describe('the platform chains', () => {