Skip to content

fix(security): ADR task records stay inside the wiki root (GHSA-5w3v-7v7h-q9x4) - #941

Merged
github-actions[bot] merged 8 commits into
mainfrom
fix/ghsa-5w3v-adr-containment
Nov 7, 2026
Merged

github-actions[bot] merged 8 commits into
mainfrom
fix/ghsa-5w3v-adr-containment

Conversation

@cdeust

@cdeust cdeust commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Fix for GHSA-5w3v-7v7h-q9x4 (accepted, CVE requested). The advisory is published with 4.24.0. The fix was written by the reporter, kasparovabi, in the advisory's private fork. It is brought up to date with main here: the writer now goes through gated_files.write_text, and the containment check sits before that call.

  • record_session_end._resolve_domain passes an explicit domain through domain_id_from_label.
  • auto_task_record_writer refuses a record path that resolves outside WIKI_ROOT.

tests_py/handlers/test_auto_task_record_writer_containment.py covers a traversal domain, a symlinked domain directory and path-like variants. It fails 8 of 9 on the unfixed code and passes 9 of 9 with the fix. The 28 record_session_end and auto_task_record tests pass. ruff: clean; check_craftsmanship.py: OK.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T10:40:51.387051Z 03bf3d8 New commits
🔒 Security Review ✅ Completed 2026-11-07T10:46:11.348591Z 03bf3d8 New commits

Security findings

Advisory findings (1)

ℹ️ 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.

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 2m 43s —— View job


PR #941 review (1 of 3): done

The reviews found no Blockers. The containment fix is complete for an MCP caller:

  • An explicit domain is reduced to [a-z0-9-].
  • The writer resolves the path, which handles both .. and a symlinked domain directory, and refuses it before gated_files.write_text.

The review comment lists three Notes that need no action before merge:

  • An explicit domain is now normalized differently from resolve_explicit_domain, so cortex_v2 becomes cortex-v2.
  • _next_adr_number lists the domain directory before the containment check runs.
  • No test runs the full handler with a traversal domain.

I traced the code but did not run the tests.

Verdict: approve

@claude

claude Bot commented Nov 7, 2026

Copy link
Copy Markdown

Review 1 of 3: PR #941 (GHSA-5w3v-7v7h-q9x4)

Five foreground reviews ran: security, code quality, test coverage, documentation accuracy and performance. None found a Blocker. Reviewed at 702bb62.

Security (traced, not run): the fix is complete for an MCP caller.

  • domain_id_from_label (shared/project_ids.py:106) leaves only [a-z0-9-]. Any input that reduces to nothing becomes "unknown".
  • The writer resolves the path before checking it (auto_task_record_writer.py:223-226), so .. and a symlinked adr/<domain> are both refused before gated_files.write_text.
  • _atomic_write opens the temp file with O_NOFOLLOW, and os.replace replaces a symlink rather than writing through it.
  • maybe_write_task_record has one caller (record_session_end.py:349). Every other adr/<domain> writer already slugifies the domain or calls contained_file.

Tests:

  • The writer tests reach the new check: without it, the rglob and outside.iterdir() assertions fail.
  • No existing test breaks. The only explicit domain passed to the handler is "nonexistent", which normalization leaves unchanged.
  • Performance: two resolve() calls run once per session end, which costs nothing measurable.

Notes, no action before merge

  • record_session_end.py:181: domain_id_from_label doesn't match resolve_explicit_domain (shared/domain_mapping.py:476), which remember/recall use. An explicit cortex_v2 now becomes cortex-v2, so the profile lookup at :275 misses and the session memories land under a different domain. Consider resolve_explicit_domain plus rejecting anything that isn't a single safe path segment, and raising an error instead of substituting "unknown".
  • auto_task_record_writer.py:221: _next_adr_number lists adr/<domain> before the containment check. It only reads, and the error path leaks nothing, but checking first would close the read too. The existing safe_join/contained_file helpers could replace the hand-rolled check.
  • Tests: no end-to-end handler({"domain": "../../../x"}) test, and status == "error" could also assert "escapes wiki root" in result["reason"].

