fix(sandbox): close classifier evidence gaps (round 2)

This commit is contained in:
Hypatia May
2026-08-03 17:48:41 +08:00
118 changed files with 2770 additions and 262 deletions

View File

@@ -52,7 +52,12 @@ export function classifyRunnerFailure(
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
const fatalSignatures = rule.fatalSignatures.map(signature => signature.toLowerCase())
// An empty substring matches every string in JavaScript. Ignore it so a
// malformed public rule cannot turn a gated exit status into evidence by
// itself; keep any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue

View File

@@ -1,7 +1,7 @@
/**
* Deterministic composition proof for the partial-Landlock diagnostic: the
* real local provider and sandbox bash executor wrap commands through a POSIX
* fake launcher that prints the native informational line before exec.
* Deterministic real-process proofs for runner classification: the real local
* provider and sandbox bash executor exercise an outer-shell launch failure
* and a POSIX fake Landlock launcher that prints its notice before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
@@ -64,6 +64,26 @@ async function setup(fatal = false): Promise<SandboxBashExecutor> {
}
describe('partial Landlock runner-failure classification', () => {
it.skipIf(process.platform === 'win32')('classifies a genuinely missing configured runner through the outer bash exec rule', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-missing-sandbox-runner-'))
tempDirs.push(dir)
const missingRunner = join(dir, 'missing-runner')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [missingRunner],
runnerFailureSignatures: ['configured-runner: fatal'],
})
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
const error = await ctx.bash.run(ctx.bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(missingRunner)
})
it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => {
const bash = await setup()
for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) {

View File

@@ -234,6 +234,24 @@ describe('classifyDenial', () => {
})
describe('classifyRunnerFailure', () => {
it('ignores empty fatal signatures instead of treating exit status or notice text as evidence', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: [''] }]
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
})
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const fatal = 'landlock-run: ruleset creation failed'
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['', 'landlock-run: '],
informationalLines: [notice],
}]
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
})
it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => {
const rules = [{
allowedExitCodes: [126, 127],