Skip to content

fix: path containment hardening for worktrees, fleet names, read guards and capture paths - #6678

Merged
9 commits merged into
mainfrom
fix/path-containment
Sep 28, 2026
Merged

9 commits merged into
mainfrom
fix/path-containment

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Path containment hardening for v0.10.1. Each commit is a separate change with its own regression tests.

Refs #6561

Changes

  • Sub-agent worktrees (tools/subagent/worktree.rs, subagent/mod.rs, crates/lane/src/worktree.rs)
    • Absolute worktree_path values now get the same containment as relative ones: the path must be under .codewhale-worktrees/<repo>/. The check runs on the lexical path and again after resolving the nearest existing ancestor, so a symlink under the root can't redirect the checkout.
    • A start that requests a worktree now always shows the approval card, including for read-only roles.
    • provision_worktree refuses a branch or base that starts with - and passes path and base after --. The sub-agent path also rejects such a base early as invalid input.
    • Existing tests that used absolute temp paths now use paths under the worktree root.
    • Blocking-call budget for worktree.rs goes up by one. The added canonicalize runs in the same synchronous spawn path as the existing git and canonicalize calls.
  • Fleet / router names (crates/workflow, tui/src/fleet/exact.rs)
    • New shared validate_fleet_file_stem accepts only a single normal path component: no separators, drive prefix, NUL or ...
    • split_qualified_fleet_name now validates too and is shared with the TUI loader. load_named_fleet and ReasoningRouterProfile::load_by_name use the same validator.
    • The new InvalidName error doesn't echo any of the rejected name.
  • Read guards for pandoc_convert and image_ocr (tools/file.rs, pandoc.rs, image_ocr.rs)
    • The guard sequence from read is now one helper, resolve_guarded_read_path: deny-list on the raw spelling, then resolve, then the credential check, then the deny-list on the resolved path.
    • read, read_file, pandoc_convert and image_ocr all use that helper.
    • pandoc always runs with --sandbox, built by pandoc_args() and pinned by a unit test that needs no pandoc. The flag needs pandoc 2.15 or newer: an older pandoc gets "pandoc 2.15 or newer is required (found X.Y)" and an upgrade link, and the tool and codewhale doctor install hints now name 2.15 and the pandoc.org package.
  • Computer Use capture paths (crates/tui/plugins/computer-use)
    • New src/recordings.mjs holds recordingsDir() and recordingsOutputPath(). An explicit output path must be an absolute .png/.jpg/.jpeg file inside the recordings directory, must not be a symlink, and its existing parent must resolve inside that directory. This is checked before any directory is created.
    • All four backends use it for screenshot and zoom, and zoom checks the path before anything else runs. browser-cdp.mjs and trajectory.mjs now use the same recordingsDir(), so it is the only definition (default stateDir()/recordings).
    • Each backend has a test showing that screenshot and zoom with an outside path or a non-image extension fail with bad_args, write nothing and run no command.
    • The zoom source argument is removed from the backends and stripped by the server.
    • The module is added to the embedded bundle list.
  • Skill registry sync (skills/install.rs): sync_one_skill now runs validate_skill_name_segment on the registry key before using it as a cache directory.

Verification

Each new Rust regression test was run with its fix reverted and failed. The server-side source test also failed without the server change.

  • cargo test -p codewhale-lane --lib worktree: test result: ok. 10 passed; 0 failed (with the fix reverted, 2 failed)
  • cargo test -p codewhale-workflow --lib -- named_fleet reasoning_router: test result: ok. 24 passed; 0 failed (with the fix reverted, 2 failed)
  • cargo test -p codewhale-tui --lib -- worktree read_only_role_starts write_capable_or_unproven_starts fleet::exact tools::pandoc tools::image_ocr tools::file plugins::builtin: test result: ok. 303 passed; 0 failed; 1 ignored. With the fixes reverted, the 7 new or extended tests: 7 failed.
  • cargo test -p codewhale-tui --lib -- skills::install: test result: ok. 21 passed; 0 failed (with the fix reverted, 1 failed)
  • (cd crates/tui/plugins/computer-use && npm test): tests 392, pass 376, fail 0, skipped 16. With the backends' path check replaced by the raw path, the 4 per-backend tests fail; with only zoom's check replaced, 3 fail.
  • cargo test -p codewhale-tui --lib -- tools::pandoc tools::file: test result: ok. 189 passed; 0 failed. With --sandbox removed from pandoc_args, pandoc_args_always_include_sandbox fails (0 passed; 1 failed).
  • cargo clippy -p codewhale-workflow -p codewhale-lane --all-targets -- -D warnings: clean
  • cargo fmt --all -- --check, scripts/sync-changelog.sh --check, check-blocking-calls-budget.py and check-bundled-plugin-claims.py all pass

I didn't run the full workspace suite or clippy on codewhale-tui locally because this machine is memory-limited. CI covers both.

🤖 Generated with Claude Code

CodeWhale Bot and others added 6 commits September 26, 2026 23:56
…orktree add

Cause: resolve_worktree_path applied the per-repo
.codewhale-worktrees/<repo>/ containment only to relative worktree_path
values. A read-only role start that requested a worktree did not show the
approval card. The base ref was passed to `git worktree add` without a
`--` separator.

Fix: absolute and relative worktree paths get the same containment, checked
lexically and again after resolving the nearest existing ancestor so a
symlink under the root cannot redirect the checkout. Any worktree request
keeps the approval card. provision_worktree refuses a branch or base that
starts with '-' and passes path and base after `--`; the sub-agent path also
rejects such a base up front as invalid input. Existing tests that passed
absolute temp paths now use paths under the worktree root. The blocking-call
budget for worktree.rs rises by one: the added canonicalize runs in the same
synchronous spawn path as the existing git and canonicalize calls.

Tests: new create_isolated_worktree_keeps_absolute_paths_under_worktree_root,
create_isolated_worktree_rejects_option_shaped_base_ref, approval cases in
write_capable_or_unproven_starts_keep_the_approval_gate, and lane
provision_refuses_option_shaped_base_ref /
provision_accepts_a_path_that_looks_like_an_option. All fail with the fix
reverted (lane: 2 failed; tui: 3 failed).
- cargo test -p codewhale-lane --lib worktree:
  test result: ok. 10 passed; 0 failed
- cargo test -p codewhale-tui --lib (worktree, approval, fleet::exact,
  pandoc, image_ocr, tools::file, plugins::builtin filters):
  test result: ok. 303 passed; 0 failed; 1 ignored

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cause: FleetDocument::load_by_name, load_named_fleet,
ReasoningRouterProfile::load_by_name and the TUI's load_fleet_document built
`<root>/fleets/<name>.toml` from the part after the first '/' without
checking that it was a single file name.

Fix: one validator, validate_fleet_file_stem, in the workflow crate: the name
must be a single normal path component with no separators, drive prefix, NUL
or `..`. split_qualified_fleet_name now validates and is shared with the TUI
loader, so every lookup (qualified origin, other-forms probe, NotFound
fallback) runs after the check. load_named_fleet and the router loader call
the same validator. The new InvalidName error carries no text from the name.

Tests: fleet_names_cannot_leave_the_fleets_directory,
router_names_cannot_leave_the_router_directory (workflow) and
fleet_names_that_leave_the_fleets_directory_are_refused (tui). With the
validator disabled they fail.
- cargo test -p codewhale-workflow --lib -- named_fleet reasoning_router:
  test result: ok. 24 passed; 0 failed
- tui fleet::exact is inside the focused run:
  test result: ok. 303 passed; 0 failed; 1 ignored

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cause: pandoc_convert and image_ocr resolved the model's path without the
read deny-list or the credential-store check that `read` applies, and
pandoc ran without --sandbox.

Fix: move read's guard sequence (deny-list on the raw spelling, resolve,
credential check, deny-list on the resolved path) into one helper,
file::resolve_guarded_read_path, used by read, read_file, pandoc_convert and
image_ocr. pandoc always runs with `--sandbox`; a pandoc too old for the
flag fails the call instead of running without it.