Verdict: approve

…w3v-7v7h-q9x4)

Mutation gate on #941: 3 survivors replaced the escape message (None, wrapped,
upper case) and 2 the 'unknown' fallback of an explicit domain that sanitizes
to nothing. The tests now check the reported reason exactly and that '..',
'../..' and '///' resolve to 'unknown'. Local gate (PostgreSQL 18.6): 0 judged
survivors in both files.
@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 1m 37s —— View job


PR #941 review (2 of 3): done

  • Checked the earlier Blockers: review 1 had none
  • Found the commits pushed after 702bb62: only 4a74a0a, which changes only a test file
  • Ran the security, code quality, test coverage, documentation and performance reviews on 4a74a0a
  • Run the containment tests and ruff: not done, because both commands need approval in this environment. I traced the code instead.
  • Posted the review as a top-level comment: fix(security): ADR task records stay inside the wiki root (GHSA-5w3v-7v7h-q9x4) #941 (comment)

None of the reviews found a Blocker. The new assertions match what the writer actually returns, "ValueError: task record path escapes wiki root", and an explicit domain of .., ../.. or /// falls back to "unknown". Both tests would fail if the fix were removed.

The review comment lists two Notes that need no action before merge:

  • ESCAPE has no leading underscore, unlike the file's other constant, _TRAVERSAL.
  • From review 1, still open: _next_adr_number reads the domain directory before the containment check runs.

Verdict: approve

@claude

claude Bot commented Nov 7, 2026

Copy link
Copy Markdown

Review 2 of 3: PR 941 (GHSA-5w3v-7v7h-q9x4)

Earlier Blockers: review 1 (702bb62) had none.

Scope: the only commit since 702bb62 is 4a74a0a. It changes only tests_py/handlers/test_auto_task_record_writer_containment.py; no production code. All five foreground reviews ran on it: security, code quality, test coverage, documentation and performance. None found a Blocker.

  • Exact refusal pinned: the writer raises ValueError("task record path escapes wiki root") at auto_task_record_writer.py:226. The error boundary at :241 formats it as f"{type(exc).__name__}: {exc}", which is exactly ESCAPE.
    • The traversal test and the symlink test now pass only on this specific refusal. Before, any other error caught by that boundary (an OSError, say) would also have passed.
    • Without the containment check, the writer would succeed (ensure_private_dir creates the parent directory), so both assertions would fail.
  • Fallback to "unknown": domain_id_from_label turns .., ../.. and /// into a single -, then strips it to "". _resolve_domain (record_session_end.py:181) then returns "unknown". The test would fail if either the normalization or the or "unknown" were removed.
  • Craftsmanship gate: the source: rule only applies to numeric literals, so the string constant ESCAPE does not trigger it. The test file is 105 lines.
  • Docs: the new comment, the test name and the CHANGELOG entry all match the code.
  • Performance: no new I/O or fixtures.

I traced the code but did not run pytest or ruff: this environment needs approval for those commands.

Notes, no action before merge

  • ESCAPE has no leading underscore, but the file's other constant, _TRAVERSAL, does.
  • From review 1, still open: _next_adr_number lists the files in adr/<domain> before the containment check runs. It only reads.

Verdict: approve

@claude

claude Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Claude finished @cdeust's task in 1m 27s —— View job


PR #941 review (3 of 3): done

  • Checked the earlier Blockers: reviews 1 and 2 found none
  • Found the commits pushed after 4a74a0a: only 03bf3d8, which merges origin/main at 843fdd3. git diff 843fdd3 03bf3d8 is exactly the PR's original 4 files, so the merge was clean and left the fix as it was.
  • Ran the five foreground reviews (security, code quality, test coverage, documentation, performance) on what the merge did to the PR files. None found a Blocker.
  • Checked CI: the PR #941 check passed; Pipeline was still running when I posted.
  • Posted the review as a top-level comment: fix(security): ADR task records stay inside the wiki root (GHSA-5w3v-7v7h-q9x4) #941 (comment)

