fix: refuse unsafe ACP permission requests during read-only turns

Local invocations answer ACP permission prompts automatically. The
handler picked the "allow" option for every request, ignoring whether the
turn was read-only, so a read-only discussion participant (for example
Hermes running with command approval prompts) had edit, delete, move,
command execution and mode-switch requests approved.

Read-only turns now approve only side-effect-free tool kinds (read,
search, fetch, think) and refuse everything else, including requests
without a recognised kind. Writable turns and the explicit Human Gate
mode are unchanged.

Checks: new local-workspace-permission-policy tests (read-only refusal
matrix, writable approval). Full desktop suite: 1537 pass.
This commit is contained in:
Ryder Sun committed 2026-10-15 19:04:42 +08:00
1 parent 6c0f773d3f
commit 6e0becfb70
2 files changed
+71 -5

No files matched your search

@@ -107,6 +107,9 @@ function stripV4InternalProtocolTail(value) {
return paragraphs.join('\n\n').trim()
}
// Tool kinds an Agent may use without approval during a read-only turn.
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
function boundedReceiptText(value, limit = V4_RECEIPT_MAX_TEXT) {
if (typeof value !== 'string' || value.trim().length > limit) return ''
const text = cleanText(value, limit)
@@ -1599,11 +1602,20 @@ class LocalWorkspaceAgentInvocation {
// `yolo: false`; sandbox and workspace authorization remain enforced
// before this callback is reached.
if ((context.yolo ?? this.defaultYolo) !== false) {
const option = Array.isArray(request?.options)
? request.options.find(candidate => candidate?.kind === 'allow_once')
|| request.options.find(candidate => candidate?.kind === 'allow_always')
|| request.options[0]
: null
const options = Array.isArray(request?.options) ? request.options : []
// A read-only turn must stay read-only even when the Agent asks:
// only side-effect-free tools are approved automatically, and
// edits, deletes, moves, commands and mode switches are refused.
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
const rejection = options.find(candidate => candidate?.kind === 'reject_once')
|| options.find(candidate => candidate?.kind === 'reject_always')
return Promise.resolve(rejection?.optionId
? { status: 'rejected', optionId: rejection.optionId }
: null)
}
const option = options.find(candidate => candidate?.kind === 'allow_once')
|| options.find(candidate => candidate?.kind === 'allow_always')
|| options[0]
if (option?.optionId) {
return Promise.resolve({ status: 'approved', optionId: option.optionId })
}
@@ -0,0 +1,54 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
const { fixture } = require('../support/local-workspace-test-helpers.cjs')
const OPTIONS = [
{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'deny', name: 'Reject once', kind: 'reject_once' },
]
async function decisionsFor(allowWrite) {
const { directory, options } = fixture()
// Production answers permission prompts automatically (the fixture
// defaults to Human Gates for gate-focused tests).
options.defaultYolo = true
const decisions = {}
options.runAgent = async (_agent, _prompt, _cwd, runOptions) => {
for (const kind of ['read', 'search', 'edit', 'execute', 'switch_mode', undefined]) {
decisions[kind || 'unknown'] = await runOptions.onPermissionRequest({
sessionId: 'session', toolCall: { toolCallId: `tool-${kind}`, ...(kind ? { kind } : {}) }, options: OPTIONS,
})
}
return { text: 'done', sessionRef: runOptions.sessionRef || 'codex-session' }
}
try {
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({
name: 'Permissions', agentKinds: ['codex'], workdir: directory, allowWrite,
conversationType: 'direct', directAgentKind: 'codex',
})
await workspace.sendMessage({ groupId: group.id, text: 'Inspect the project' })
return decisions
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
}
test('read-only turns auto-approve only side-effect-free ACP tools', async () => {
const decisions = await decisionsFor(false)
assert.deepEqual(decisions.read, { status: 'approved', optionId: 'allow' })
assert.deepEqual(decisions.search, { status: 'approved', optionId: 'allow' })
for (const kind of ['edit', 'execute', 'switch_mode', 'unknown']) {
assert.deepEqual(decisions[kind], { status: 'rejected', optionId: 'deny' }, kind)
}
})
test('writable turns keep approving tool permissions automatically', async () => {
const decisions = await decisionsFor(true)
for (const kind of ['read', 'edit', 'execute']) {
assert.deepEqual(decisions[kind], { status: 'approved', optionId: 'allow' }, kind)
}
})