Tests: pandoc_convert_refuses_deny_listed_sources,
pandoc_convert_does_not_follow_include_directives and
image_ocr_refuses_deny_listed_paths (direct and via a workspace symlink).
All three fail with the fix reverted.
- cargo test -p codewhale-tui --lib (focused filters incl. tools::file):
  test result: ok. 303 passed; 0 failed; 1 ignored

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cause: screenshot and zoom accepted a caller-supplied `path` without a
directory check, and zoom accepted an undeclared `source` argument that the
server forwarded to the backend.

Fix: one shared src/recordings.mjs owns recordingsDir() and
recordingsOutputPath(): an explicit path must be an absolute .png/.jpg/.jpeg
file inside the recordings directory, must not be a symlink, and its
existing parent must not resolve outside the directory (checked before any
directory is created). darwin, linux, win32 and harmonyos use it for
screenshot and zoom and drop their private recordingsDir copies. Every zoom
crops the backend's last raster; `source` is gone from the backends and the
server strips it from screenshot/zoom arguments. The new module is added to
the embedded bundle list. Existing capture tests now point
CODEWHALE_CU_RECORDINGS_DIR at their temp dir.

Tests: tests/recordings-path.test.mjs (inside/outside, `..`, non-image
extensions, symlinked subdirectory and file) and a server test that
screenshot/zoom never forward `source` (fails without the server change;
the fake backend now records full arguments).
- (cd crates/tui/plugins/computer-use && npm test):
  tests 388, pass 372, fail 0
- cargo test -p codewhale-tui --lib computer_use_embed_list: ok (in the
  focused run: test result: ok. 303 passed; 0 failed; 1 ignored)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Security bullets under [Unreleased] for the worktree, fleet name, read
guard and Computer Use capture path changes; crates/tui/CHANGELOG.md
regenerated with scripts/sync-changelog.sh (--check: up to date).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ories

Cause: sync_one_skill joined the registry index key straight onto the skill
cache directory and later removed, renamed and wrote under it, while the
installed-skill path already ran validate_skill_name_segment.

Fix: run validate_skill_name_segment on the key first and report a Failed
outcome for a key that is not one path-safe segment.

Test: registry_sync_refuses_a_key_that_is_not_a_single_segment (`../escape`,
`..`, `a/b`, `/abs`); fails with the check disabled (1 failed).
- cargo test -p codewhale-tui --lib -- skills::install:
  test result: ok. 21 passed; 0 failed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 27, 2026 07:00
@Hmbown Hmbown added this to the v0.10.1 milestone Sep 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 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-09-27T07:07:26.614910Z 95c01b4 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.

@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: 95c01b493f

ℹ️ 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 on lines +51 to +53
let stat = null;
try { stat = fs.lstatSync(resolved); } catch { /* does not exist yet */ }
if (stat?.isSymbolicLink()) throw badPath("output path must not be a symlink");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject hard-linked capture targets

When an existing path inside the recordings directory is a hard link to a writable file outside it, lstatSync reports a regular file, so this check accepts it and the screenshot/zoom backend overwrites the external inode. This defeats the new containment boundary and can corrupt user files without requiring a symlink race; reject multiply linked targets or write to a new no-follow temporary file and atomically replace the directory entry.

Useful? React with 👍 / 👎.

