fix(context-archive): mask archived credentials, scrub snapshots, bound queries

Review of #7173 (independent read-only review, Codex CLI gpt-6.1-sol, then
confirmed in source) found the archive could hand the model a credential
that `codewhale sessions scrub-secrets` was meant to remove:
- session_secret_scrub never visited <session>/artifacts/, where compaction
  writes context-transfer-*.json. session_files now includes each session's
  artifacts directory (transfer snapshots only), and scrub_files rewrites a
  snapshot atomically without a session lock: compaction writes each one
  once under a fresh name, and its stem is a checkpoint, not a session id,
  so the existing lock path would have reported it clean and left it.
- Request-time masking sees only pages and snippets, where a page boundary
  or the << >> marker can split a credential past the mask. compaction_archive
  ::load now masks each whole message with redact_model_bound_text (shapes
  plus the configured secrets scrub-secrets uses) before search or paging.
- context_archive_search accepted an unbounded query that every snippet
  repeats; queries are now limited to 200 characters.
- ACP registered both tools but its dispatch allowlist rejected them; the
  allowlist now admits these two read-only tools.
Known limits, stated in compaction_archive's header: a bare secret absent
from current configuration is not masked, and a /fork cannot read the
archive its handoff note refers to.

Checks: codewhale-tui --lib filters conformance::, compaction,
session_secret_scrub, context_archive, tools::registry, hooks::executor,
tool_catalog: 440 passed, 0 failed, 1 ignored; tools::subagent::tests 601
passed, 1 failed (late_launch_permit_still_gets_the_full_work_budget, a
4s wall-clock test that made 0 model requests under the parallel run; it
passes alone, 1 passed); core::engine test_cases_10 and host_profile
included above; feature_registry 2 passed; check-blocking-calls-budget
578 sites within budget; cargo fmt --check clean. No new tests.

Refs #7173

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-31 16:11:43 -07:00
1 parent 8f8d4632b3
commit 7d5464e3cb
4 files changed
+50 -5

No files matched your search

