mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
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:
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)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user