Comment on lines +14 to +16
function inside(dir, file) {
const rel = path.relative(dir, file);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Permit in-root names beginning with two dots

For a valid path such as <recordings>/..hidden.png or <recordings>/..hidden/a.png, path.relative returns a string beginning with .., so this predicate rejects it even though it does not contain a parent-directory component and remains inside the recordings directory. Check for rel === ".." or a ..${path.sep} prefix instead of rejecting every name whose first two characters are dots.

Useful? React with 👍 / 👎.

Comment on lines +316 to +318
fn worktree_path_within_root(candidate: &Path, root: &Path) -> bool {
candidate.starts_with(root)
&& canonicalize_existing_prefix(candidate).starts_with(canonicalize_existing_prefix(root))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Anchor containment before resolving the worktree root

If .codewhale-worktrees/<repo> itself, or its .codewhale-worktrees parent, is a symlink to an outside directory, both sides of this comparison canonicalize through the same symlink, so the check returns true and git worktree add creates the checkout outside the intended root. Validate the root component against its canonical repository parent before using its canonical form as the containment boundary.

Useful? React with 👍 / 👎.

Comment on lines +58 to +59
let image_path =
crate::tools::file::resolve_guarded_read_path(context, path_str, "image_ocr")?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Move the added read guards onto the blocking pool

On a slow or unavailable filesystem, this newly expanded guard runs synchronous canonicalization and credential/read-deny checks inline before image_ocr enters its existing spawn_blocking section, so a supposedly parallel tool call can stall a Tokio worker and the UI. Resolve and guard the path inside spawn_blocking (and do the same for the new pandoc_convert call site) rather than invoking this synchronous helper directly from execute.

AGENTS.md reference: AGENTS.md:L164-L170

Useful? React with 👍 / 👎.

if (typeof file !== "string" || !path.isAbsolute(file) || file.includes("\0")) {
throw badPath("output path must be an absolute filename inside the recordings directory");
}
if (!/\.(png|jpe?g)$/i.test(file)) throw badPath("output path must end in .png, .jpg or .jpeg");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match accepted capture extensions to backend encoders

The shared validator accepts all three extensions for every backend, but several backends always emit a fixed format: HarmonyOS always pulls a JPEG even for a .png path, while Windows screenshots and Windows/macOS zooms always save PNG even for .jpg or .jpeg. Those accepted requests therefore produce files whose bytes contradict their extension, breaking consumers that select a decoder or content type from the filename; either convert according to the requested suffix or restrict each backend to the formats it actually writes.

Useful? React with 👍 / 👎.

CodeWhale Bot and others added 2 commits September 27, 2026 00:11
…in a test

Cause: pandoc_convert always passes --sandbox, which pandoc only knows from
2.15. Distro packages such as Ubuntu 22.04's 2.9.2.1 then failed every call
with pandoc's own "Unknown option --sandbox", and the doctor hint pointed
Linux users at exactly those packages. The only test covering the flag
needed pandoc installed and passed on any pandoc error, so CI without pandoc
could not notice the flag going missing. The new resolve_guarded_read_path
doc had also been placed between enforce_read_denylist's doc and its fn.

Fix: build the argument list in pandoc_args() (--sandbox first, always) and
probe `pandoc --version` once per process; below 2.15 the call fails with
"pandoc 2.15 or newer is required (found X.Y)" and an upgrade link instead
of running without the flag. The tool error and `codewhale doctor` hints now
name 2.15 and the pandoc.org release package. Doc comments in file.rs are
reattached to resolve_guarded_read_path, enforce_read_denylist and
is_codewhale_credential_path. CHANGELOG notes the 2.15 requirement.

Tests: pandoc_args_always_include_sandbox (no pandoc needed; fails with the
flag removed: test result: FAILED. 0 passed; 1 failed) and
pandoc_version_gate_matches_sandbox_release.
- CARGO_BUILD_JOBS=4 cargo test -p codewhale-tui --lib -- tools::pandoc tools::file:
  test result: ok. 189 passed; 0 failed; 0 ignored
- cargo fmt --all -- --check: clean; scripts/sync-changelog.sh --check: up to date
- scripts/check-blocking-calls-budget.py: within budget

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… path tests

Cause: browser-cdp.mjs and trajectory.mjs kept their own recordings
directory (under the state dir) while the desktop backends used
recordings.mjs (under the home dir), so captures could land in two places
when CODEWHALE_CU_STATE_DIR was set. Only recordingsOutputPath itself was
tested; no test drove a backend's screenshot or zoom with an outside path,
so a backend falling back to the raw path would not have been caught. zoom
checked for a previous raster before the output path.

Fix: recordingsDir() in recordings.mjs is the only definition (default
stateDir()/recordings, which is ~/.codewhale-cu/recordings unless the state
dir is moved) and browser-cdp and trajectory import it. darwin, linux and
win32 zoom validate the caller's output path before anything else runs.

Tests: per-backend cases in tests/recordings-path.test.mjs for darwin,
linux, win32 and harmonyos: screenshot and zoom with a path outside the
recordings directory or a non-image extension fail with bad_args, write
nothing and run no command. With the backends' recordingsOutputPath calls
replaced by the raw path: 4 failed; with only zoom's call replaced: 3 failed.
- node --test tests/recordings-path.test.mjs: tests 9, pass 9, fail 0
- (cd crates/tui/plugins/computer-use && npm test):
  tests 392, pass 376, fail 0, skipped 16
- scripts/check-bundled-plugin-claims.py: match

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Hmbown
Hmbown force-pushed the fix/path-containment branch from 95c01b4 to 7eddbe5 Compare September 27, 2026 07:12
The Windows test job failed
tools::subagent::tests::create_isolated_worktree_keeps_absolute_paths_under_worktree_root:
the worktree root comes from `git rev-parse --show-toplevel` (C:/...),
while a canonicalized request carries the \\?\ verbatim prefix, so
Path::starts_with saw different prefix components and refused a
legitimate path under the root. The same mismatch let the
"inside the parent checkout" guard silently never fire on Windows.

resolve_worktree_path now takes the part of the request below the root
via relative_to_root (strip_prefix off Windows; on Windows a
separator-, verbatim-prefix- and ASCII-case-insensitive comparison that
must end on a component boundary and refuses `..`), rebases it onto the
root as git reported it, and keeps the existing lexical + symlink-resolved
containment check. The parent-checkout guard uses the same comparison.
New unit tests exercise Windows-shaped inputs on every platform.

Verified (macOS): cargo test -p codewhale-tui --lib -- tools::subagent::worktree
tools::subagent::tests::create_isolated_worktree
tools::subagent::tests::unchanged_isolated_worktree
-> test result: ok. 11 passed; 0 failed. Windows run is CI's to confirm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 7 potential issues.

Devin Review

Comment on lines +496 to +500
const outPath = recordingsOutputPath(args.path);
await probeSession();
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.png`);
const file = outPath ?? path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.png`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 JPEG screenshots lose Linux raster geometry

When screenshot writes an explicit JPEG path on Linux, pngSize returns null for the captured JPEG. Full screenshots lose their geometry, so coordinate actions cannot target them.

Learn more

Linux screenshots use scrot, grim, or ImageMagick import through takeShot. The new path validator accepts JPEG suffixes, while pngSize only extracts dimensions from PNG files. When the capture utility writes a JPEG for the requested suffix, dims is null. A full screenshot then reports null points and pixels, preventing the server from binding usable raster geometry.

Example: With an X11 desktop, request screenshot({path: '/home/alice/.codewhale-cu/recordings/shot.jpg'}). scrot writes a JPEG, but the screenshot receipt has no pixel dimensions instead of the captured screen's dimensions.

Recommended fix: Either restrict Linux screenshot output to .png or add a JPEG dimension reader and use it when reporting lastRaster; test a real JPEG capture and subsequent coordinate mapping.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


function inside(dir, file) {
const rel = path.relative(dir, file);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Valid recordings subdirectories rejected

When recordingsOutputPath receives a file under a directory named ..drafts, inside rejects its relative path. The directory is inside recordings, but the capture fails with bad_args.

Suggested change
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
return rel !== "" && rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

if (Object.hasOwn(args, "app_ref") || Object.hasOwn(args, "window_id")) throw unsupportedSelector("Windows screenshot does not support app_ref or window_id; omit them for a desktop screenshot");
const { display = activeDisplay, region, path: outPath } = args;
const { display = activeDisplay, region } = args;
const outPath = recordingsOutputPath(args.path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 JPEG-named captures contain PNG data

When screenshot or zoom receives a .jpg path on Windows, it saves PNG bytes under the JPEG name. macOS zoom does the same, so extension-based consumers can misread these captures.

Learn more

The shared output validator now accepts PNG and JPEG names. Windows screenshot encoding saves with ImageFormat.Png, and zoom encoding also saves PNG regardless of suffix. macOS zoom forces PNG with sips -s format png. The returned file name can therefore claim a JPEG while carrying PNG bytes.

Example: A caller requests zoom({region:[0,0,100,100],path:'.../recordings/crop.jpg'}) on Windows. The file is PNG-encoded but named crop.jpg, which can fail a JPEG-only consumer.

Recommended fix: Enforce .png for PNG-only operations, or select the encoder based on the accepted output extension and test the produced file signature.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread CHANGELOG.md
Comment on lines +163 to +183
### Security

- Sub-agent worktrees stay under the per-repo `.codewhale-worktrees/<repo>/`
root: an absolute `worktree_path` is now held to the same containment as a
relative one, with symlinks resolved before the check. Any start that asks
for a worktree keeps the approval card, even for a read-only role. A
`worktree_base` starting with `-` is refused, and `git worktree add` now
receives its path and base after `--`.
- Fleet and reasoning-router names must be plain file names (optionally
`origin/name`); a name with path separators or `..` is refused before any
file is looked up, through one shared check in the workflow crate.
- `pandoc_convert` and `image_ocr` apply the same read deny-list and
credential-store checks as `read`, through one shared helper, and pandoc
always runs with `--sandbox`. This needs pandoc 2.15 or newer; an older
pandoc gets an upgrade message instead of a conversion.
- Computer Use: screenshot and zoom output paths must be `.png`/`.jpg`/`.jpeg`
files inside the recordings directory, and zoom always crops the last
captured raster instead of a caller-named source file.
- Skill registry sync refuses an index key that is not a single path-safe
name before it is used as a cache directory, the same check an installed
skill name already gets.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Move changelog entries to merge time

The contribution guide reserves both changelogs for a batched merge-time update. These PR hunks increase conflict risk and need removal before landing.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +243 to +249
let out = Command::new(pandoc)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
parse_pandoc_version(&String::from_utf8_lossy(&out.stdout))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Move pandoc subprocesses off Tokio workers

require_sandbox_support adds a synchronous process wait to an async tool handler. The existing conversion also waits synchronously; move both to the blocking pool.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +240 to +250
fn require_sandbox_support(pandoc: &str) -> Result<(), ToolError> {
static VERSION: OnceLock<Option<(u32, u32)>> = OnceLock::new();
let version = *VERSION.get_or_init(|| {
let out = Command::new(pandoc)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
parse_pandoc_version(&String::from_utf8_lossy(&out.stdout))
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Version cache ignores binary replacement

VERSION stays fixed if pandoc changes on disk during a session. The selected executable is also cached, so clarify whether live upgrades need accurate version diagnostics.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +40 to +41
fs.mkdirSync(dir, { recursive: true });
const realDir = fs.realpathSync(dir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟥 Recordings directory can escape through a symlink

When the recordings directory itself is a symlink, recordingsOutputPath validates children against its resolved target. A caller can choose an output inside that target through the configured recordings path, even when the target is outside the intended directory.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Hmbown pushed a commit that referenced this pull request Sep 27, 2026
…e/0.10.1

crates/tui/src/tools/verify.rs (run_git_diff): #6671 routed the verify diff
through tools::git::read_only_git_command (Git::review_command: no
fsmonitor, hooks or clean filters) with --no-ext-diff --no-textconv;
#6679 routed it through Git::review_command directly with the new
Git::REVIEW_DIFF_ARGS (the same two flags plus --submodule=short and
--ignore-submodules=dirty). Kept #6671's helper and availability check and
#6679's REVIEW_DIFF_ARGS, so the verify diff gets both.

crates/tui/src/skills/install.rs: tests module; kept both new tests
(#6678 registry key segment check, #6679 download cap).
crates/tui/src/tools/shell.rs: same flush() added by both with different
comments; kept one comment. tools/shell/tests.rs add-only kept both sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Hmbown Hmbown closed this pull request by merging all changes into main in 0bfe04e Sep 28, 2026
@Hmbown
Hmbown deleted the fix/path-containment branch September 28, 2026 08:40
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