fix(tools): grep_files, rlm open and the working set honor the read deny-list

Three more in-process readers handed file content to the model without
resolve_guarded_read_path or enforce_read_denylist, the guard that
read, image_ocr, pandoc and (since 4fa8fdda02) validate_data, review
and verify go through:

- grep_files resolved its root with plain resolve_path. The walk skips
  denied entries, but a root that is itself a file was searched directly,
  so `grep_files {path: ".env", pattern: "."}` returned every line. The
  tool is read-only and auto-approved; it is also reached as
  `File search_content` and the MCP server's `search`.
- rlm open with file_path read the file and returned a 500-character
  preview, the same way.
- With CODEWHALE_CACHE_MAXIMAL, append_resident_file_contents embedded
  every active working-set path in model-bound turn metadata. Paths are
  recorded from tool inputs even when the read was refused, so a refused
  `read .env` put the whole file into every later request. It now calls
  enforce_read_denylist and leaves a one-line skip note with the path the
  model already named.

Found by the 2026-10-17 safety hunt, round 3. The verifiers rated each
real with high confidence; the working-set entry is low severity because
it needs the opt-in env var. Receipts:
CW/artifacts/failopen-hunt-20261017/round3.json and round3-verify.json.

Verification:
- scripts/dev-test.sh tui tools:: core:: session_manager working_set
  legacy_checkpoint subagent: 4085 run, 4077 passed. Of the 8 failures,
  5 are tests updated in the next commit and 3 are the environmental
  test_runner tests (nested cargo EPERM in this harness).
- scripts/dev-test.sh tui tools::search tools::rlm tools::fim
  tools::workflow workflow_plan_approval tui::approval: 349 passed, 0
  failed.
- clippy -p codewhale-tui --all-targets --all-features --locked with the
  CI flags: clean. cargo fmt --check: clean. Blocking-call and dead-code
  budgets pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-17 17:22:35 -07:00
1 parent b33f0e5d43
commit 864d0db02a
5 files changed
+24 -2

No files matched your search

+5
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `grep_files` (also `File search_content`) aimed at a single file, `rlm`
`open` with `file_path`, and the cache-maximal working set now honor the
read deny-list. Each read files such as `.env` past the check and returned
their lines to the model. A deny-listed working-set file is skipped with a
note.
- A `tool_call_before` hook that answers `ask` now always shows the approval
prompt, as `docs/HOOKS.md` promises. Before, an earlier "approve for
session" or a conversation grant for the same command answered it
+5
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `grep_files` (also `File search_content`) aimed at a single file, `rlm`
`open` with `file_path`, and the cache-maximal working set now honor the
read deny-list. Each read files such as `.env` past the check and returned
their lines to the model. A deny-listed working-set file is skipped with a
note.
- A `tool_call_before` hook that answers `ask` now always shows the approval
prompt, as `docs/HOOKS.md` promises. Before, an earlier "approve for
session" or a conversation grant for the same command answered it
+1 -1
View File
@@ -856,7 +856,7 @@ async fn load_source(
context: &ToolContext,
) -> Result<(String, String, Option<String>), ToolError> {
if let Some(path) = rlm_open_source_field(input, "file_path").map(str::trim) {
let resolved = context.resolve_path(path)?;
let resolved = crate::tools::file::resolve_guarded_read_path(context, path, "rlm_open")?;
let body = tokio::fs::read_to_string(&resolved).await.map_err(|e| {
ToolError::execution_failed(format!("rlm_open: read {}: {e}", resolved.display()))
})?;
+2 -1
View File
@@ -181,7 +181,8 @@ impl ToolSpec for GrepFilesTool {
.map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
// Resolve search path
let search_path = context.resolve_path(path_str)?;
let search_path =
crate::tools::file::resolve_guarded_read_path(context, path_str, "grep_files")?;
let workspace = context.workspace.clone();
let cancel_token = context.cancel_token.clone();
+11
View File
@@ -1165,6 +1165,17 @@ impl WorkingSet {
}
let abs = workspace.join(&entry.path);
if crate::tools::file::enforce_read_denylist(&abs, "working_set").is_err() {
if !header_pushed {
lines.push("### Active file contents (cache-resident)".to_string());
header_pushed = true;
}
lines.push(format!(
"<!-- file: {} (read deny-list, skipped) -->",
entry.path
));
continue;
}
let body = match std::fs::read_to_string(&abs) {
Ok(text) => text,
Err(_) => {