fix(tools): apply the read guards to pandoc_convert and image_ocr

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) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-26 23:57:04 -07:00
1 parent 7fa8d44b66
commit d87f4c33ca
3 files changed
+130 -18

No files matched your search

+25 -16
View File
@@ -436,6 +436,29 @@ fn is_config_or_backup(candidate: &Path, config_path: &Path) -> bool {
/// those tools, and the refusal is always an explicit error — never an empty
/// result, which would read as "the file is empty" and invite the model to
/// probe siblings.
/// Resolve a model-supplied path for an in-process read, applying every read
/// guard in the one safe order: the deny-list on the caller's raw spelling
/// (so a denial never names a symlink target), then `resolve_path`, then the
/// credential-store check and the deny-list again on the resolved path.
///
/// Every tool that reads a file's content in-process and hands it (or a
/// derivative) to the model goes through this.
pub(crate) fn resolve_guarded_read_path(
context: &ToolContext,
raw: &str,
tool: &str,
) -> Result<PathBuf, ToolError> {
enforce_read_denylist(Path::new(raw), tool)?;
let path = context.resolve_path(raw)?;
if is_codewhale_credential_path(&path) {
return Err(ToolError::permission_denied(format!(
"{tool} cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection"
)));
}
enforce_read_denylist(&path, tool)?;
Ok(path)
}
pub(crate) fn enforce_read_denylist(path: &Path, tool: &str) -> Result<(), ToolError> {
// Expand the user's home before authorization, retaining the spelling they
// supplied in every denial. This shares the file tools' path resolution;
@@ -763,14 +786,7 @@ impl ReadFileTool {
// raw spelling still matches by its target; the resolved check after
// `resolve_path` stays as defense in depth for callers whose process
// cwd is not the workspace.
enforce_read_denylist(Path::new(path_str), "read")?;
let file_path = context.resolve_path(path_str)?;
if is_codewhale_credential_path(&file_path) {
return Err(ToolError::permission_denied(
"read cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection",
));
}
enforce_read_denylist(&file_path, "read")?;
let file_path = resolve_guarded_read_path(context, path_str, "read")?;
check_file_operation_cancelled(context)?;
let bytes = tokio::fs::read(&file_path).await.map_err(|error| {
ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display()))
@@ -941,14 +957,7 @@ impl ToolSpec for ReadFileTool {
// S1/F2: raw spelling first, resolved path after — see the matching
// comment in `execute_contract_read`. Only the raw-spelling denial can
// promise an error that never names the symlink target's location.
enforce_read_denylist(Path::new(path_str), "read_file")?;
let file_path = context.resolve_path(path_str)?;
if is_codewhale_credential_path(&file_path) {
return Err(ToolError::permission_denied(
"File `read` cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection",
));
}
enforce_read_denylist(&file_path, "read_file")?;
let file_path = resolve_guarded_read_path(context, path_str, "read_file")?;
let pages = optional_str(&input, "pages")?;
if let Some(result) = read_pdf_if_detected(
+28 -1
View File
@@ -54,7 +54,9 @@ impl ToolSpec for ImageOcrTool {
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let path_str = required_str(&input, "path")?;
let image_path = context.resolve_path(path_str)?;
// OCR text is file content: the same read guards as `read` apply.
let image_path =
crate::tools::file::resolve_guarded_read_path(context, path_str, "image_ocr")?;
// OCR shells out to tesseract (or runs a Vision pass): the blocking
// subprocess call stays on the blocking pool (blocking-call
// convention, #6149).
@@ -359,6 +361,31 @@ mod tests {
);
}
#[tokio::test]
async fn image_ocr_refuses_deny_listed_paths() {
// `.env` is on the default read deny-list, so no global guard setup
// is needed; the refusal precedes any OCR backend.
let tmp = tempdir().expect("tempdir");
fs::copy(ocr_fixture_path(), tmp.path().join(".env")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(tmp.path().join(".env"), tmp.path().join("pic.png")).unwrap();
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut paths = vec![".env"];
if cfg!(unix) {
paths.push("pic.png");
}
for path in paths {
let err = ImageOcrTool
.execute(json!({ "path": path }), &ctx)
.await
.expect_err("a deny-listed image must be refused");
assert!(
matches!(err, ToolError::PermissionDenied { .. }),
"{path}: {err:?}"
);
}
}
#[tokio::test]
async fn image_ocr_recovers_hello_from_fixture_image() {
if !ocr_available() {
+77 -1
View File
@@ -119,7 +119,13 @@ impl ToolSpec for PandocConvertTool {
)));
}
let source_path = context.resolve_path(source_path_str)?;
// pandoc reads the source in full and its output goes to the model,
// so it takes the same read guards as `read`.
let source_path = crate::tools::file::resolve_guarded_read_path(
context,
source_path_str,
"pandoc_convert",
)?;
if !source_path.exists() {
return Err(ToolError::execution_failed(format!(
"source_path does not exist: {}",
@@ -155,6 +161,12 @@ impl ToolSpec for PandocConvertTool {
})?;
let mut cmd = Command::new(&pandoc);
// `--sandbox` stops readers and writers from touching any file but
// the named source and output: no include directives, no embedded
// images or other resources fetched from disk or the network. A
// pandoc too old to know the flag fails the call instead of running
// without it.
cmd.arg("--sandbox");
cmd.arg(&source_path);
cmd.arg("--to").arg(&target_format);
if let Some(out) = resolved_output_path.as_ref() {
@@ -262,6 +274,70 @@ mod tests {
}
}
#[tokio::test]
async fn pandoc_convert_refuses_deny_listed_sources() {
// `.env` is on the default read deny-list; the refusal comes before
// pandoc would run, so this holds with or without pandoc installed.
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join(".env"), "TOKEN=SECRET_PANDOC_VALUE\n").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(tmp.path().join(".env"), tmp.path().join("notes.md")).unwrap();
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut sources = vec![".env"];
if cfg!(unix) {
sources.push("notes.md");
}
for source in sources {
let err = PandocConvertTool
.execute(
json!({"source_path": source, "target_format": "plain"}),
&ctx,
)
.await
.expect_err("a deny-listed source must be refused");
assert!(
matches!(err, ToolError::PermissionDenied { .. }),
"{source}: {err:?}"
);
assert!(!err.to_string().contains("SECRET_PANDOC_VALUE"));
}
}
#[tokio::test]
async fn pandoc_convert_does_not_follow_include_directives() {
if !pandoc_present() {
return;
}
let outside = tempdir().expect("outside");
let secret = outside.path().join("outside.txt");
fs::write(&secret, "SECRET_INCLUDED_VALUE\n").unwrap();
let tmp = tempdir().expect("tempdir");
fs::write(
tmp.path().join("p.rst"),
format!("Intro\n\n.. include:: {}\n", secret.display()),
)
.unwrap();
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = PandocConvertTool
.execute(
json!({"source_path": "p.rst", "target_format": "plain"}),
&ctx,
)
.await;
if let Err(err) = &result
&& pandoc_environment_unavailable(err)
{
return;
}
if let Ok(result) = result {
assert!(
!result.content.contains("SECRET_INCLUDED_VALUE"),
"include directive must not pull in outside files: {}",
result.content
);
}
}
#[tokio::test]
async fn pandoc_convert_rejects_unsupported_target_format() {
let tmp = tempdir().expect("tempdir");