mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
8f8d4632b3
commit
7d5464e3cb
4 files changed
+50
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user