Accepted: the whole PR, all 4 files. No file or commit has an open Blocker, so nothing needs a refinement PR. The review comment lists three Notes that need no action before merge.

I traced the code but did not run pytest or ruff.

Verdict: approve

@claude

claude Bot commented Nov 7, 2026

Copy link
Copy Markdown

Review 3 of 3: PR #941 (GHSA-5w3v-7v7h-q9x4)

Earlier Blockers: reviews 1 (702bb62) and 2 (4a74a0a) found none.

Scope: one commit landed after 4a74a0a: 03bf3d8, which merges origin/main (843fdd3). git diff 843fdd3 03bf3d8 is exactly the PR's original 4 files (+113/−2), so the merge was clean and changed nothing in the fix. I ran all five foreground reviews (security, code quality, test coverage, documentation, performance) on what the merge did to those files. None found a Blocker.

  • Security: the fix is still in place.
    • record_session_end.py:181 returns domain_id_from_label(domain) or "unknown".
    • auto_task_record_writer.py:223-226 resolves the path and raises ValueError("task record path escapes wiki root") before gated_files.write_text.
    • host_patch.py came in from main and touches neither path.
  • Code quality: no conflict markers or duplicated code. Both files have size and layer breaks that predate this PR, all already listed in .craftsmanship-baseline.json, so the gate is unchanged.
  • Tests: the imports, the monkeypatched WIKI_ROOT / _git_commits_in_window and the ESCAPE string all still match the code. The tests that came in from main add no shared fixtures that would affect this test.
  • Docs: the CHANGELOG entry for GHSA-5w3v appears once, reads correctly and matches the code. Main's GHSA-pw64 entry next to it is intact.
  • Performance: no change. Each session end still does one pair of resolve() calls.

I traced all of this but did not run pytest or ruff. When I posted this, the PR #941 check had passed and Pipeline was still running.

Accepted: the whole PR, all 4 files: CHANGELOG.md, mcp_server/handlers/auto_task_record_writer.py, mcp_server/handlers/record_session_end.py, tests_py/handlers/test_auto_task_record_writer_containment.py. No file or commit has an open Blocker, so nothing needs a refinement PR.

Notes, no action before merge

  • record_session_end.py:181: domain_id_from_label doesn't match resolve_explicit_domain, so an explicit cortex_v2 now becomes cortex-v2 (from review 1).
  • auto_task_record_writer.py:221: _next_adr_number lists adr/<domain> before the containment check runs. It only reads.
  • ESCAPE in the test file has no leading underscore, unlike the file's other constant, _TRAVERSAL.

Verdict: approve

@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 Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 03bf3d80fa

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

adr_num = _next_adr_number(wiki_root, domain)
record = build_task_record(inputs, adr_number=adr_num)
page_path = wiki_root / record.suggested_path
page_path = (wiki_root / record.suggested_path).resolve()

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 Security Review · Automatically triggered

P2 Badge Security: Preserve private mode when resolving the wiki path

When the global wiki path is a symlink to a group/world-readable directory, resolving the destination here before gated_files.write_text removes the lexical ~/.claude/methodology prefix used by is_private. The gate then creates auto-ADRs as 0666 minus umask—0644 under 022—whereas the parent revision wrote 0600, allowing another local account to read commit and memory excerpts through the physical target. Preserve the private classification or pass mode 0600 explicitly after containment validation.

SECURITY.md reference: SECURITY.md:L5-L16

Useful? React with 👍 / 👎.

@github-actions
github-actions Bot merged commit a5764f6 into main Nov 7, 2026
18 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.

2 participants