fix(B03): preauthorize full access and replace composer with active approval
This commit is contained in:
1 parent
0bc9faa129
commit
adbbb3bd37
15 files changed
+627
-63
No files matched your search
@@ -0,0 +1,124 @@
|
||||
/** B03 offline host contract: query is stubbed; no SDK/CLI/model/filesystem tools run. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
|
||||
import { clearSessionGrants, sessionGrantRules } from '../src/main/agent/claude/session-grants'
|
||||
const root = await mkdtemp(join(tmpdir(), 'b03-offline-'))
|
||||
process.env.CLAUDE_TEST_USER_DATA = root
|
||||
const logs: string[] = []
|
||||
const info = console.info
|
||||
console.info = (...args) => logs.push(args.join(' '))
|
||||
const queries: any[] = []
|
||||
let approvals = 0, questions = 0, assertions = 0
|
||||
function check(actual: unknown, expected: unknown, message: string) { assert.deepEqual(actual, expected, message); assertions++ }
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>(r => { resolve = r }); return { promise, resolve } }
|
||||
;(globalThis as any).__b03Query = ({ options }: any) => {
|
||||
const stopped = deferred()
|
||||
const instance = {
|
||||
closed: false, modes: [] as string[], applied: [] as unknown[],
|
||||
async *[Symbol.asyncIterator]() { await stopped.promise },
|
||||
setPermissionMode: async (mode: string) => { instance.modes.push(mode) },
|
||||
applyFlagSettings: async (value: unknown) => { instance.applied.push(value) },
|
||||
close: () => { instance.closed = true; stopped.resolve() }, interrupt: async () => undefined
|
||||
}
|
||||
queries.push({ options, instance }); return instance
|
||||
}
|
||||
const make = (permissionMode: 'default-sandbox' | 'full-access', agentMode: 'craft' | 'ask' | 'plan' = 'craft') => new ClaudeKernel({
|
||||
sessionId: `offline-${queries.length}`, workspaceRoot: root, permissionMode, agentMode,
|
||||
model: { config: { model: 'offline-model' }, profile: { authMode: 'none' } } as any, onSessionId() {}
|
||||
})
|
||||
function active(kernel: ClaudeKernel) {
|
||||
const raw = kernel as any, signal = new AbortController().signal
|
||||
const state = { settled: false, content: '', planEmitted: false, appends: new Map(), request: { runId: 'offline', signal,
|
||||
emit() {}, interactions: {
|
||||
approval: async () => { approvals++; return { kind: 'approval', optionId: 'allow-once' } },
|
||||
question: async () => { questions++; return { kind: 'question', answer: { questions: [] } } }
|
||||
} }, resolve() {}, reject() {} }
|
||||
raw.active = state
|
||||
return { raw, state, invoke: (name = 'Bash', metadata = {}) => raw.canUseTool(name, { command: 'SECRET_COMMAND', file_path: 'SECRET_PATH', questions: [{ question: 'Business?', options: [{ label: 'Yes' }] }] }, { signal, toolUseID: 'SECRET_TOOL_ID', ...metadata }) }
|
||||
}
|
||||
const kernels: ClaudeKernel[] = []
|
||||
try {
|
||||
for (const permission of ['default-sandbox', 'full-access'] as const) for (const mode of ['craft', 'ask', 'plan'] as const) {
|
||||
const kernel = make(permission, mode); kernels.push(kernel); await kernel.ensureStarted()
|
||||
const { options, instance } = queries.at(-1)
|
||||
check(options.permissionMode, mode === 'plan' ? 'plan' : 'default', 'initial SDK mode matches host-gate design')
|
||||
check(options.allowDangerouslySkipPermissions, undefined, 'no bypass capability')
|
||||
check(options.allowedTools, [], 'no global whitelist')
|
||||
const test = active(kernel), hook = options.hooks.PreToolUse[0].hooks[0]
|
||||
for (const toolName of ['Read', 'Write', 'Edit', 'Bash', 'PowerShell']) {
|
||||
const response = await hook({ hook_event_name: 'PreToolUse', tool_name: toolName, tool_use_id: 'SECRET_TOOL_ID', tool_input: {} })
|
||||
const expected = mode !== 'craft' && toolName !== 'Read' ? 'deny' : permission === 'full-access' && mode === 'craft' ? 'allow' : undefined
|
||||
check(response.hookSpecificOutput?.permissionDecision, expected, `${permission}/${mode}/${toolName} hook`)
|
||||
}
|
||||
if (permission === 'full-access' && mode === 'craft') {
|
||||
const beforeFull = approvals
|
||||
const metadataCases = [ {}, { classifierApprovable: false }, { classifier_approvable: false },
|
||||
{ decisionReasonType: 'subcommandResults' }, { decision_reason_type: 'subcommandResults' },
|
||||
{ decisionReasonType: 'safetyCheck' }, { decision_reason_type: 'safetyCheck' },
|
||||
{ decisionReasonCode: 'SECRET_CODE' }, { decision_reason_code: 'SECRET_CODE' },
|
||||
{ requiresUserInteraction: true }, { requires_user_interaction: true }, { serverPolicyAskOnly: true },
|
||||
{ matchedAskRule: { source: 'projectSettings', toolName: 'Bash' } },
|
||||
{ matched_ask_rule: { source: 'projectSettings', tool_name: 'Bash' } }, { matchesAskRule: true },
|
||||
{ futureUnknownField: { text: 'SECRET_CODE' } } ]
|
||||
for (const tool of ['Read', 'Write', 'Edit', 'Bash', 'PowerShell']) for (const metadata of metadataCases) {
|
||||
const response = await test.invoke(tool, metadata)
|
||||
check(response.behavior, 'allow', `full access callback preauthorizes ${tool} regardless of metadata`)
|
||||
check(response.updatedPermissions, undefined, 'preauthorization does not return permission grants')
|
||||
}
|
||||
check(approvals, beforeFull, 'every full-access callback metadata combination has zero approvals')
|
||||
}
|
||||
const before = approvals
|
||||
const result = await test.invoke('Bash', { matchedAskRule: { source: 'projectSettings', toolName: 'Bash' }, decisionReasonType: 'subcommandResults', decisionReasonCode: 'SECRET_CODE' })
|
||||
check(result.behavior, mode === 'craft' ? 'allow' : 'deny', 'callback respects mode')
|
||||
check(approvals - before, mode === 'craft' && permission !== 'full-access' ? 1 : 0, 'only default permission asks')
|
||||
check((await hook({ hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_use_id: 'question', tool_input: {} })).hookSpecificOutput?.permissionDecision, 'ask', 'business question remains interactive')
|
||||
const q = questions; await test.invoke('AskUserQuestion'); check(questions, q + 1, 'business callback not masked')
|
||||
check((await options.hooks.PreToolUse[1].hooks[0]({ hook_event_name: 'PreToolUse', tool_input: { plan: 'Test plan' } })).hookSpecificOutput.permissionDecision, 'deny', 'plan content does not authorize execution')
|
||||
check(test.state.planEmitted, true, 'plan content still emitted')
|
||||
check(instance.applied, [], 'preauthorization never writes grants')
|
||||
check(sessionGrantRules((kernel as any).options.sessionId, root), [], 'no remembered full-access grants')
|
||||
test.raw.active = null; await kernel.close()
|
||||
}
|
||||
const kernel = make('default-sandbox'); kernels.push(kernel); await kernel.ensureStarted()
|
||||
const test = active(kernel), first = queries.at(-1), gate = deferred(), called = deferred()
|
||||
first.instance.setPermissionMode = async (mode: string) => { first.instance.modes.push(mode); called.resolve(); await gate.promise }
|
||||
const switching = kernel.setPolicy('full-access', 'craft'); await called.promise
|
||||
check((await test.invoke()).behavior, 'deny', 'pending transition fails closed')
|
||||
const second = kernel.setPolicy('full-access', 'plan')
|
||||
gate.resolve(); await Promise.all([switching, second])
|
||||
check(first.instance.modes, ['default', 'plan'], 'SDK updates serialized')
|
||||
check(test.raw.agentMode, 'plan', 'latest policy wins')
|
||||
check((await test.invoke()).behavior, 'deny', 'full-access plan cannot execute')
|
||||
await kernel.setPolicy('default-sandbox', 'craft')
|
||||
const answer = deferred(), approvalStarted = deferred()
|
||||
test.state.request.interactions.approval = async () => { approvals++; approvalStarted.resolve(); await answer.promise; return { kind: 'approval', optionId: 'allow-once' } }
|
||||
const stale = test.invoke(); await approvalStarted.promise
|
||||
await kernel.setPolicy('full-access', 'craft'); answer.resolve()
|
||||
check((await stale).behavior, 'deny', 'stale approval cannot execute after policy switch')
|
||||
test.raw.active = null
|
||||
first.instance.setPermissionMode = async () => { throw new Error('synthetic switch rejection') }
|
||||
await assert.rejects(kernel.setPolicy('default-sandbox', 'ask'), /synthetic switch rejection/); assertions++
|
||||
check(first.instance.closed, true, 'failed switch retires uncertain producer')
|
||||
check(test.raw.instance, null, 'failed switch cannot reuse CLI')
|
||||
await assert.rejects(kernel.ensureStarted(), /权限切换失败/); assertions++
|
||||
// Caller retries its requested persisted policy before the next turn.
|
||||
await kernel.setPolicy('default-sandbox', 'ask'); await kernel.ensureStarted()
|
||||
check(queries.at(-1).options.permissionMode, 'default', 'rebuilt SDK uses latest mode')
|
||||
check(test.raw.agentMode, 'ask', 'rebuilt host uses latest policy')
|
||||
const rebuilt = active(kernel); check((await rebuilt.invoke()).behavior, 'deny', 'rebuilt ask still denies command')
|
||||
rebuilt.raw.active = null
|
||||
const text = logs.join('\n')
|
||||
for (const secret of ['SECRET_COMMAND', 'SECRET_PATH', 'SECRET_TOOL_ID', 'SECRET_CODE']) check(text.includes(secret), false, 'diagnostics exclude raw data')
|
||||
const diagnostic = logs.map(line => JSON.parse(line.slice(line.indexOf('{')))).find(item => item.decisionCodePresent)
|
||||
check(diagnostic.decisionCode, 'unknown', 'unrecognized code is explicitly unknown')
|
||||
check(diagnostic.decisionCategory, 'subcommandResults', 'known category retained')
|
||||
check(diagnostic.matchedAskRule, true, 'true matched rule distinguished')
|
||||
info(`B03 offline host policy: ${assertions} checks passed; no real SDK/CLI/model execution.`)
|
||||
} finally {
|
||||
console.info = info
|
||||
for (const kernel of kernels) { (kernel as any).active = null; await kernel.close() }
|
||||
clearSessionGrants(); await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
Reference in new issue
Block a user