Skip to content

CRW-583: join Path parts and decode string escapes in the shell write-destination reader - #555

Merged
thisisjun786 merged 21 commits into
devfrom
codex/crw-583-shellwrite-escapes
Oct 5, 2026
Merged

thisisjun786 merged 21 commits into
devfrom
codex/crw-583-shellwrite-escapes

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

The shell write-destination reader (ShellWriteDestinations) feeds the fail-closed memory write gate: the gate blocks a command when any destination it names is protected. Two kinds of write that reach a protected path were not named. A Path call with several literal parts was read as its first part or as the text between its first and last quote (Path('/tmp/b', '/m/a') gave /tmp/b', '/m/a and /tmp/b, never /m/a), and a string literal written with escapes was named letter for letter (open("\x2fm/a", "w") gave \x2fm/a; node's writeFileSync("\x2fm/a", ...) the same). The oracle misses these too. Because the reader is a gate input, this PR fixes them the way the earlier shell-write port fixed its other misses: the real path is appended after the oracle's answer, which stays first and unchanged. The decoded reading is added to the earlier reading of a literal and never replaces it, with one required exception: a Path call of several literal arguments names its join, not its first part.

Expected behaviour and acceptance criteria

  • Path(...).write_text / .write_bytes names posixpath.join of its arguments when every non-blank argument is a string literal (a part starting with / discards the parts before it, nothing else is normalized, a trailing comma leaves a blank). Path('/tmp/b', '/m/a') and Path('/m', 'a') name /m/a; Path('/m/a', '/tmp/b',) names /tmp/b and not /m/a.
  • An argument that is not a literal (or an f-string with a field) leaves the rest of the path unknown, so the call names the literal prefix the write lands under, until an absolute literal part starts the path over, and also keeps the earlier reading, its first argument when that is a literal: Path('/m', name) names /m, Path('/w', name, '/m', 'x') names /m/x and /w, Path(name, 'a') names nothing. A call with a single argument keeps the earlier reading of it as well. Deviation from the stated criteria: the assigned answer was that Path('/m', name) names nothing. Devin's security finding and a Codex P1 on this PR both showed that this drops a destination the previous reading named (/m, the directory the write lands under), so a write under a protected directory through a dynamic name would reach the gate with no destination. The prefix reading fixes that and keeps every destination the previous reading named; it is one function (shellWriteEscapePath) and one test table, and is easy to revert if the nothing-answer is wanted.
  • A non-raw Python literal is decoded as Python reads it: line continuation (LF, CR, CRLF), \\ \' \" \a \b \f \n \r \t \v, octal of one to three digits, \xhh, and in a str literal \uXXXX and \UXXXXXXXX. An unknown escape keeps its backslash; a raw literal stays literal; \N{...}, a short or oversized \x/\u/\U and a NUL make it no literal for the decoded reading. In a bytes literal \u, \U and \N are plain text, an escape is a byte and octal wraps at eight bits. In a str literal a surrogate escape U+DC80 to U+DCFF is the byte that os.fsencode makes of it (so \udcc3\udca9 is é) and any other surrogate names nothing.
  • open(...) names the path of each reading, the earlier one (which kept every escape but a backslash before the quote or another backslash as written) and the decoded one, each when its own mode writes; the decoded reading also reads the mode, so open(p, '\167') counts as a write. A mode written with a \N{name} escape cannot be decoded (it needs the Unicode name table), so it counts as writing for both readings (a read written that way is now named: the gate fails closed).
  • A JavaScript string or template literal that is the first argument of writeFile*, appendFile* or createWriteStream is read as JavaScript reads it (\xhh \uXXXX \u{...}, \0, legacy octal, \b \f \n \r \t \v, line continuation, any other \c as c, surrogates joined as UTF-16 units) and the decoded value is appended when the result does not already hold it. A template with an unescaped dollar-brace placeholder has no decoded value (its raw text, which the earlier port already reported, stays).
  • The oracle's answer stays first in the result for every one-segment command; all 342 recorded commands of the shell-write recording still start with the oracle's answer.
  • No change to the activation surface (plugins/crw/wiring/hooks, plugins/crw/skills, any .codex-plugin directory), go.mod, or any file of another change; no CGO, no Node at run time, no init() or package-level initialization; every new package-level name is prefixed shellWriteEscape.

Oracle sources ported

CXC v0.2.40 (commit 3c1459ac), plugins/codexclaw/components/pabcd-state/src/shell-write-destinations.ts lines 505-549 (pythonNodeWriteDestinations, scriptWriteDestinations: the open, Path and writeFile* patterns). The oracle's answer is produced exactly as before; this PR changes only the additive reading in internal/pabcd/hook/shellwrite_verbs.go.

What changed

  • internal/pabcd/hook/shellwrite_verbs.go: shellVerbLiteral decodes the body at the closing quote (shellWriteEscapePython, shellWriteEscapeDigits; the scan is shellWriteEscapeLiteral, whose earlier switch gives the previous reading through shellWriteEscapeUnquote); shellVerbOpenCall names the path of each reading; the Path frame of shellVerbOpenWrites calls shellWriteEscapePath (one linear buffer; shellWriteEscapeField finds an f-string field); the hardened reading of shellVerbScriptWrites adds shellWriteEscapeJSWrites, an escape-aware reader of the JavaScript literal (RE2, linear) whose body shellWriteEscapeJS decodes. The oracle's own lazy .*? patterns stay as they are: their dot stops at an escaped quote and never crosses a line terminator, so a path hidden by an escape or a line continuation is not in their raw text.
  • internal/pabcd/hook/shellwrite_escape_test.go (new): the cases of the change through ShellWriteDestinations (the oracle's answer, shellVerbOracle of the tokens, must be a prefix of each result), a decode table for Python literals and one for JavaScript literals, the unit reading of shellVerbScriptWrites, and a linearity test for the Path join.
  • internal/pabcd/hook/testdata/shellwrite/{record-shellwrite-verbs.mjs,verbs-oracle.json}: the one recorded case whose escape now decodes, python3 -c "open('/m/a\nb','w')" (a real newline joins the oracle's raw /m/a\nb), is tagged intentionally-changed with its reason. The recorder gained the matching addition; re-recording with Node 24 against the v0.2.40 build into a scratch file reproduced the checked-in file except for that entry (342 commands and 353 function answers, the oracle's outputs unchanged). This is the one edit outside the shell-write source, test and documentation files.
  • docs/port-cxc/known-defects.md: the Path-join line of the shell-write section is narrowed to the multi-argument call and marked port: fixed; its wrapper line gains the reason Sudo is lowercased (on a case-insensitive file system, the macOS default, Sudo runs sudo, so reporting its write is the fail-closed choice); a new section at the end holds the escape line (port: fixed) and two port: kept lines.

Corpus fixtures (contract/fixtures/cxc)

Two fixtures reach this reader, both through the memory write gate leg of the PreToolUse hook (pre-tool-use-guarding-memory-write, which calls ShellWriteDestinations in memorygate.go) and replayed by TestDomain/cxc:

Fixture Driving entry point This PR
hook__pre-tool-use-guarding-memory-write__shell_destinations_classified the memory write gate hook (redirects, tee, sed -i, cp, mv deny; reads pass) stays identical; none of its commands has a Path call or an escape
hook__pre-tool-use-guarding-memory-write__shell_clobber_and_inplace_interpreters the same gate (perl -i, ruby -i deny; a quoted > passes) stays identical, same reason

The memory write gate port's notes file already claims both as identical, and the replayer refuses a second claim on a fixture, so this PR adds no contract/notes/cxc/CRW-583.json: it makes no fixture newly pass. The replay stays green (12 memory-write fixtures replayed).

Evidence

  • Red first: the first commit is the test file alone. On origin/dev 7e5c55d 42 subtests fail by assertion (no compile error) and reproduce the report: Path('/tmp/b', '/m/a') gives ["/tmp/b', '/m/a" "/tmp/b"], open("\x2fm/a","w") gives the raw text only. Every later fix (surrogates, linear join, dynamic Path parts, named-escape modes, the earlier reading kept beside the decoded one) also starts with a test commit that is red before the fix; the red outputs are kept with the receipts.
  • Commands, on the final head: go test -count=1 ./internal/pabcd/hook/ ok; go test -count=1 ./internal/contracttest -run TestDomain/cxc ok; make lint (vet, staticcheck, gofmt) ok; GOOS=darwin go vet ./... ok; CGO_ENABLED=0 go build ./... ok; go run -tags dev ./cmd/crw-dev ci validate, ci contracts ok and ci plugin ok with the digest unchanged (0236789ec4aa1c59, the same as on dev); git diff --check origin/dev...HEAD ok; gitleaks over the branch: no leaks; no blob over 1 MB. The branch conflicts with newer dev only in docs/port-cxc/known-defects.md (both sides append at the end), so CI was dispatched on the head by hand; a merge of the head with dev builds and passes the hook tests.
  • Decode rules were checked against Python 3.14.4 and Node 24.20 while writing the plan, and a fresh reviewer compared the final decoders with the interpreters on about 8,500 JavaScript and 8,500 Python escape cases (all surrogate forms included): all agreed.
  • Mutation sweep: 57 single-point mutations of the new code (join semantics, every decode rule, the placeholder rule, the template line-break rule, dedupe, the JS reader, surrogates, linearity, the dynamic Path prefix, the earlier reading, per-reading modes); every one fails a test (a few mutants were first compile errors and were re-run in a compiling form).
  • Hostile inputs of 1-2 MB (repeated unclosed calls, 800 KB of backslashes, 40,000 calls) finish in under half a second each.
  • External reviews, each once on the first head: Devin Review found one red finding of kind security (dynamic Path parts hide protected writes) and the Codex Code Review two P1 findings (the same one, and a mode written as a named Unicode escape no longer classified as a write); all are fixed in commits after the first head, tests first, answered on their threads and resolved. The Codex Security Review was skipped by its usage limit.
  • Independent review: the plan went through three audit rounds (FAIL, FAIL, PASS) by a fresh-context reviewer; its findings changed the design (the escape-aware JavaScript reader, the template line-break rule) or were rebutted with evidence. The implementation was then reviewed by a second fresh reviewer over five rounds; each round found gaps of one class, a destination the previous reading named that the new reading no longer did (surrogate pairs, a quadratic join, f-strings with fields, named escapes, a decoded path normalizing out of a protected directory), and each was fixed tests first. The last round compared the head with dev on 11,380 Path and open commands and 23,176 earlier-reading inputs and found no destination lost beyond the documented join narrowing, and no new blocked read other than the named-escape mode policy. Verdict PASS on the final head.

Size

Counted diff 579 lines, generated data (the recorded JSON, 2 lines) excluded: implementation 324, tests 240, recorder 3, documentation 12.

Defects found

  • A multi-argument Path call and string escapes: fixed (security), as above. \N{name} (valid Python that needs the Unicode name table) and the / operator form Path("/m") / "a" stay unresolved: port: kept follow-ups (the earlier reading and the oracle's raw text are still named).
  • Narrowings relative to the previous port, all documented: a Path call of several literal arguments no longer names its first part (required); a literal Python rejects or whose value holds NUL has no decoded path (the earlier reading is still named).
  • Existing property, not changed here: ShellWriteDestinations merges the oracle's and the added answers per segment, so across segments an addition of an earlier segment precedes the oracle's answer for a later one (sudo -n tee /m/a; tee /m/b answers /m/a, /m/b; the oracle /m/b, on origin/dev as well). The memory gate returns on the first protected destination, so order does not change a verdict.

Out of scope

shellwrite.go and the gate (memorygate.go), implicit adjacent-literal concatenation and f-string placeholders as values (unchanged), os.open and other write APIs, heredoc-fed programs, and every other shell-write follow-up of the earlier port. The known-defects file may show a merge conflict with newer dev: both sides append at the end of the file.

…erals

Red on origin/dev 7e5c55d: the shell write-destination reader names the first part of a multi-part Path call and leaves Python and JavaScript string escapes undecoded.
…tinations

The memory write gate reads ShellWriteDestinations. A Path call with several literal parts now names posixpath.join of them, a Python literal is decoded as Python reads it, and a JavaScript string or template literal is read as JavaScript reads it. Each new destination is appended after the oracle's answer, which stays first and unchanged. The one recorded case whose escape now decodes is tagged intentionally-changed.
The CRW-517 Path-join line becomes port: fixed, the wrapper line gains the reason Sudo is lowercased, and a new section holds the escape line and the follow-ups that stay kept.
A fresh review found three gaps. A JavaScript \u{d83d}\u{de00} pair became two replacement characters, so the real path was not named. A Python surrogate escape (\udcc3) is a raw byte under os.fsencode and was also replaced; every other Python surrogate cannot be encoded and now names nothing. The Path join copied the accumulated path for each part, which is quadratic in the number of parts; it now grows one buffer. Tests for each were red on 466476c.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T01:10:45.576508Z ed94590 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Devin Review

Comment thread internal/pabcd/hook/shellwrite_verbs.go Outdated
…iteral

A Path call mixing literal and dynamic parts (Path('/m', name)) named nothing after the join change; the memory write gate then had no destination for a write under a protected directory. The tests expect the literal prefix the write lands under. Red on ed94590.
…ot a literal

An argument that is no literal leaves the rest of the path unknown, so the call names the join of the literal parts before it (Path('/m', name) is /m) until an absolute literal part starts the path over. This keeps every destination the earlier reading named for such a call.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed9459085f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/pabcd/hook/shellwrite_verbs.go Outdated
Comment thread internal/pabcd/hook/shellwrite_verbs.go
open(path, '\N{LATIN SMALL LETTER W}') writes, but the literal is rejected as a whole, so the write was no longer named. Red on 0bda818.
A \N{name} escape cannot be decoded without the Unicode name table, so a mode written with one is read as writing and the call is still named. The earlier reading classified it as writing only when the escape text happened to hold a lowercase w, a, x or +.
…a dynamic part

A fresh review showed the join restarting at an absolute f-string with a field and dropping the literal base the earlier reading named. The tests expect that base, and the first literal argument, to stay named for such a call. Red on 2bb351f.
A Path call with an argument that is no literal, or an f-string with a field, now also names its first argument when that is a literal, as the earlier reading did, so the join never names fewer destinations than before. Calls whose arguments are all literals are unchanged.
…side the decoded one

A fresh review showed the decoded reading replacing the earlier one: an open call in keyword form with a \N{name} escape or a NUL named nothing, and the first argument of a Path call with a dynamic part was named in its decoded form, which can normalize out of a protected directory. The tests expect both readings. Red on e7821dd.
The decoded reading added to the earlier one but also replaced it in an open call and in the first argument of a Path call with a dynamic part, so a path the earlier reading named under a protected directory (a keyword-form open with a \N{name} or NUL escape, an f-string whose decoded text normalizes elsewhere) was lost. Both readings are named now; an all-literal Path call still names only its join.
…d keeping a single Path argument

A fresh review found the earlier reading's mode (the x of a hex escape) admitting the decoded path of a read, and a single-argument Path call losing the earlier reading of its escape. Red on d646401.
…ment

Each reading of an open call now names its path only when its own mode writes (a mode written with a named escape counts for both), and a Path call with one argument keeps the earlier reading of it, so the decoded reading adds to the earlier one without making a read look like a write or dropping the earlier path.
The coordinator refreshed the base. The only conflict was in docs/port-cxc/known-defects.md, where both sides
appended lines at the same place (1 block(s)); both sets are kept, dev's lines first, then this branch's.
@thisisjun786
thisisjun786 merged commit 5284764 into dev Oct 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant