fix: decode HTML entities in skill-stderr so an approved grant matches - #347
Conversation
A skill substitution command that a human approves is persisted with the HTML escaping it arrived with. The `<local-command-stderr>` payload is escaped on that channel, so a declaration like `!`sh x 2>/dev/null`` arrives as `sh x 2>/dev/null`, and #tryHandleSkillBlock keys the grant on that stderr verbatim. The rule recomputed from `SKILL.md` is the bare form. The two are compared by exact string equality in #resolveSkillGrants, so the grant never reaches `allowedTools`; the command is blocked again, and the anti-loop guard — reading the same escaped key — concludes it is already granted and suppresses the retry. The phase dies with `phase turn ended in "error"`. Decode entities at the stderr convergence point, the single string every downstream key derives from, so nothing else changes. `&` is decoded last, so a doubly-escaped `&gt;` resolves to the literal `>` rather than over-decoding into `>`. Five entities cover what the SDK emits here; no dependency needed. Tests: the helper, that the decoded stderr key equals the rule skillCommandAllowRules recomputes, and a provider-level run of a `>`-bearing command through park → approve → rebuild → retry. The provider test fails without the decode and passes with it. Signed-off-by: WeiYiAcc <weiyiacc@outlook.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
src/daemon/providers/claude/index.ts (~15 min) — Logic changes in index.ts
📋 PR Summary
What this PR does: Adds HTML entity decoding at the skill-stderr convergence point so that persisted grant keys match the unescaped rules recomputed from SKILL.md, fixing a silent mismatch that caused approved skill commands to be refused.
Key changes:
- Introduce decodeStderrEntities helper that decodes lt, gt, quot, apos, and amp (amp last to prevent over-decoding of doubly-escaped entities like >)
- Apply decode at the single convergence point where state.lastLocalCommandStderr is assigned, aligning all downstream keys (persisted grant, anti-loop guard, allowedTools)
Areas affected: skill command approval/grant matching, local-command-stderr processing
Testing notes: Unit tests for decodeStderrEntities (including amp-last ordering), table test asserting decoded key equals recomputed rule for >-bearing commands, and provider-level park→approve→rebuild→retry integration test that fails without the decode (verified 114 pass / 1 fail on revert)
🔍 Code Review
This is a precisely scoped, well-reasoned fix for a subtle but impactful bug where HTML entity escaping caused a deterministic key mismatch that silently broke skill grant matching. The decode helper is minimal, dependency-free, and correctly orders the ampersand decode last to prevent over-decoding — applied at exactly the right convergence point to fix all downstream consumers without broader refactoring.
What's good:
- ✨ Exceptionally thorough PR description that traces the full causal chain from SDK escaping through the anti-loop guard to the silent phase death, making the fix rationale unambiguous
- ✨ Decode applied at the single convergence point rather than patching multiple downstream consumers — minimal blast radius, no behavioral change elsewhere
- ✨ Ampersand-decoded-last ordering prevents over-decoding edge cases (e.g., > → > not >), with test coverage verifying this
- ✨ No external dependency introduced for a 10-line helper, respecting CONTRIBUTING guidelines
Generated by Oracle - Highflame's AI Code Reviewer
| .replace(/"/g, '"') | ||
| .replace(/'/g, "'") | ||
| .replace(/&/g, "&"); | ||
| } |
There was a problem hiding this comment.
Consider defensive handling of ' entity
The entity set (< > " ' &) correctly covers what the SDK currently emits. However, if the SDK or a future upstream change emits ' (the XML-named equivalent of '), it would silently pass through undecoded, reintroducing the same class of mismatch. Adding ' → ' before the & replacement would be a low-cost defensive measure with no downside for the current input domain:
.replace(/'/g, "'")
.replace(/'/g, "'")That said, ' is not valid HTML4 and most encoders (including what the SDK uses) prefer ', so this is purely a maintenance guard — not a current bug.
Suggested fix:
| } | |
| export function decodeStderrEntities(s: string): string { | |
| return s | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, '"') | |
| .replace(/'/g, "'") | |
| .replace(/'/g, "'") | |
| .replace(/&/g, "&"); | |
| } |
There was a problem hiding this comment.
Good call — adopted in 736236b. ' is now decoded before &, next to ' (same value, so order between those two is irrelevant; only & must stay last). Added a regression test asserting both spellings of the apostrophe map to '.
Agreed it is a guard rather than a live bug: the channel only ever shows the numeric form today. The cost is one .replace, so it is worth closing the door before an encoder swap reopens #346 silently.
The entity set covered what the SDK emits today (`< > " &highflame-ai#39; &`). `'` is the XML-named form of `&highflame-ai#39;`; it is not emitted now — `'` is not valid HTML4 and the encoder prefers the numeric form — but decoding it costs nothing and keeps a future encoder change from silently reintroducing the mismatch this fix closes (highflame-ai#346). Decoded before `&`, alongside `&highflame-ai#39;`. Regression test added. Signed-off-by: WeiYiAcc <weiyiacc@outlook.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
src/daemon/providers/claude/index.ts (~15 min) — Logic changes in index.ts
📋 PR Summary
What this PR does: Decodes HTML entities in skill-stderr at the capture point so that persisted grant keys match the rules recomputed from SKILL.md, fixing a bug where approved skill commands were silently refused on retry.
Key changes:
- Added
decodeStderrEntitieshelper that decodes lt, gt, quot, apos, and amp (amp last to prevent over-decoding of doubly-escaped sequences) - Applied decode at the single convergence point where
state.lastLocalCommandStderris assigned, ensuring all downstream consumers (persisted grant, anti-loop guard, allowedTools) use the same unescaped key
Areas affected: skill command approval/grant resolution, stderr processing in claude provider
Testing notes: Unit tests for decodeStderrEntities (including amp-last ordering), table test verifying decoded key equals recomputed rule for >-bearing commands, and provider-level park→approve→rebuild→retry test that fails without the fix (verified by revert: 114 pass / 1 fail)
🔍 Code Review
This is an exemplary targeted fix — it identifies the exact convergence point where the mismatch originates and corrects it with minimal blast radius, preserving all downstream semantics without refactoring. The amp-last decoding order and defensive handling of ' demonstrate careful reasoning about edge cases, and the provider-level test that demonstrably fails on revert provides strong correctness evidence.
What's good:
- ✨ Fixing at the single convergence point rather than patching multiple comparison sites is excellent architecture-aware reasoning — it guarantees consistency without scattering decode logic
- ✨ The amp-last decoding order to prevent over-decoding of doubly-escaped sequences shows thorough understanding of the escaping semantics
- ✨ The PR description itself is outstanding — the defect explanation, failure mode analysis (quiet failure with anti-loop guard interaction), and severity note provide exceptional context for reviewers and future readers
Generated by Oracle - Highflame's AI Code Reviewer
| .replace(/>/g, ">") | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, "'") | ||
| .replace(/'/g, "'") |
There was a problem hiding this comment.
Consider documenting the boundary of the entity set
The named entity set (< > " ' & + ') covers what the SDK emits today, and the JSDoc explains this. However, if the SDK ever emits numeric decimal entities like < (for <) or > (for >), they'd pass through undecoded and silently reintroduce the same class of mismatch. Consider adding a brief inline comment or a runtime assert/dev-mode check that logs a warning if any &#\d+; or &#x[0-9a-fA-F]+; patterns remain after decoding — not to handle them now, but to surface a future SDK change before it becomes a silent mismatch again.
|
@WeiYiAcc thank you for this fix ! |
Summary
A skill substitution command that a human approves is stored with the HTML escaping it
arrived with, so the grant never matches the rule recomputed from
SKILL.mdand thecommand stays refused. Fixes #346.
The defect
The blocked command is captured from
<local-command-stderr>, where the SDK escapes thepayload — a declaration of
!`sh x 2>/dev/null`arrives assh x 2>/dev/null.#tryHandleSkillBlockkeys the persisted grant on that string verbatim, whileskillCommandAllowRulesrecomputes the rule from the unescapedSKILL.md. The two arecompared by exact string equality, so the filter at
#resolveSkillGrantsreturns[]and
allowedToolsis built without the grant.The failure is quiet in the worst way: the approval UI reports success and the daemon
logs
skill command approved: …, but the retry is refused again. The anti-loop guardreads the same escaped key, concludes the command is already granted, and suppresses
the re-park — so the phase dies with
phase turn ended in "error"instead of recovering.The fix
Decode entities at the stderr convergence point — the one string every downstream key
(the persisted grant, the guard, and
allowedTools) derives from, so nothing elsechanges:
&is decoded last, so a doubly-escaped&gt;resolves to the literal>rather than over-decoding into
>.< > " ' &cover what the SDKemits here; a ~10-line local helper, no dependency (per CONTRIBUTING).
Test plan
decodeStderrEntitiesunit tests, including the&-last ordering that preventsover-decoding.
skillCommandAllowRulesrecomputes for a
>-bearing command.>-bearing command through park → approve → rebuild→ retry, asserting the stored key, the rebuilt
allowedTools, and a non-errorturn_done. This test fails without the decode (verified by reverting just thedecode:
114 pass / 1 fail) and passes with it.bun run typecheck(all three projects) is clean,biome checkis clean, and thesrc/tests+src/daemonsuites pass. Three failures insrc/integration/conductor-zeroid.test.tsare unrelated — they hit a live ZeroID andfail identically on unmodified
main.Note on severity
The key mismatch is static and deterministic, but it only kills the phase when the
blocked expansion leaves the turn at
num_turns: 0. #346 documents a second run whereother tools ran in the same turn and the phase survived. So a headless
pipeline run,where a skill phase is often the only thing in the turn, hits it reliably; a busy
interactive session may not. The grant is broken either way.
#346 also records three further, independent defects found alongside (skill approval has
no
timeoutMs, so it hangs forever with noui.dialogsclient attached;codeoid approvesends
approvalId: ""against amin(1)schema). Those are out of scope here and leftfor a decision.
🤖 Generated with Claude Code