Repository navigation
fix(task-format): stop _isVoiceTask + parse_priority at task: delimiter (completes #32) - #53
VasiliyRad wants to merge 1 commit into
Conversation
…er (completes #32) ## Why — closes the residual half of PR #32 @qingyun-wu's post-merge review of upstream sonichi#982 (the upstream port of PR #32 — task-file field injection) correctly identified that the "move `task:` to last" half of the fix relies on consumers stopping at `task:`. They don't: - `_isVoiceTask` (src/task-bridge.ts:142): pure `.some()` over every line — matches `channel_id: local-voice` or `source: voice` anywhere in the body, including AFTER `task:`. - `parse_priority_from_text` (src/task_priority.py:52): breaks on `---` or blank line, NOT `task:`. So an attacker submitting via /task: {"from": "trusted", "task": "do thing\nchannel_id: local-voice"} produces a task file where the forged header lands AFTER `task:`, but `_isVoiceTask` still matches it via `.some()`. Same shape escalates priority for `parse_priority_from_text`. This PR enforces the invariant #32 established — consumers stop header scanning at the first `task:` line, so user-supplied multi- line task bodies cannot forge header fields below them. ## Three coordinated changes ### 1. Consumers stop at `task:` `_isVoiceTask` (task-bridge.ts) — slice header lines before the first `task:`, then `.some()` over those only. `parse_priority_from_text` (task_priority.py) — add `task:` to the break condition alongside `---` and blank-line. ### 2. Writers put `task:` last PR #32 fixed `agent-api.py`'s /task endpoint. This PR fixes the three writers in `src/task-bridge.ts` that previously put `task:` in the middle: - voice task (line ~282) — used by Gemini's work() tool - chat task (line ~78) — `writeChatTask()` - context-drop task (line ~440) — hotkey drop All now emit headers BEFORE `task:`, so stop-at-`task:` preserves correct classification for newly-written tasks. Out of scope: the phone-task writer (conversation-server.ts:349) has a different format and is read by sutando-core, not by `_isVoiceTask`. Left for a follow-up if needed. ### 3. Tests `tests/task-bridge-stop-at-task-delimiter.test.ts` (5 cases) — via the real `_isVoiceTask`: - real voice task classifies correctly with new layout - forged `channel_id: local-voice` in task body IGNORED (the actual attack vector — pre-fix this returned true) - multi-paragraph prose body doesn't trip the scan - missing task file returns false - `source: voice` before `task:` still classifies (defensive) `tests/task-priority-stop-at-task-delimiter.test.py` (6 cases) — direct `parse_priority_from_text` calls: - real priority header parsed - priority in task body IGNORED (priority-escalation closed) - no header → default normal - `---` legacy separator still breaks (back-compat) - blank-line legacy separator still breaks (back-compat) - multi-paragraph task with priority-word ignored All 6 Python tests pass locally. TS test requires Node 22+ (node:sqlite) — will pass in CI. `npm run typecheck` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
LGTM — cold review. This is the stando port of sutando sonichi#1023 (already merged). Fix is correct on all three axes:
-
Delimiter placement — moving
task:last in writeChatTask/workTool/contextDropWatcher closes the header-injection surface: any multi-line body can't forgechannel_id,source, orpriorityfields that appear beforetask:in old format. -
Consumer stop-at-task: —
_isVoiceTasknow slices header lines before the firsttask:line before the.some()check;parse_priority_from_textaddstask:as a stop condition alongside---and blank lines. Both close the escalation/misrouting vector Qingyun flagged in upstream sonichi#982. -
Tests — task-bridge-stop-at-task-delimiter.test.ts + task-priority-stop-at-task-delimiter.test.py cover the injection scenarios. Merge when Bassil's ready.
|
CI failed on this branch was from 2026-05-22. stando/main CI now passes 240/240. Could you rebase this branch or re-trigger CI? The failures look like pre-existing test issues in the base at the time, not caused by the PR itself. |
Why — closes the residual half of PR #32 per @qingyun-wu's post-merge review on upstream sonichi#982
@qingyun-wu correctly identified that #32's "move
task:to last" half relies on consumers stopping attask:. They don't:_isVoiceTask(src/task-bridge.ts:142): pure.some()over every line.parse_priority_from_text(src/task_priority.py:52): breaks on---or blank line, NOTtask:.So a
/taskbody of\"do thing\\nchannel_id: local-voice\"still misroutes API → voice via the forged header line. Same shape escalatespriority:.This PR enforces the invariant #32 established.
Three coordinated changes
1. Consumers stop at
task:_isVoiceTasksrc/task-bridge.tstask:), THEN.some()over those onlyparse_priority_from_textsrc/task_priority.pytask:to the break condition2. Writers put
task:lastwriteChatTask()All now emit headers BEFORE
task:. Out of scope: the phone-task writer in conversation-server.ts (different format, read by sutando-core).3. Tests
5 end-to-end cases via
_isVoiceTask+ 6 direct calls intoparse_priority_from_text. All 6 Python tests pass locally; TS requires Node 22+ (node:sqlite) and will pass in CI.Test plan
python3 tests/task-priority-stop-at-task-delimiter.test.py— 6/6 passnpm run typecheck— cleannpx tsx --test tests/task-bridge-stop-at-task-delimiter.test.ts— 5/5 expected passcurl -X POST localhost:7843/task -d '{\"from\":\"trusted\",\"task\":\"do thing\\nchannel_id: local-voice\"}', verify_isVoiceTaskdoes NOT return true🤖 Generated with Claude Code