+9 -3
View File
@@ -21,7 +21,13 @@
//! among a million messages a birthday collision is roughly 0.2% likely, and
//! `load` keeps the first entry for a repeated id; two executions of one tool
//! call that share provider id, name and input are one entry because
//! `execution_id` and `caller` stay out of the hash.
//! `execution_id` and `caller` stay out of the hash. Each message is masked
//! whole, before search or paging, with the credential shapes and the
//! currently configured secret values that `codewhale sessions scrub-secrets`
//! uses, so a page boundary or snippet marker cannot split a credential past
//! the mask; a bare secret absent from current configuration is not masked.
//! A `/fork` starts a new session namespace, so the fork cannot read the
//! archive its inherited handoff note refers to.
use std::io::{self, Read};
use std::path::Path;
@@ -319,7 +325,7 @@ fn read_snapshot(session_id: &str, name: &str) -> io::Result<Vec<Message>> {
}
/// Load every reachable snapshot for this session, oldest first.
pub fn load(session_id: &str) -> io::Result<ArchiveLoad> {
pub fn load(session_id: &str, secrets: &[String]) -> io::Result<ArchiveLoad> {
let (snapshots, directory_present) = list_snapshots(session_id)?;
let mut budget = MAX_LOAD_BYTES;
let mut selected: Vec<&Snapshot> = Vec::new();
@@ -354,7 +360,7 @@ pub fn load(session_id: &str) -> io::Result<ArchiveLoad> {
};
for (seq, message) in messages.iter().enumerate() {
let role = message.role.as_str().to_ascii_lowercase();
let text = render_message(message);
let text = crate::client::redact_model_bound_text(&render_message(message), secrets);
if text.trim().is_empty() {
continue;
}
+31 -1
View File
@@ -9,7 +9,9 @@
//! rewrites them on request.
//!
//! Only transcript tool results, transcript tool-call arguments and Runtime
//! tool receipts (including event copies) are touched. Cleanup masks credential
//! tool receipts (including event copies) are touched, including the
//! pre-compaction snapshots under each session's artifacts directory that the
//! context archive tools read back. Cleanup masks credential
//! shapes and currently configured secret values; bare secrets absent from
//! current configuration cannot be recovered.
//! Runtime rewrites require the exclusive process lease, and session rewrites
@@ -80,6 +82,10 @@ pub(crate) fn session_files(
&& entry_has_type(&runtime, true, unreadable)
{
runtime_roots.push(runtime.path());
} else if name == crate::compaction_archive::TRANSFER_DIR_NAME
&& entry_has_type(&runtime, true, unreadable)
{
dirs.push(runtime.path());
}
}
}
@@ -93,6 +99,14 @@ pub(crate) fn session_files(
for dir in dirs {
for entry in directory_entries(&dir, unreadable) {
let path = entry.path();
if path
.parent()
.and_then(Path::file_name)
.is_some_and(|dir| dir == crate::compaction_archive::TRANSFER_DIR_NAME)
&& !is_transfer_snapshot(&path)
{
continue;
}
if !entry_has_type(&entry, false, unreadable)
|| !matches!(
path.extension().and_then(|ext| ext.to_str()),
@@ -201,6 +215,19 @@ fn is_import_archive(path: &Path, manager: &crate::session_manager::SessionManag
.is_ok_and(|archive| path.parent() == Some(archive.as_path()))
}
/// Compaction writes each pre-compaction snapshot once under a fresh name and
/// never rewrites it, so a scrub can replace it atomically without a session
/// lock; its stem is a checkpoint, not a session id.
fn is_transfer_snapshot(path: &Path) -> bool {
path.parent()
.and_then(Path::file_name)
.is_some_and(|dir| dir == crate::compaction_archive::TRANSFER_DIR_NAME)
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("context-transfer-"))
}
fn runtime_root(path: &Path) -> Option<&Path> {
let parent = path.parent()?;
matches!(parent.file_name()?.to_str()?, "items" | "events")
@@ -240,6 +267,9 @@ pub(crate) fn scrub_files(
};
scrub_file(path, true, secrets)?
}
(Some(_), FileScan::Dirty(_)) if is_transfer_snapshot(path) => {
scrub_file(path, true, secrets)?
}
(Some(manager), FileScan::Dirty(redacted)) => {
// `<id>.json` and `checkpoints/<id>.json` share the id's lock.
// The rewrite re-reads the file under that lock, so it masks
+8 -1
View File
@@ -17,6 +17,7 @@ const DEFAULT_GET_CHARS: usize = 4_000;
const MAX_GET_CHARS: usize = 12_000;
const DEFAULT_SEARCH_RESULTS: usize = 5;
const MAX_SEARCH_RESULTS: usize = 20;
const MAX_QUERY_CHARS: usize = 200;
/// Run one whole archive read on a blocking thread and keep the namespace rules
/// in one place: no namespace, no archive.
@@ -39,7 +40,8 @@ where
}
fn load(session_id: &str) -> Result<ArchiveLoad, ToolError> {
compaction_archive::load(session_id).map_err(|error| {
let secrets = crate::session_secret_scrub::configured_secrets(None, None).unwrap_or_default();
compaction_archive::load(session_id, &secrets).map_err(|error| {
ToolError::execution_failed(format!("compaction archive read failed: {error}"))
})
}
@@ -228,6 +230,11 @@ impl ToolSpec for ContextArchiveSearchTool {
ToolError::invalid_input("context_archive_search requires a non-empty query")
})?
.to_string();
if query.chars().count() > MAX_QUERY_CHARS {
return Err(ToolError::invalid_input(format!(
"context_archive_search query is limited to {MAX_QUERY_CHARS} characters"
)));
}
let skip = input.get("skip").and_then(Value::as_u64).unwrap_or(0) as usize;
let limit = input
.get("limit")
+2
View File
@@ -705,6 +705,8 @@ pub(crate) fn enforce_tool_authority(
| "apply_patch"
| "bash"
| "Bash"
| "context_archive_get"
| "context_archive_search"
) {
return Err(ToolError::not_available(format!(
"{name} is outside the ACP foreground tool profile"