mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
7fa8d44b66
commit
d87f4c33ca
3 files changed
+130
-18
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user