fix(memory): neutralize every tag spelling a reader accepts in the data boundary - #243
Merged
Zongwei9888 merged 1 commit intoSep 27, 2026
Conversation
…ta boundary `_escape_data_block` replaced four literal strings, so only the exact lower-case, space-free spellings were escaped. A note containing `</UNTRUSTED-DATA>` or `</untrusted-data >` therefore stayed in plain text, and the framed block carried a second closing tag: the remainder of the note read as if it sat outside the untrusted-data boundary (HKUDS#216). Escape the tags case-insensitively, tolerating whitespace inside the delimiters, and add a regression test next to the existing boundary test.
Collaborator
|
Merged into |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_escape_data_blockincore/harness/memory.pyneutralized the untrusted-data boundary by replacing four literal strings, so it only caught the exact lower-case, space-free spellings. A note containing</UNTRUSTED-DATA>or</untrusted-data >therefore stayed in plain text, and the framed block carried two closing tags that a reader accepts as a delimiter — the remainder of the note read as if it sat outside the untrusted-data boundary.This is the case #216 explicitly invited: "If you find a payload that the current tests do not catch, a PR adding that case to
tests/test_memory.pywould be very welcome."Changes
core/harness/memory.py— replace the literal-pair replacements with a small_DATA_BLOCK_ESCAPEStable of pre-compiled patterns, matched case-insensitively and tolerating whitespace inside the delimiters (</\s*untrusted-data\s*>,</\s*system-reminder\s*>, and their opening forms)._escape_data_blocknow rewrites every spelling a tag reader accepts into one canonical escaped form, so the framed block keeps exactly one literal closing tag: the boundary's own.tests/test_memory.py— add the regression case next to the existing boundary test.Scope decisions (why exactly this spelling set)
</UNTRUSTED-DATA>) and whitespace inside the delimiters (</untrusted-data >,</system-reminder\t>). Both are spellings an HTML-ish tag reader accepts as the same tag.</untrusted-data>), and full-width angle brackets (</untrusted-data>). No tag reader accepts either as the delimiter, and rewriting them would mutate the note's own text rather than neutralize a tag. They stay in the note verbatim; that is the intended reading, not a residual hole._escape_reminder(theAGENTS.mdframe) keeps escaping only the exact spelling. That frame carries user-authorized instruction text; the boundary is the only surface that claims untrusted-ness, so it is the only one widened here. Keeps the diff to the claim being made.Tests
assert 3 == 1—len(re.findall(r"</\s*untrusted-data\s*>", text, re.IGNORECASE))counted the forged</UNTRUSTED-DATA>, the forged</untrusted-data >and the boundary's own closing tag.pytest tests/test_memory.py -k boundary→ 2 passed; the three memory test files → 66 passed, 1 skipped (tests/test_memory.pyalone: 25 passed).uvx ruff@0.15.21 format --checkanduvx ruff@0.15.21 checkrc=0 on both files (same rev as.pre-commit-config.yaml).ci-tools/run-ci-jobs.sh, one log dir per leg, all at this revision):lint→precommit_rc=0;windows→group1_rc=0 group2_rc=0;secret-scan→gitleaks_rc=0(PR-scopedgitleaks git);desktopquality →cargotest_rc=0;desktopbundle →npmci / sidecar / tauribuild / verifybundle = 0withupload_rc=SKIPPED(locally only the GitHub artifact upload is skipped — skipped, not red).test(py3.12) → no attributable new failures, measured as a differential. Two loop-CLI tests hang under whole-suite load on both revisions, so base and patched were run with the same two nodes deselected —tests/test_loop_cli.py::test_loop_resume_active_running_goal_attaches_without_duplicate_turn,tests/test_loop_cli.py::test_loop_waits_for_the_deciding_turn_to_finish— which lets both sides finish and keeps the failure node sets comparable:114 failed / 2051 passed / 2 deselected / 4 errors(1488 s) vs base110 / 2054 / 2 / 4(1500 s): 3 patched-only nodes, all timing-sensitive tests in unrelated subsystems (app-server status probe, automation-goal ownership, transparency log), and one of them is already registered as flaky by this harness's own baseline (baselines/pytest-win-py3.12.txt:115: "两次实跑一红一绿 ⇒ 机理未定性").111 / 2054 / 2 / 4(1417 s) vs base111 / 2053 / 2 / 4(1414 s): failure node sets identical (101 nodes each;commempty in both directions) and none of repetition 1's patched-only nodes recurred.passeddiffers by exactly +1 (the new test) and wall clock by 3 s; the local failure count is itself nondeterministic (base reads 110/111/112 across runs), which is why the comparison is node-set based.test(py3.13 / py3.14) → no local baseline exists, so the harness declines to classify instead of passing silently; the hang-prone pair and the failure population are the same as base on both versions.package→ not revision-related:scripts/verify_python_distribution.pyunconditionally requires the built browser client (app_server/web_assets/web-build.json+index.html+ the assets they reference), which is a vite product (desktop/vite.config.ts:43) and gitignored. CI builds it before packaging (python-ci.yml:166 npm run build:web→:177). Locally the same revision readsverify_rc=1before that client is built andverify_rc=0(Verified deepcode-hku 2.2.0) once it is — the local red was that build state, not this change.Dependency note
No new dependencies, no new modules: two existing files only (
modify/modify), 42 insertions / 8 deletions.