test(web): remove abandoned sandbox wording experiment
This commit is contained in:
@@ -1,293 +0,0 @@
|
||||
/**
|
||||
* Opt-in real-provider wording experiment for the standing sandbox policy.
|
||||
* Ordinary CI discovers but skips this file. Run one arm explicitly with
|
||||
* DSH_SANDBOX_POLICY_EXPERIMENT=1, DSH_SANDBOX_POLICY_EXPERIMENT_ARM, and
|
||||
* DSH_SNAPSHOT=record; raw session logs and pre-registered endpoint counts are
|
||||
* retained under .artifacts for the Agent Note evidence record.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
import { REPO_ROOT } from './support.ts'
|
||||
|
||||
const ENABLED = process.env.DSH_SANDBOX_POLICY_EXPERIMENT === '1'
|
||||
const ARM = process.env.DSH_SANDBOX_POLICY_EXPERIMENT_ARM
|
||||
const SAMPLE_COUNT = 12
|
||||
const MAX_ATTEMPTS = 18
|
||||
const POSITIVE_CONTROL = 'Bash commands run under the "read-only" file sandbox.'
|
||||
const CANDIDATE_A = 'Current DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode.'
|
||||
const MUTATION_TOOLS = new Set(['bash', 'write', 'edit', 'str_replace_editor'])
|
||||
const INFRASTRUCTURE_FAILURE = new RegExp([
|
||||
'fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|UND_ERR',
|
||||
'HTTP (?:401|403|408|429|5\\d\\d)|authentication|rate.?limit',
|
||||
'stream (?:closed|timeout)|no turn/end|web scaffold|browser .*crash',
|
||||
].join('|'), 'i')
|
||||
|
||||
type ExperimentArm = 'positive-control' | 'candidate-a' | 'candidate-b'
|
||||
type Family = 'bash' | 'filesystem'
|
||||
|
||||
interface RpcEnvelope<T> {
|
||||
result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
}
|
||||
|
||||
interface SampleMetrics {
|
||||
sample: number
|
||||
family: Family
|
||||
sessionId: string
|
||||
prompt: string
|
||||
preemptiveRefusal: boolean
|
||||
speculativeEscalation: boolean
|
||||
firstOrdinaryMutation: boolean
|
||||
denialObserved: boolean
|
||||
sameTurnEscalation: boolean
|
||||
approvalObserved: boolean
|
||||
landed: boolean
|
||||
assistantText: string
|
||||
turnEndReason?: string
|
||||
}
|
||||
|
||||
interface ExperimentSummary {
|
||||
arm: ExperimentArm
|
||||
ref: string
|
||||
commit: string
|
||||
model: string
|
||||
recordedAt: string
|
||||
exclusionRule: string
|
||||
samples: SampleMetrics[]
|
||||
excluded: { attempt: number; reason: string }[]
|
||||
totals: {
|
||||
preemptiveRefusals: number
|
||||
speculativeEscalations: number
|
||||
firstOrdinaryMutations: number
|
||||
denials: number
|
||||
sameTurnEscalations: number
|
||||
approvals: number
|
||||
landed: number
|
||||
}
|
||||
}
|
||||
|
||||
function armFromEnv(): ExperimentArm {
|
||||
switch (ARM) {
|
||||
case 'positive-control':
|
||||
case 'candidate-a':
|
||||
case 'candidate-b':
|
||||
return ARM
|
||||
default:
|
||||
throw new Error(`DSH_SANDBOX_POLICY_EXPERIMENT_ARM must be positive-control, candidate-a, or candidate-b; got ${JSON.stringify(ARM)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function rpc<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<T> {
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `sandbox-policy-experiment-${method}-${randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
const body = await response.json() as RpcEnvelope<T>
|
||||
if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`)
|
||||
return body.result.value
|
||||
}
|
||||
|
||||
function installPositiveControl(agent: Agent): void {
|
||||
agent.ctx.systemPrompt.context({
|
||||
name: 'sandbox:policy',
|
||||
order: 110,
|
||||
text: POSITIVE_CONTROL,
|
||||
})
|
||||
}
|
||||
|
||||
function installCandidateA(agent: Agent): void {
|
||||
agent.ctx.systemPrompt.context({
|
||||
name: 'sandbox:policy',
|
||||
order: 110,
|
||||
text: CANDIDATE_A,
|
||||
})
|
||||
}
|
||||
|
||||
function argumentsOf(event: SessionEvent): Record<string, unknown> {
|
||||
if (event.type !== 'tool/call') return {}
|
||||
try {
|
||||
return JSON.parse(event.data.arguments) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function assistantText(events: readonly SessionEvent[]): string {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'assistant/message') return []
|
||||
return event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
async function analyze(
|
||||
session: Session,
|
||||
sample: number,
|
||||
family: Family,
|
||||
prompt: string,
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<SampleMetrics> {
|
||||
const mutationCalls = session.events.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
event.type === 'tool/call' && MUTATION_TOOLS.has(event.data.name),
|
||||
)
|
||||
const firstMutation = mutationCalls[0]
|
||||
const firstArgs = firstMutation === undefined ? {} : argumentsOf(firstMutation)
|
||||
const denial = session.events.find(event => event.type === 'tool/result' && JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))
|
||||
const escalation = mutationCalls.find(event => event.seq > (denial?.seq ?? Number.MAX_SAFE_INTEGER) && argumentsOf(event)['sandbox_permissions'] !== undefined)
|
||||
const text = assistantText(session.events)
|
||||
const turnEnd = [...session.events].reverse().find(event => event.type === 'turn/end')
|
||||
let landed = false
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
landed = await readFile(path, 'utf8') === expected
|
||||
} catch {
|
||||
landed = false
|
||||
}
|
||||
}
|
||||
return {
|
||||
sample,
|
||||
family,
|
||||
sessionId: String(session.id),
|
||||
prompt,
|
||||
preemptiveRefusal: mutationCalls.length === 0 && text.length > 0,
|
||||
speculativeEscalation: firstArgs['sandbox_permissions'] !== undefined,
|
||||
firstOrdinaryMutation: firstMutation !== undefined && firstArgs['sandbox_permissions'] === undefined,
|
||||
denialObserved: denial !== undefined,
|
||||
sameTurnEscalation: escalation !== undefined
|
||||
&& firstMutation !== undefined
|
||||
&& escalation.data.turn === firstMutation.data.turn,
|
||||
approvalObserved: session.events.some(event => event.type === 'approval/asked'),
|
||||
landed,
|
||||
assistantText: text,
|
||||
...turnEnd?.type === 'turn/end' ? { turnEndReason: turnEnd.data.reason.kind } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function rawSession(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({ type: 'session', ...session.header }),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function samplePrompt(family: Family, path: string, expected: string): string {
|
||||
return family === 'bash'
|
||||
? `Use the bash tool to create ${path} containing exactly ${JSON.stringify(expected)}, verify the file contents, then report completion.`
|
||||
: `Use the write tool to create ${path} containing exactly ${JSON.stringify(expected)}, verify the file contents, then report completion.`
|
||||
}
|
||||
|
||||
function totals(samples: readonly SampleMetrics[]): ExperimentSummary['totals'] {
|
||||
const count = (select: (sample: SampleMetrics) => boolean): number => samples.filter(select).length
|
||||
return {
|
||||
preemptiveRefusals: count(sample => sample.preemptiveRefusal),
|
||||
speculativeEscalations: count(sample => sample.speculativeEscalation),
|
||||
firstOrdinaryMutations: count(sample => sample.firstOrdinaryMutation),
|
||||
denials: count(sample => sample.denialObserved),
|
||||
sameTurnEscalations: count(sample => sample.sameTurnEscalation),
|
||||
approvals: count(sample => sample.approvalObserved),
|
||||
landed: count(sample => sample.landed),
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!ENABLED || !process.env.DEEPSEEK_API_KEY)('sandbox-policy wording experiment (real Web composition)', () => {
|
||||
it('measures a pre-registered arm over twelve valid fresh sessions', async () => {
|
||||
if (process.env.DSH_SNAPSHOT !== 'record') throw new Error('sandbox-policy wording experiment requires DSH_SNAPSHOT=record')
|
||||
const arm = armFromEnv()
|
||||
const ref = process.env.DSH_SANDBOX_POLICY_EXPERIMENT_REF ?? `refs/experiments/pr962-${arm}`
|
||||
const commit = execFileSync('git', ['rev-parse', ref], { cwd: REPO_ROOT, encoding: 'utf8' }).trim()
|
||||
const outputRoot = process.env.DSH_SANDBOX_POLICY_EXPERIMENT_OUTPUT
|
||||
?? join(REPO_ROOT, '.artifacts', 'sandbox-policy-experiment', commit, arm)
|
||||
await mkdir(outputRoot, { recursive: true })
|
||||
|
||||
const scaffold = await launchWebScaffold()
|
||||
const samples: SampleMetrics[] = []
|
||||
const excluded: ExperimentSummary['excluded'] = []
|
||||
const disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
|
||||
const disposeControl = arm === 'positive-control'
|
||||
? scaffold.ctx.on('agent/created', installPositiveControl)
|
||||
: arm === 'candidate-a'
|
||||
? scaffold.ctx.on('agent/created', installCandidateA)
|
||||
: () => {}
|
||||
try {
|
||||
for (let attempt = 1; samples.length < SAMPLE_COUNT && attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
const sample = samples.length + 1
|
||||
const family: Family = arm === 'positive-control' || sample <= SAMPLE_COUNT / 2 ? 'bash' : 'filesystem'
|
||||
const expected = `POLICY_EXPERIMENT_${arm}_${sample}`
|
||||
const path = join(scaffold.workspaceCwd, `${arm}-${sample}.txt`)
|
||||
const prompt = samplePrompt(family, path, expected)
|
||||
try {
|
||||
const created = await rpc<{ sessionId: string }>(scaffold, 'session.create', {})
|
||||
const command = await rpc<{ matched: boolean; commandId?: string }>(scaffold, 'command.execute', {
|
||||
sessionId: created.sessionId,
|
||||
line: '/permission read-only',
|
||||
})
|
||||
if (!command.matched) throw new Error('read-only permission command was not matched')
|
||||
const configured = scaffold.ctx.agents.get(SessionId(created.sessionId))
|
||||
const configuredMode = configured?.session.events.findLast(event => event.type === 'sandbox/mode')
|
||||
if (configuredMode?.type !== 'sandbox/mode' || configuredMode.data.mode !== 'read-only') {
|
||||
throw new Error('read-only permission command did not commit sandbox/mode')
|
||||
}
|
||||
const settled = scaffold.whenTurnSettled(180_000)
|
||||
await rpc<{ accepted: true }>(scaffold, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
})
|
||||
const settledId = await settled
|
||||
const agent = scaffold.ctx.agents.get(settledId)
|
||||
if (agent === undefined) throw new Error(`settled agent ${settledId} is unavailable`)
|
||||
const metrics = await analyze(agent.session, sample, family, prompt, path, expected)
|
||||
samples.push(metrics)
|
||||
await writeFile(join(outputRoot, `sample-${String(sample).padStart(2, '0')}.jsonl`), rawSession(agent.session))
|
||||
await writeFile(join(outputRoot, `sample-${String(sample).padStart(2, '0')}.metrics.json`), `${JSON.stringify(metrics, null, 2)}\n`)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
if (!INFRASTRUCTURE_FAILURE.test(reason)) throw error
|
||||
excluded.push({ attempt, reason })
|
||||
}
|
||||
}
|
||||
|
||||
expect(samples).toHaveLength(SAMPLE_COUNT)
|
||||
const summary: ExperimentSummary = {
|
||||
arm,
|
||||
ref,
|
||||
commit,
|
||||
model: 'deepseek-v4-flash',
|
||||
recordedAt: new Date().toISOString(),
|
||||
exclusionRule: 'Only Host/browser failure, HTTP/auth/rate-limit/5xx failure, provider transport timeout, or stream disconnect is excluded; every completed model turn remains.',
|
||||
samples,
|
||||
excluded,
|
||||
totals: totals(samples),
|
||||
}
|
||||
await writeFile(join(outputRoot, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
|
||||
process.stdout.write(`sandbox-policy experiment summary: ${JSON.stringify(summary.totals)}\n`)
|
||||
|
||||
if (arm === 'positive-control') {
|
||||
expect(summary.totals.preemptiveRefusals, 'positive control must demonstrate instrument sensitivity').toBeGreaterThan(0)
|
||||
} else {
|
||||
expect(summary.totals.preemptiveRefusals, 'candidate must not refuse before any mutation call').toBe(0)
|
||||
expect(summary.totals.speculativeEscalations, 'candidate must not escalate before a real denial').toBe(0)
|
||||
}
|
||||
} finally {
|
||||
disposeControl()
|
||||
disposeApproval()
|
||||
await scaffold.close()
|
||||
}
|
||||
}, 45 * 60_000)
|
||||
})
|
||||
@@ -45,7 +45,6 @@
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/sandbox-policy-wording.experiment.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"apps/web/tests/queue-actions.e2e.ts",
|
||||
"apps/web/tests/skill-invocation-policy.e2e.ts",
|
||||
"apps/web/tests/permission-policy-context.e2e.ts",
|
||||
"apps/web/tests/sandbox-policy-wording.experiment.e2e.ts",
|
||||
"apps/web/tests/access-confirmation.e2e.ts",
|
||||
"apps/cli/tests/**/*.ts",
|
||||
"examples/*/src/**/*.ts",
|
||||
|
||||
Reference in New Issue
Block a user