Merge pull request #6096 from aboimpinto/feat/FEAT-025-adopt-command-shapes-in-tui-session-export-slice

FEAT-025: adopt capability shapes in the TUI session-export slice.

Structural migration only; no user-visible behaviour change. Merged as three
commits rather than squashed so Paulo's curated slice boundaries and authorship
survive on main.
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-16 10:07:01 -07:00
commit 9a674f47fb
30 files changed
+6380 -1973

No files matched your search

+5
View File
@@ -19,6 +19,11 @@ crates/telemetry/tests/golden/*.json text eol=lf
# always join rows with LF (golden_harness.rs::render_golden_text), so a
# CRLF checkout failed all eight Windows golden tests with a pure \r\n diff.
crates/tui/src/tui/goldens/*.txt text eol=lf
# FEAT-025 baseline export goldens (crates/tui/src/commands/fixtures/*.md) are
# include_str!()'d and compared byte for byte against a document the exporter
# builds with LF, so a CRLF checkout failed the four Windows golden tests with a
# pure \r\n diff (Windows CI caught this; Linux cannot see it).
crates/tui/src/commands/fixtures/*.md text eol=lf
crates/*/assets/**/*.json text eol=lf
crates/*/assets/**/*.md text eol=lf
crates/*/locales/*.json text eol=lf
Generated
+3
View File
@@ -874,6 +874,7 @@ name = "codewhale-command-contract"
version = "0.9.13"
dependencies = [
"codewhale-core",
"serde_json",
]
[[package]]
@@ -1057,12 +1058,14 @@ dependencies = [
"chrono",
"codewhale-paths",
"keyring",
"regex",
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.20",
"tracing",
"url",
]
[[package]]
+4
View File
@@ -12,3 +12,7 @@ workspace = true
[dependencies]
codewhale-core = { path = "../core", version = "0.9.13" }
# FEAT-025 export projections carry JSON tool payloads. The workspace already
# pins serde_json with `preserve_order` (crates/core and crates/tui), so this
# adds no new external dependency to the graph.
serde_json = { workspace = true, features = ["preserve_order"] }
+221
View File
@@ -8,6 +8,7 @@
use std::path::{Path, PathBuf};
use codewhale_core::request::{Message, SystemPrompt};
use serde_json::Value;
use crate::types::{
CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort,
@@ -1369,3 +1370,223 @@ pub trait CommandSessionControlContext {
/// errors.
fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget>;
}
// ---------------------------------------------------------------------------
// FEAT-025: session export slice (D1-D9).
//
// One independently optional session-export authority covering exactly the
// host work `/export` (and its `/daochu` alias) consumes. The shared
// `CommandSessionContext`, `CommandSessionLifecycleContext`, and
// `CommandSessionControlContext` facets are deliberately not widened: export
// authority exists only on this facet, and every delegate is an atomic host
// operation or a semantic projection so the portable handler keeps
// byte-identical composition. Hidden payloads are excluded while projections
// are built (D9), so internal reasoning, reasoning signatures, and inline or
// local image bytes never enter these DTOs. No `App`, clipboard handler,
// snapshot repository, history cell, session manager, configuration, client,
// filesystem handle, or host callback crosses this boundary (D1/D3/D5/D7).
// ---------------------------------------------------------------------------
/// Portable conversation metadata for the export header (D3).
///
/// Values that already have an authoritative host derivation keep it
/// (session-label truncation, provider identity, model label, mode display,
/// workspace basename, message count, clock); portable rendering adds only
/// export formatting and sanitization (D10).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExportMetadata {
/// Host-truncated session id, or the baseline `unsaved` fallback.
pub session_label: String,
pub provider: String,
pub model: String,
pub mode: String,
/// Workspace directory basename, or the baseline `workspace` fallback.
pub workspace_name: String,
/// `api_messages.len()` when authoritative, otherwise `history.len()`.
pub message_count: usize,
pub exported_at_unix: i64,
}
/// One tool-call caller projection (D3). Only the fields the baseline export
/// renders cross the boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolCallerProjection {
pub caller_type: String,
pub tool_id: Option<String>,
}
/// One projected content block (D3/D9).
///
/// Visible text and structured content cross as portable data; internal
/// reasoning bodies, reasoning signatures, and inline or local image payloads
/// are replaced by typed omission markers at projection time and never cross.
#[derive(Clone, Debug, PartialEq)]
pub enum ExportBlock {
/// Visible text block; portable rendering sanitizes it.
Text {
text: String,
},
/// External image reference (`http`/`https` only); portable rendering
/// redacts credential-bearing URLs.
ImageReference {
url: String,
},
/// Inline or local image payload excluded at projection time (D9).
ImageOmitted,
/// Internal reasoning body and reasoning signature excluded (D9).
InternalReasoning,
ToolCall {
id: String,
name: String,
caller: Option<ToolCallerProjection>,
input: Value,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
/// `Some` when the host message carried structured result blocks; the
/// host has already applied the safe-result filter (D9).
structured: Option<Value>,
},
ServerToolCall {
id: String,
name: String,
input: Value,
},
ToolSearchResult {
tool_use_id: String,
content: Value,
},
CodeExecutionResult {
tool_use_id: String,
content: Value,
},
}
/// One projected authoritative message (D3).
///
/// `prompt_snippet` is the host-computed `snapshot_label_prompt_snippet` of
/// the first visible text block. The parser and snippet algorithm stay
/// TUI-owned (D8), so correlation compares authoritative values instead of
/// re-deriving them portably.
///
/// `is_user_role` carries the host's exact `Role::User` comparison. `role` is
/// the rendered wire string, and comparing it textually would also match a
/// `Role::Unrecognized("user")`, which the baseline never treated as a user
/// turn. The flag keeps restore-point correlation faithful to the baseline.
#[derive(Clone, Debug, PartialEq)]
pub struct ExportMessage {
pub role: String,
/// Exact `message.role == Role::User`, not a string comparison.
pub is_user_role: bool,
pub blocks: Vec<ExportBlock>,
pub prompt_snippet: Option<String>,
}
/// One projected visible-history fallback entry (D3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HistoryEntry {
/// Visible host content that portable rendering must still sanitize.
Sanitized { role: String, body: String },
/// An already-final baseline marker line that must not be sanitized again.
Literal { role: String, body: String },
}
/// Transcript source precedence (D3): authoritative API messages when
/// present, otherwise the sanitized visible-history fallback.
#[derive(Clone, Debug, PartialEq)]
pub enum TranscriptProjection {
Authoritative(Vec<ExportMessage>),
HistoryFallback(Vec<HistoryEntry>),
}
/// One snapshot projected to semantic fields (D8).
///
/// `kind`, `sequence`, and `prompt_snippet` are the host-parsed label fields;
/// the raw `label` is kept only for the human-readable table column. No
/// preformatted correlation line crosses the boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestoreSnapshot {
pub id: String,
pub label: String,
pub timestamp_unix: i64,
pub kind: String,
pub sequence: Option<u64>,
pub prompt_snippet: Option<String>,
}
/// Restore-point projection with distinct baseline states (D3/D8).
///
/// `None` means no snapshot repository exists, `Unreadable` preserves the host
/// failure reason, and `Recorded` distinguishes an existing-but-empty
/// repository from one with snapshots by the vector length.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RestorePointProjection {
None,
Unreadable { reason: String },
Recorded { snapshots: Vec<RestoreSnapshot> },
}
/// Full conversation projection (D3/D8/D9).
#[derive(Clone, Debug, PartialEq)]
pub struct ConversationExportProjection {
pub metadata: ExportMetadata,
pub transcript: TranscriptProjection,
pub restore_points: RestorePointProjection,
}
/// Turn-handoff projection (D2).
///
/// `markdown` is the unmodified shared TUI renderer output and
/// `workspace_path` is the value the portable handler replaces with `.` after
/// sanitizing; the renderer itself is neither moved nor duplicated.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TurnHandoffProjection {
pub markdown: String,
pub workspace_path: String,
}
/// Session-export authority for the `/export` slice (FEAT-025 D1-D9).
///
/// Operation-granular synchronous delegates over the exact minimum host work
/// the command consumes. The portable handler parses the request first, renders
/// the selected scope second, and then uses these delegates in baseline order:
/// clipboard exports call terminal-paste detection, recovery write, and
/// clipboard delivery exactly once each with the same Markdown; file exports
/// resolve the destination before writing it. A recovery-write `None` never
/// prevents the clipboard attempt, and a turn-only export never requests the
/// conversation projection (D6/D7).
pub trait CommandSessionExportContext {
/// Conversation export projection: metadata, authoritative-or-fallback
/// transcript, and restore-point state. Read-only; opens only an existing
/// snapshot repository and never creates one (D8).
fn conversation_projection(&self) -> ConversationExportProjection;
/// Turn-handoff projection: unmodified shared renderer Markdown plus the
/// workspace path value (D2).
fn turn_handoff_projection(&self) -> TurnHandoffProjection;
/// Whether clipboard delivery goes through the terminal-client (SSH/OSC 52
/// via tmux) path (D6).
fn clipboard_requires_terminal_paste(&self) -> bool;
/// Write the shared `last-copy.md` recovery file. `None` reproduces the
/// baseline silent failure; recovery writing never falls through to an
/// error (D5/D6).
fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf>;
/// Attempt clipboard delivery. `Err` carries the raw host clipboard error
/// text; the handler composes the exact failure wording (D6).
fn write_clipboard(&self, markdown: &str) -> Result<(), String>;
/// Resolve a file destination exactly as the baseline does (trim, empty
/// check, `..` rejection, workspace canonicalization and rebasing, filename
/// requirement). Errors are returned unwrapped (D7).
fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String>;
/// Write the rendered export to a resolved destination with the baseline
/// protection checks. Errors are returned unwrapped; the handler wraps them
/// in `Failed to export {label} to {path}: {err}` (D7).
fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String>;
}
+39 -3
View File
@@ -7,9 +7,9 @@
use crate::facets::{
CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext,
CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext,
CommandSessionContext, CommandSessionControlContext, CommandSessionLifecycleContext,
CommandSkillGroupContext, CommandSkillsContext, CommandSystemPromptContext,
CommandWorkspaceContext,
CommandSessionContext, CommandSessionControlContext, CommandSessionExportContext,
CommandSessionLifecycleContext, CommandSkillGroupContext, CommandSkillsContext,
CommandSystemPromptContext, CommandWorkspaceContext,
};
/// Exact host capabilities exposed to one contextual command handler.
@@ -51,6 +51,30 @@ impl CommandCapabilities {
/// storage remains `u16` per the resolved maintainer review on FEAT-023 PR
/// #5902 — bit 14 is available, so no speculative widening is performed.
pub const SESSION_CONTROL: Self = Self(1 << 14);
/// Session-export host data (FEAT-025 D1), the next non-conflicting bit
/// after `SESSION_CONTROL`. Required only by the host-dependent `/export`
/// command and its `/daochu` alias; every concrete App, snapshot, clipboard,
/// filesystem, history, and turn-handoff access stays behind the TUI export
/// adapter.
///
/// **Capacity: this is the last free bit.** Bits 0-15 are now fully
/// allocated, so another capability cannot be added without widening the
/// backing storage to `u32`. FEAT-026 (session structcopy) needs its own
/// exact-minimum facet and therefore owns that widening decision; reusing
/// `SESSION_EXPORT` for it would break the least-capability invariant.
/// The `export_capability_space_is_exactly_full` test pins the capacity so
/// the next author gets a deliberate decision instead of a compile error
/// with no context.
pub const SESSION_EXPORT: Self = Self(1 << 15);
/// Raw bit pattern, for tests that pin the capability-space capacity.
///
/// Kept `#[cfg(test)]` so the `u16` backing stays an implementation detail
/// and nothing can widen it accidentally through a public accessor.
#[cfg(test)]
pub(crate) const fn bits_for_test(self) -> u16 {
self.0
}
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
@@ -100,6 +124,7 @@ pub struct CommandContexts<'a> {
plugin: Option<&'a mut dyn CommandPluginContext>,
lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>,
control: Option<&'a mut dyn CommandSessionControlContext>,
export: Option<&'a mut dyn CommandSessionExportContext>,
}
/// Consumed envelope used when one handler needs several independent facets.
@@ -119,6 +144,7 @@ pub struct ContextParts<'a> {
pub plugin: Option<&'a mut dyn CommandPluginContext>,
pub lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>,
pub control: Option<&'a mut dyn CommandSessionControlContext>,
pub export: Option<&'a mut dyn CommandSessionExportContext>,
}
impl<'a> CommandContexts<'a> {
@@ -139,6 +165,7 @@ impl<'a> CommandContexts<'a> {
plugin: None,
lifecycle: None,
control: None,
export: None,
}
}
@@ -159,6 +186,7 @@ impl<'a> CommandContexts<'a> {
plugin: self.plugin,
lifecycle: self.lifecycle,
control: self.control,
export: self.export,
}
}
@@ -278,6 +306,14 @@ impl<'a> CommandContexts<'a> {
);
self
}
pub fn with_export(mut self, value: &'a mut dyn CommandSessionExportContext) -> Self {
assert!(
self.export.replace(value).is_none(),
"export facet already set"
);
self
}
}
impl Default for CommandContexts<'_> {
+544
View File
@@ -1,3 +1,4 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use codewhale_core::request::{ContentBlock, Message, SystemPrompt};
@@ -258,15 +259,20 @@ fn new_capabilities_are_object_safe_and_independently_transportable() {
presentation(&Presentation);
media(&Media);
digest_workspace(&DigestWorkspace);
fn export(_: &dyn CommandSessionExportContext) {}
export(&FakeExport::default());
let mut presentation = Presentation;
let mut media = Media;
let mut export = FakeExport::default();
let parts = CommandContexts::empty()
.with_presentation(&mut presentation)
.with_media(&mut media)
.with_export(&mut export)
.into_parts();
assert!(parts.presentation.is_some());
assert!(parts.media.is_some());
assert!(parts.export.is_some());
assert!(parts.session.is_none());
}
@@ -2514,3 +2520,541 @@ fn control_surface_does_not_widen_session_or_lifecycle_facets() {
assert!(!parts.lifecycle.as_deref_mut().unwrap().transition_blocked());
assert!(parts.control.as_deref_mut().unwrap().transition_blocked());
}
// ---------------------------------------------------------------------------
// FEAT-025: session export contract (D1/D3/D5/D6/D7/D8/D9).
// ---------------------------------------------------------------------------
#[test]
fn export_capability_is_stable_distinct_and_non_conflicting() {
let export = CommandCapabilities::SESSION_EXPORT;
let existing = [
CommandCapabilities::SESSION,
CommandCapabilities::MODEL,
CommandCapabilities::COST,
CommandCapabilities::MODE_POLICY,
CommandCapabilities::SYSTEM_PROMPT,
CommandCapabilities::SKILLS,
CommandCapabilities::WORKSPACE,
CommandCapabilities::PRESENTATION,
CommandCapabilities::MEDIA,
CommandCapabilities::MEMORY,
CommandCapabilities::PROJECT,
CommandCapabilities::SKILL_GROUP,
CommandCapabilities::PLUGIN,
CommandCapabilities::SESSION_LIFECYCLE,
CommandCapabilities::SESSION_CONTROL,
];
let mut union = CommandCapabilities::NONE;
for capability in existing {
assert_ne!(
export, capability,
"SESSION_EXPORT must not collide with an existing capability"
);
union = union.union(capability);
}
assert!(
!union.contains(export),
"SESSION_EXPORT must be a bit outside every existing capability (bits 0-14)"
);
assert!(!CommandCapabilities::NONE.contains(export));
assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
assert!(export.contains(export));
assert!(
export
.union(CommandCapabilities::SESSION_CONTROL)
.contains(export)
);
assert!(
export
.union(CommandCapabilities::SESSION_CONTROL)
.contains(CommandCapabilities::SESSION_CONTROL)
);
assert!(!CommandCapabilities::SESSION_CONTROL.contains(export));
assert!(!export.contains(CommandCapabilities::SESSION_CONTROL));
// Storage remains `u16`-backed: bit 15 (1 << 15 = 32768) fits without the
// speculative widening FEAT-023's maintainer review ruled out.
assert_eq!(
std::mem::size_of::<CommandCapabilities>(),
std::mem::size_of::<u16>(),
"CommandCapabilities storage must stay u16"
);
}
/// Canary: after FEAT-025 the `u16` capability space is *exactly* full.
///
/// This is deliberate capacity documentation, not a health check. When FEAT-026
/// (session structcopy) adds its own facet it must widen the backing storage to
/// `u32`, and this test is expected to be updated in that commit. Until then it
/// guarantees that no capability bit is silently reused, and that anyone who
/// adds a seventeenth capability is told why `1 << 16` on a `u16` will not do.
#[test]
fn export_capability_space_is_exactly_full() {
let all = [
CommandCapabilities::SESSION,
CommandCapabilities::MODEL,
CommandCapabilities::COST,
CommandCapabilities::MODE_POLICY,
CommandCapabilities::SYSTEM_PROMPT,
CommandCapabilities::SKILLS,
CommandCapabilities::WORKSPACE,
CommandCapabilities::PRESENTATION,
CommandCapabilities::MEDIA,
CommandCapabilities::MEMORY,
CommandCapabilities::PROJECT,
CommandCapabilities::SKILL_GROUP,
CommandCapabilities::PLUGIN,
CommandCapabilities::SESSION_LIFECYCLE,
CommandCapabilities::SESSION_CONTROL,
CommandCapabilities::SESSION_EXPORT,
];
let mut union = CommandCapabilities::NONE;
for (index, capability) in all.iter().enumerate() {
assert_eq!(
capability.bits_for_test(),
1u16 << index,
"capability {index} must occupy exactly bit {index}"
);
union = union.union(*capability);
}
assert_eq!(
all.len(),
u16::BITS as usize,
"the declared capability count must consume the whole u16 space"
);
assert_eq!(
union.bits_for_test(),
u16::MAX,
"bits 0-15 are fully allocated; FEAT-026 must widen the storage to u32"
);
}
/// Deterministic fake export facet: every delegate returns canned portable
/// values or host error text, and effectful delegates record their calls so a
/// later phase can assert sequencing without a real host.
#[derive(Default)]
struct FakeExport {
projection: Option<ConversationExportProjection>,
turn: Option<TurnHandoffProjection>,
terminal_paste: bool,
recovery: Option<Option<PathBuf>>,
clipboard: Option<Result<(), String>>,
resolved: Option<Result<PathBuf, String>>,
write: Option<Result<(), String>>,
calls: RefCell<Vec<String>>,
}
impl CommandSessionExportContext for FakeExport {
fn conversation_projection(&self) -> ConversationExportProjection {
self.calls
.borrow_mut()
.push("conversation_projection".to_string());
self.projection
.clone()
.expect("unexpected conversation_projection() on empty fake")
}
fn turn_handoff_projection(&self) -> TurnHandoffProjection {
self.calls
.borrow_mut()
.push("turn_handoff_projection".to_string());
self.turn
.clone()
.expect("unexpected turn_handoff_projection() on empty fake")
}
fn clipboard_requires_terminal_paste(&self) -> bool {
self.calls
.borrow_mut()
.push("clipboard_requires_terminal_paste".to_string());
self.terminal_paste
}
fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
self.calls
.borrow_mut()
.push(format!("write_recovery_copy:{markdown}"));
self.recovery
.clone()
.expect("unexpected write_recovery_copy() on empty fake")
}
fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
self.calls
.borrow_mut()
.push(format!("write_clipboard:{markdown}"));
self.clipboard
.clone()
.unwrap_or_else(|| Err("unexpected write_clipboard() on empty fake".to_string()))
}
fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
self.calls
.borrow_mut()
.push(format!("resolve_export_path:{raw}"));
self.resolved.clone().unwrap_or_else(|| {
Err(format!(
"unexpected resolve_export_path({raw}) on empty fake"
))
})
}
fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
self.calls.borrow_mut().push(format!(
"write_export_file:{}:{}:{force}",
path.display(),
contents.len()
));
self.write
.clone()
.unwrap_or_else(|| Err("unexpected write_export_file() on empty fake".to_string()))
}
}
fn export_metadata() -> ExportMetadata {
ExportMetadata {
session_label: "abc123".to_string(),
provider: "deepseek".to_string(),
model: "deepseek-chat".to_string(),
mode: "ACT".to_string(),
workspace_name: "workspace".to_string(),
message_count: 2,
exported_at_unix: 1_760_000_000,
}
}
fn export_recorded_snapshot() -> RestoreSnapshot {
RestoreSnapshot {
id: "0123456789abcdef".to_string(),
label: "pre-turn:3: fix parser".to_string(),
timestamp_unix: 1_759_999_000,
kind: "pre-turn".to_string(),
sequence: Some(3),
prompt_snippet: Some("fix parser".to_string()),
}
}
#[test]
fn export_facet_is_object_safe_and_transports_every_outcome() {
// Object safety: usable behind a single `dyn` reference.
fn accepts_dyn(_: &dyn CommandSessionExportContext) {}
fn accepts_dyn_mut(_: &mut dyn CommandSessionExportContext) {}
let mut fake = FakeExport {
projection: Some(ConversationExportProjection {
metadata: export_metadata(),
transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
is_user_role: false,
role: "assistant".to_string(),
prompt_snippet: Some("fix parser".to_string()),
blocks: vec![
ExportBlock::Text {
text: "visible".to_string(),
},
ExportBlock::ImageReference {
url: "https://example.test/a.png".to_string(),
},
ExportBlock::ImageOmitted,
ExportBlock::InternalReasoning,
ExportBlock::ToolCall {
id: "tool-1".to_string(),
name: "read".to_string(),
caller: Some(ToolCallerProjection {
caller_type: "direct".to_string(),
tool_id: Some("caller-1".to_string()),
}),
input: serde_json::json!({"path": "a.txt"}),
},
ExportBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "ok".to_string(),
is_error: false,
structured: Some(serde_json::json!([{"type": "text", "text": "ok"}])),
},
ExportBlock::ServerToolCall {
id: "server-1".to_string(),
name: "web_search".to_string(),
input: serde_json::json!({"q": "rust"}),
},
ExportBlock::ToolSearchResult {
tool_use_id: "search-1".to_string(),
content: serde_json::json!({"results": []}),
},
ExportBlock::CodeExecutionResult {
tool_use_id: "code-1".to_string(),
content: serde_json::json!({"stdout": "hi"}),
},
],
}]),
restore_points: RestorePointProjection::Recorded {
snapshots: vec![export_recorded_snapshot()],
},
}),
turn: Some(TurnHandoffProjection {
markdown: "# turn handoff".to_string(),
workspace_path: "/workspace/example".to_string(),
}),
terminal_paste: true,
recovery: Some(Some(PathBuf::from(
"/home/u/.codewhale/exports/last-copy.md",
))),
clipboard: Some(Ok(())),
resolved: Some(Ok(PathBuf::from("/workspace/example/out.md"))),
write: Some(Ok(())),
..FakeExport::default()
};
accepts_dyn(&fake);
accepts_dyn_mut(&mut fake);
let projection = fake.conversation_projection();
assert_eq!(projection.metadata.session_label, "abc123");
assert_eq!(projection.metadata.provider, "deepseek");
assert_eq!(projection.metadata.model, "deepseek-chat");
assert_eq!(projection.metadata.mode, "ACT");
assert_eq!(projection.metadata.workspace_name, "workspace");
assert_eq!(projection.metadata.message_count, 2);
assert_eq!(projection.metadata.exported_at_unix, 1_760_000_000);
let TranscriptProjection::Authoritative(messages) = projection.transcript else {
panic!("expected authoritative transcript");
};
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "assistant");
assert_eq!(messages[0].prompt_snippet.as_deref(), Some("fix parser"));
assert_eq!(messages[0].blocks.len(), 9);
let ExportBlock::ToolCall {
caller: Some(caller),
input,
..
} = &messages[0].blocks[4]
else {
panic!("expected tool call with caller");
};
assert_eq!(caller.caller_type, "direct");
assert_eq!(caller.tool_id.as_deref(), Some("caller-1"));
assert_eq!(input["path"], "a.txt");
let ExportBlock::ToolResult {
is_error,
structured,
..
} = &messages[0].blocks[5]
else {
panic!("expected tool result");
};
assert!(!is_error);
assert!(structured.is_some());
let RestorePointProjection::Recorded { snapshots } = projection.restore_points else {
panic!("expected recorded restore points");
};
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].id, "0123456789abcdef");
assert_eq!(snapshots[0].label, "pre-turn:3: fix parser");
assert_eq!(snapshots[0].timestamp_unix, 1_759_999_000);
assert_eq!(snapshots[0].kind, "pre-turn");
assert_eq!(snapshots[0].sequence, Some(3));
assert_eq!(snapshots[0].prompt_snippet.as_deref(), Some("fix parser"));
let turn = fake.turn_handoff_projection();
assert_eq!(turn.markdown, "# turn handoff");
assert_eq!(turn.workspace_path, "/workspace/example");
assert!(fake.clipboard_requires_terminal_paste());
assert_eq!(
fake.write_recovery_copy("# md"),
Some(PathBuf::from("/home/u/.codewhale/exports/last-copy.md"))
);
assert!(fake.write_clipboard("# md").is_ok());
assert_eq!(
fake.resolve_export_path("out.md").expect("resolved"),
PathBuf::from("/workspace/example/out.md")
);
assert!(
fake.write_export_file(Path::new("/workspace/example/out.md"), b"# md", false)
.is_ok()
);
// Effectful delegates were exercised exactly once each, in call order.
let expected: Vec<String> = [
"conversation_projection",
"turn_handoff_projection",
"clipboard_requires_terminal_paste",
"write_recovery_copy:# md",
"write_clipboard:# md",
"resolve_export_path:out.md",
"write_export_file:/workspace/example/out.md:4:false",
]
.into_iter()
.map(str::to_string)
.collect();
assert_eq!(fake.calls.borrow().as_slice(), expected.as_slice());
}
#[test]
fn export_error_and_empty_states_transport_exactly() {
let fake = FakeExport {
projection: Some(ConversationExportProjection {
metadata: export_metadata(),
transcript: TranscriptProjection::HistoryFallback(vec![
HistoryEntry::Sanitized {
role: "user".to_string(),
body: "visible history".to_string(),
},
HistoryEntry::Literal {
role: "system".to_string(),
body: "[internal context omitted]".to_string(),
},
]),
restore_points: RestorePointProjection::Unreadable {
reason: "permission denied".to_string(),
},
}),
recovery: Some(None),
clipboard: Some(Err("clipboard unavailable".to_string())),
resolved: Some(Err("export paths may not contain `..`".to_string())),
write: Some(Err("destination already exists".to_string())),
..FakeExport::default()
};
let projection = fake.conversation_projection();
let TranscriptProjection::HistoryFallback(entries) = projection.transcript else {
panic!("expected history fallback");
};
assert_eq!(entries.len(), 2);
assert!(matches!(
&entries[0],
HistoryEntry::Sanitized { role, body }
if role == "user" && body == "visible history"
));
assert!(matches!(
&entries[1],
HistoryEntry::Literal { role, body }
if role == "system" && body == "[internal context omitted]"
));
let RestorePointProjection::Unreadable { reason } = projection.restore_points else {
panic!("expected unreadable restore points");
};
assert_eq!(reason, "permission denied");
assert!(!fake.clipboard_requires_terminal_paste());
assert_eq!(fake.write_recovery_copy("# md"), None);
assert_eq!(
fake.write_clipboard("# md").unwrap_err(),
"clipboard unavailable"
);
assert_eq!(
fake.resolve_export_path("../out.md").unwrap_err(),
"export paths may not contain `..`"
);
assert_eq!(
fake.write_export_file(Path::new("/tmp/out.md"), b"x", false)
.unwrap_err(),
"destination already exists"
);
}
#[test]
fn export_projection_distinguishes_restore_states() {
let states = [
RestorePointProjection::None,
RestorePointProjection::Unreadable {
reason: "boom".to_string(),
},
RestorePointProjection::Recorded { snapshots: vec![] },
RestorePointProjection::Recorded {
snapshots: vec![export_recorded_snapshot()],
},
];
assert!(matches!(&states[0], RestorePointProjection::None));
assert!(matches!(
&states[1],
RestorePointProjection::Unreadable { reason } if reason == "boom"
));
let RestorePointProjection::Recorded { snapshots } = &states[2] else {
panic!("expected recorded state");
};
assert!(snapshots.is_empty(), "existing-but-empty stays distinct");
let RestorePointProjection::Recorded { snapshots } = &states[3] else {
panic!("expected recorded state");
};
assert_eq!(snapshots.len(), 1);
}
#[test]
fn export_projection_omission_markers_carry_no_hidden_payload() {
// D9: the projection has no field for a reasoning body, reasoning
// signature, or inline/local image payload. Omission markers are data-free
// unit variants, so prohibited payloads cannot be transported even by
// accident.
let block = ExportBlock::InternalReasoning;
let ExportBlock::InternalReasoning = block else {
panic!("internal reasoning must be a payload-free marker");
};
let block = ExportBlock::ImageOmitted;
let ExportBlock::ImageOmitted = block else {
panic!("omitted image must be a payload-free marker");
};
const HIDDEN_REASONING: &str = "signed-thinking-secret-body";
const HIDDEN_SIGNATURE: &str = "sig_1234567890";
const HIDDEN_IMAGE: &str = "data:image/png;base64,QUJD";
let projection = ConversationExportProjection {
metadata: export_metadata(),
transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
is_user_role: false,
role: "assistant".to_string(),
prompt_snippet: None,
blocks: vec![ExportBlock::InternalReasoning, ExportBlock::ImageOmitted],
}]),
restore_points: RestorePointProjection::None,
};
let rendered = format!("{projection:?}");
assert!(!rendered.contains(HIDDEN_REASONING));
assert!(!rendered.contains(HIDDEN_SIGNATURE));
assert!(!rendered.contains(HIDDEN_IMAGE));
assert!(rendered.contains("InternalReasoning"));
assert!(rendered.contains("ImageOmitted"));
}
#[test]
fn envelope_export_slot_is_independent_and_rejects_duplicates() {
let mut first = FakeExport::default();
let mut second = FakeExport::default();
let mut control = FakeControl::default();
let parts = CommandContexts::empty()
.with_export(&mut first)
.with_control(&mut control)
.into_parts();
assert!(
parts.export.is_some(),
"export slot must be present when declared"
);
assert!(
parts.control.is_some(),
"control slot may coexist with export"
);
assert!(
parts.session.is_none()
&& parts.lifecycle.is_none()
&& parts.plugin.is_none()
&& parts.skill_group.is_none(),
"unrelated slots must stay absent (exact exposure)"
);
let bare = CommandContexts::empty().into_parts();
assert!(bare.export.is_none(), "undeclared export stays absent");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
CommandContexts::empty()
.with_export(&mut first)
.with_export(&mut second);
}));
assert!(
result.is_err(),
"duplicate export slot must assert deterministically"
);
// Reading through the dyn facet works after insertion.
let mut projection = FakeExport {
terminal_paste: true,
..FakeExport::default()
};
let inserted = CommandContexts::empty().with_export(&mut projection);
let export = inserted.into_parts().export.expect("inserted export");
assert!(export.clipboard_requires_terminal_paste());
}
+8 -591
View File
@@ -230,597 +230,14 @@ fn rollback(snapshots: &[Snapshot]) {
}
}
/// Hints that mark a config/JSON/env key as carrying a secret value.
///
/// Compound hints (`api_key`, `client_secret`) match as a substring of the
/// normalized key. Single-word hints (`token`, `secret`, `password`) match a
/// whole identifier segment so they describe a credential (`token`,
/// `api_token`) and not an English word (`tokens`, `tokenizer`).
const SENSITIVE_KEY_HINTS: &[&str] = &[
"api_key",
"apikey",
"api-key",
"secret",
"token",
"password",
"passwd",
"authorization",
"auth_token",
"access_key",
"client_secret",
"private_key",
];
/// Known opaque-token prefixes worth masking even when they appear bare (not as
/// `key = value`). Conservative on purpose: only well-known provider/key shapes.
const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
/// The placeholder substituted for any redacted secret value.
pub const REDACTED: &str = "[redacted]";
/// Return a copy of a JSON value with secret-bearing data removed.
///
/// Object values whose key contains a sensitive hint are replaced wholesale,
/// while all other objects and arrays are traversed recursively. String leaves
/// still pass through [`redact_secrets`] so bare provider tokens and embedded
/// assignments remain covered without treating the serialized JSON document as
/// one flat keyed assignment.
#[must_use]
pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(object) => serde_json::Value::Object(
object
.iter()
.map(|(key, value)| {
let value = if key_is_sensitive(key) {
serde_json::Value::String(REDACTED.to_string())
} else {
redact_json_secrets(value)
};
(key.clone(), value)
})
.collect(),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(redact_json_secrets).collect())
}
serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)),
scalar => scalar.clone(),
}
}
/// Redact secret-bearing values from arbitrary text so it is safe to put in a
/// setup report, log line, error message, or test snapshot.
///
/// Two passes, both dependency-free:
///
/// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
/// like `key = value`, `key: value`, or `key=value` whose key
/// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS`
/// credential identifier have their value replaced with [`REDACTED`]. The
/// spaced form (`key = value`) is matched anywhere on the line, not only
/// when the sensitive key owns the line's first separator — an `anyhow`
/// chain rendered with `{:#}` puts prose and its own `: ` separators in
/// front of the assignment, and that must not be a hole. Because such a
/// value can span several words (`authorization = Bearer <token>`),
/// everything from the value to the end of the line is dropped, exactly as
/// the whole-line form already does. Token *counts* in diagnostics
/// (`max tokens = 8192`) are not credentials and stay visible.
/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
/// `SECRET_TOKEN_PREFIXES` are replaced wholesale.
///
/// The goal is defense in depth: setup state and reports are built from safe
/// summaries that never include secrets in the first place, and this is the
/// backstop for anything that echoes raw config text.
#[must_use]
pub fn redact_secrets(input: &str) -> String {
redact_secrets_with(input, RedactionPolicy::KeyBased)
}
/// How aggressively [`redact_secrets_with`] treats a sensitive-looking key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedactionPolicy {
/// Mask the value of every sensitive-looking key, whatever the value is.
/// Right for logs, previews, exports, and diagnostics: a false positive
/// costs nothing there and a miss leaks a credential.
KeyBased,
/// Mask a keyed value only when the value itself looks like a credential
/// (known prefix, JWT, bearer token, PEM block, long opaque string).
/// Right for text the model must be able to quote back byte-for-byte,
/// such as tool results that feed exact-match edits: `password:
/// credentials?.password`, `"password-validator": "^5.3.0"`, or
/// `token = make_token()` are code, not secrets (#5546).
CredentialShaped,
}
/// Redact model-bound tool output: exact configured credential values are the
/// caller's job; this masks only values that look like credentials so the
/// model keeps seeing the real bytes of ordinary code and config.
#[must_use]
pub fn redact_model_bound_secrets(input: &str) -> String {
redact_secrets_with(input, RedactionPolicy::CredentialShaped)
}
/// [`redact_secrets`] with an explicit [`RedactionPolicy`].
#[must_use]
pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String {
let mut out = String::with_capacity(input.len());
let mut in_private_key_block = false;
for line in input.split_inclusive('\n') {
// split_inclusive keeps the newline on the previous chunk, so we do
// not need to re-add separators here.
let body = line.strip_suffix('\n').unwrap_or(line);
let trimmed = body.trim();
if in_private_key_block {
if trimmed.starts_with("-----END") {
in_private_key_block = false;
out.push_str(line);
} else {
out.push_str(REDACTED);
if line.ends_with('\n') {
out.push('\n');
}
}
continue;
}
if is_private_key_block_start(trimmed) {
in_private_key_block = true;
out.push_str(line);
continue;
}
out.push_str(&redact_line(line, policy));
}
out
}
fn is_private_key_block_start(trimmed: &str) -> bool {
trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY")
}
/// Redact a single line (which may include a trailing newline).
fn redact_line(line: &str, policy: RedactionPolicy) -> String {
// Preserve any trailing newline so callers keep their line structure.
let (body, newline) = match line.strip_suffix('\n') {
Some(rest) => (rest, "\n"),
None => (line, ""),
};
if let Some(redacted) = redact_keyed_assignment(body, policy) {
return format!("{redacted}{newline}");
}
// Inline-assignment / bare-token pass: mask any whitespace-delimited word
// carrying a sensitive keyed value or a known bare secret prefix, plus the
// spaced `key = value` form that `redact_keyed_assignment` above only sees
// when the sensitive key owns the line's first separator.
let mut changed = false;
let mut spaced = SpacedAssignment::None;
let mut masked: Vec<String> = Vec::new();
for word in body.split(' ') {
let trimmed = trim_word_punctuation(word);
if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() {
match policy {
RedactionPolicy::KeyBased => {
// The value may run to the end of the line, so drop the
// remainder rather than masking one word and leaking the
// rest.
masked.push(REDACTED.to_string());
changed = true;
break;
}
RedactionPolicy::CredentialShaped => {
// Only a credential-shaped value is hidden, and only that
// word: the rest of the line stays quotable. An auth scheme
// word (`Bearer`) keeps the assignment open for its token.
if is_auth_scheme_word(trimmed) {
masked.push(word.to_string());
continue;
}
if value_looks_like_credential(trimmed) {
masked.push(word.replace(trimmed, REDACTED));
changed = true;
} else {
masked.push(word.to_string());
}
spaced = SpacedAssignment::None;
continue;
}
}
}
if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) {
changed = true;
masked.push(word.replace(trimmed, &redacted));
spaced = SpacedAssignment::None;
} else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
masked.push(word.replace(trimmed, REDACTED));
spaced = SpacedAssignment::None;
} else {
masked.push(word.to_string());
spaced = spaced.advance(trimmed);
}
}
if changed {
format!("{}{newline}", masked.join(" "))
} else {
format!("{body}{newline}")
}
}
/// Progress through a `key <space> <sep> <space> value` assignment as the
/// word-level pass walks a line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpacedAssignment {
None,
/// The previous word was a bare sensitive key awaiting its separator.
SensitiveKey,
/// A sensitive key and its separator are both behind us.
AwaitingValue,
}
impl SpacedAssignment {
fn advance(self, trimmed: &str) -> Self {
// Runs of spaces produce empty words; they neither start nor cancel an
// assignment.
if trimmed.is_empty() {
return self;
}
if matches!(trimmed, "=" | ":") {
return if self == Self::SensitiveKey {
Self::AwaitingValue
} else {
Self::None
};
}
// `api_key=` / `api_key:` with the value in the next word. A word whose
// separator is *not* final was already offered to
// `redact_inline_keyed_assignment`, so it is not an assignment we own.
if let Some(key) = trimmed
.strip_suffix('=')
.or_else(|| trimmed.strip_suffix(':'))
{
return if key_is_sensitive(key) {
Self::AwaitingValue
} else {
Self::None
};
}
if key_is_sensitive(trimmed) {
return Self::SensitiveKey;
}
Self::None
}
}
fn trim_word_punctuation(word: &str) -> &str {
word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'))
}
/// Whether `raw`, normalized the way a config/env/JSON key is, matches a
/// [`SENSITIVE_KEY_HINTS`] credential identifier.
fn key_is_sensitive(raw: &str) -> bool {
let key_norm = normalize_sensitive_key(raw);
!key_norm.is_empty()
&& SENSITIVE_KEY_HINTS
.iter()
.any(|hint| key_matches_sensitive_hint(&key_norm, hint))
}
/// Normalize the identifier boundaries commonly used by config, env, and JSON
/// keys without turning English plurals such as `tokens` into `token`.
///
/// Punctuation and case transitions become `_`, so `oauth.token`,
/// `accessToken`, and `APIKey` share the same matching surface as
/// `oauth_token`, `access_token`, and `api_key`.
fn normalize_sensitive_key(raw: &str) -> String {
let mut normalized = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut previous = None;
while let Some(ch) = chars.next() {
if ch.is_ascii_alphanumeric() {
let next = chars.peek().copied();
let starts_case_segment = ch.is_ascii_uppercase()
&& previous.is_some_and(|previous: char| {
previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase()
&& next.is_some_and(|next| next.is_ascii_lowercase()))
});
if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
normalized.push(ch.to_ascii_lowercase());
} else if !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
previous = Some(ch);
}
while normalized.ends_with('_') {
normalized.pop();
}
normalized
}
fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool {
if key_norm == hint {
return true;
}
// Compound hints already name a credential (`api_key`, `client_secret`).
// Substring is the right match: `openai_api_key` contains `api_key`.
if hint.contains('_') || hint.contains('-') {
return key_norm.contains(hint);
}
if hint == "token" {
// Camel-case normalization turns both credentials (`accessToken`) and
// ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented
// identifiers. A credential token is either the whole key, a suffix
// such as `access_token`, or an explicitly value-bearing `token_*`
// field. Metrics must stay visible in diagnostics and tool previews.
let is_metric_suffix = |suffix: &str| {
matches!(
suffix.split('_').next(),
Some(
"budget"
| "budgets"
| "count"
| "counts"
| "limit"
| "limits"
| "total"
| "totals"
| "usage"
| "used"
| "window"
| "windows"
)
)
};
if key_norm.ends_with("_token") {
return true;
}
if let Some(suffix) = key_norm.strip_prefix("token_") {
return !is_metric_suffix(suffix);
}
if let Some((_, suffix)) = key_norm.rsplit_once("_token_") {
return !is_metric_suffix(suffix);
}
return false;
}
// Single-word hints must be a whole identifier segment so `token`
// redacts `token` / `api_token` and not English `tokens`.
key_norm.split(['_', '-']).any(|segment| segment == hint)
}
fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option<String> {
let sep_idx = word.find(['=', ':'])?;
let (raw_key, rest) = word.split_at(sep_idx);
let raw_value = &rest[1..];
if raw_value.is_empty() {
return None;
}
if !key_is_sensitive(raw_key) {
return None;
}
match policy {
RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)),
RedactionPolicy::CredentialShaped => {
let (core, quote) = strip_value_quotes(raw_value);
if !value_looks_like_credential(core) {
return None;
}
Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1]))
}
}
}
/// Whether a word announces an HTTP auth scheme whose credential follows.
fn is_auth_scheme_word(word: &str) -> bool {
matches!(
word,
"Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token"
)
}
/// Split a matching pair of surrounding quotes off a value, returning the
/// inner text and the quote to restore (empty when unquoted or unbalanced).
fn strip_value_quotes(value: &str) -> (&str, &str) {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return (&value[1..value.len() - 1], &value[..1]);
}
}
// A leading quote without its partner (the word pass strips the outer
// punctuation of `"x",` to `"x`): treat the remainder as the value.
if let Some(inner) = value.strip_prefix(['"', '\'']) {
return (inner, "");
}
(value, "")
}
/// Extra bare prefixes that mark a value as a credential even though they are
/// too product-specific to mask as standalone words in prose.
const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[
"sk-ant-",
"AKIA",
"ASIA",
"AIza",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"github_pat_",
"glpat-",
"xoxa-",
"xoxb-",
"xoxp-",
"xoxr-",
"xoxs-",
"npm_",
"ya29.",
];
/// Whether a keyed value looks like credential material rather than code,
/// configuration, or prose.
///
/// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM
/// headers, and long opaque alphanumeric runs. False for short literals,
/// version strings, identifiers, property/call/env references, and the
/// redaction placeholder itself.
pub(crate) fn value_looks_like_credential(value: &str) -> bool {
let value = value
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
if value.is_empty() || value == REDACTED {
return false;
}
if looks_like_secret_token(value)
|| CREDENTIAL_VALUE_PREFIXES
.iter()
.any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix))
{
return true;
}
if value.starts_with("-----BEGIN") {
return true;
}
if let Some((scheme, rest)) = value.split_once(' ')
&& is_auth_scheme_word(scheme)
{
return value_looks_like_credential(rest);
}
if is_jwt_shaped(value) {
return true;
}
if value.len() < 16 {
return false;
}
if is_version_like(value) || is_reference_like(value) {
return false;
}
is_opaque_run(value)
}
fn is_jwt_shaped(value: &str) -> bool {
let mut parts = value.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(header), Some(payload), Some(signature), None) => {
header.starts_with("eyJ")
&& payload.starts_with("eyJ")
&& !signature.is_empty()
&& [header, payload, signature].iter().all(|part| {
part.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
_ => false,
}
}
fn is_version_like(value: &str) -> bool {
let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']);
!digits.is_empty()
&& digits
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
&& digits.chars().next().is_some_and(|c| c.is_ascii_digit())
}
fn is_reference_like(value: &str) -> bool {
// Property access, calls, template/env lookups, and plain identifiers are
// code, not credential material.
value.contains("?.")
|| value.contains('(')
|| value.contains("${")
|| value.contains("process.env")
|| value.contains("os.environ")
|| value.contains("getenv")
|| value.contains("://")
|| value
.chars()
.all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.')
}
fn is_opaque_run(value: &str) -> bool {
value.len() >= 20
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
&& value.chars().any(|c| c.is_ascii_alphabetic())
&& value.chars().any(|c| c.is_ascii_digit())
}
/// If `body` is a `key <sep> value` assignment with a sensitive key, return the
/// line with the value redacted; otherwise `None`.
fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
// Find the first `=` or `:` that separates a key from a value.
let sep_idx = body.find(['=', ':'])?;
let (raw_key, rest) = body.split_at(sep_idx);
let sep = &rest[..1];
let raw_value = &rest[1..];
let key_norm = raw_key
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'));
if !key_is_sensitive(key_norm) {
return None;
}
if policy == RedactionPolicy::CredentialShaped {
// Replace only the value span, keep the key bytes, separator spacing,
// quote style, and trailing punctuation, and only when the value is
// credential-shaped: the model must still be able to quote the line.
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
let value_core = value_rest.trim_end();
let trailing_ws = &value_rest[value_core.len()..];
let literal = value_core.trim_end_matches([',', ';']);
let trailer = &value_core[literal.len()..];
let (core, quote) = strip_value_quotes(literal);
if core.is_empty() || !value_looks_like_credential(core) {
return None;
}
return Some(format!(
"{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}"
));
}
// Keep leading whitespace of the key and the original separator spacing so
// the redacted line reads naturally.
let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
// If the value is empty, there is nothing to hide.
if value_rest.is_empty() {
return None;
}
// Preserve surrounding quotes so structured files stay parseable-looking.
let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
let replacement = if quoted {
format!("\"{REDACTED}\"")
} else {
REDACTED.to_string()
};
Some(format!(
"{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
raw_key.trim()
))
}
fn looks_like_secret_token(word: &str) -> bool {
SECRET_TOKEN_PREFIXES
.iter()
.any(|p| word.len() > p.len() + 6 && word.starts_with(p))
}
// FEAT-025 D4: the pure secret-redaction primitives moved to
// `codewhale-secrets::redact` so portable command helpers can share one
// implementation without depending on this crate. Re-exported here to keep
// the existing `codewhale_config::persistence::*` public API stable.
pub use codewhale_secrets::redact::{
REDACTED, RedactionPolicy, redact_json_secrets, redact_model_bound_secrets, redact_secrets,
redact_secrets_with,
};
#[cfg(test)]
mod tests {
+8 -2
View File
@@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Secret storage backends for Codewhale, with OS keyring and file fallback"
description = "Secret storage backends for Codewhale, with OS keyring and file fallback, plus the shared pure secret-redaction and output-sanitization primitives"
[lints]
workspace = true
@@ -13,12 +13,18 @@ workspace = true
[dependencies]
codewhale-paths = { path = "../paths", version = "0.9.13" }
chrono.workspace = true
# FEAT-025 D4: the portable output sanitizer owns the regex-based redaction
# passes here so config diagnostics and command helpers share one copy.
regex = "1.11"
serde = { workspace = true }
serde_json = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
sha2.workspace = true
tempfile.workspace = true
thiserror = { workspace = true }
tracing = { workspace = true }
# FEAT-025 D4: URL credential redaction uses the same `url` parser reqwest
# re-exports, with no HTTP client dependency in this pure crate.
url = "2.5"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
+24 -2
View File
@@ -1,6 +1,7 @@
//! Secret storage for CodeWhale API keys.
//! Secret storage for CodeWhale API keys, plus the shared output-sanitization
//! primitives that keep secrets out of diagnostics and command output.
//!
//! Provides a small abstraction (`KeyringStore`) plus a default
//! Secret storage: provides a small abstraction (`KeyringStore`) plus a default
//! file-based implementation (`FileKeyringStore`), an opt-in OS keyring
//! implementation (`DefaultKeyringStore`), and an in-memory store for tests
//! (`InMemoryKeyringStore`).
@@ -9,10 +10,31 @@
//! and falls back to environment variables. Config-file precedence lives in the
//! config crate so user-facing commands can keep `config -> secret store -> env`
//! explicit at the call site.
//!
//! Sanitization: [`redact`] and [`sanitize`] are pure and carry no host types.
//! They live here (FEAT-025 D4) because this is the lowest crate that both the
//! config diagnostics path and the TUI already reach, so `/export`,
//! `/structcopy`, client URL masking, and OSC8 stripping share exactly one
//! implementation instead of drifting copies. `config::persistence` and
//! `tui::client` / `tui::osc8` re-export or delegate to these functions.
//!
//! Note for the command extraction (EPIC-006): this crate is already reachable
//! from `codewhale-command-contract` transitively via
//! `core -> config -> secrets`, so consuming the sanitizer from the future
//! `codewhale-commands` crate adds no new dependency edge. It does mean the
//! sanitizer inherits this crate's OS keyring dependencies; if the surface grows
//! beyond redaction, split a dedicated `codewhale-sanitize` crate rather than
//! widening this one.
#![deny(missing_docs)]
/// Shared secure-storage contract for the Codewhale account session.
pub mod account;
/// Pure secret-redaction primitives shared by config diagnostics and the
/// portable command sanitizer (FEAT-025 D4).
pub mod redact;
/// Pure text/URL/ANSI output sanitization shared by the portable command
/// helpers (FEAT-025 D4).
pub mod sanitize;
use std::collections::HashMap;
use std::fs;
+599
View File
@@ -0,0 +1,599 @@
//! Pure secret-redaction primitives (FEAT-025 D4).
//!
//! Relocated verbatim from `codewhale-config::persistence` so the portable
//! command sanitizer and the config diagnostic path share exactly one
//! implementation. The algorithm, ordering, sensitive-key vocabulary, and
//! byte-for-byte results are unchanged; `codewhale-config::persistence`
//! re-exports these items to keep its public API stable.
/// Hints that mark a config/JSON/env key as carrying a secret value.
///
/// Compound hints (`api_key`, `client_secret`) match as a substring of the
/// normalized key. Single-word hints (`token`, `secret`, `password`) match a
/// whole identifier segment so they describe a credential (`token`,
/// `api_token`) and not an English word (`tokens`, `tokenizer`).
const SENSITIVE_KEY_HINTS: &[&str] = &[
"api_key",
"apikey",
"api-key",
"secret",
"token",
"password",
"passwd",
"authorization",
"auth_token",
"access_key",
"client_secret",
"private_key",
];
/// Known opaque-token prefixes worth masking even when they appear bare (not as
/// `key = value`). Conservative on purpose: only well-known provider/key shapes.
const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
/// The placeholder substituted for any redacted secret value.
pub const REDACTED: &str = "[redacted]";
/// Return a copy of a JSON value with secret-bearing data removed.
///
/// Object values whose key contains a sensitive hint are replaced wholesale,
/// while all other objects and arrays are traversed recursively. String leaves
/// still pass through [`redact_secrets`] so bare provider tokens and embedded
/// assignments remain covered without treating the serialized JSON document as
/// one flat keyed assignment.
#[must_use]
pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(object) => serde_json::Value::Object(
object
.iter()
.map(|(key, value)| {
let value = if key_is_sensitive(key) {
serde_json::Value::String(REDACTED.to_string())
} else {
redact_json_secrets(value)
};
(key.clone(), value)
})
.collect(),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(redact_json_secrets).collect())
}
serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)),
scalar => scalar.clone(),
}
}
/// Redact secret-bearing values from arbitrary text so it is safe to put in a
/// setup report, log line, error message, or test snapshot.
///
/// Two passes, both dependency-free:
///
/// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
/// like `key = value`, `key: value`, or `key=value` whose key
/// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS`
/// credential identifier have their value replaced with [`REDACTED`]. The
/// spaced form (`key = value`) is matched anywhere on the line, not only
/// when the sensitive key owns the line's first separator — an `anyhow`
/// chain rendered with `{:#}` puts prose and its own `: ` separators in
/// front of the assignment, and that must not be a hole. Because such a
/// value can span several words (`authorization = Bearer <token>`),
/// everything from the value to the end of the line is dropped, exactly as
/// the whole-line form already does. Token *counts* in diagnostics
/// (`max tokens = 8192`) are not credentials and stay visible.
/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
/// `SECRET_TOKEN_PREFIXES` are replaced wholesale.
///
/// The goal is defense in depth: setup state and reports are built from safe
/// summaries that never include secrets in the first place, and this is the
/// backstop for anything that echoes raw config text.
#[must_use]
pub fn redact_secrets(input: &str) -> String {
redact_secrets_with(input, RedactionPolicy::KeyBased)
}
/// How aggressively [`redact_secrets_with`] treats a sensitive-looking key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedactionPolicy {
/// Mask the value of every sensitive-looking key, whatever the value is.
/// Right for logs, previews, exports, and diagnostics: a false positive
/// costs nothing there and a miss leaks a credential.
KeyBased,
/// Mask a keyed value only when the value itself looks like a credential
/// (known prefix, JWT, bearer token, PEM block, long opaque string).
/// Right for text the model must be able to quote back byte-for-byte,
/// such as tool results that feed exact-match edits: `password:
/// credentials?.password`, `"password-validator": "^5.3.0"`, or
/// `token = make_token()` are code, not secrets (#5546).
CredentialShaped,
}
/// Redact model-bound tool output: exact configured credential values are the
/// caller's job; this masks only values that look like credentials so the
/// model keeps seeing the real bytes of ordinary code and config.
#[must_use]
pub fn redact_model_bound_secrets(input: &str) -> String {
redact_secrets_with(input, RedactionPolicy::CredentialShaped)
}
/// [`redact_secrets`] with an explicit [`RedactionPolicy`].
#[must_use]
pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String {
let mut out = String::with_capacity(input.len());
let mut in_private_key_block = false;
for line in input.split_inclusive('\n') {
// split_inclusive keeps the newline on the previous chunk, so we do
// not need to re-add separators here.
let body = line.strip_suffix('\n').unwrap_or(line);
let trimmed = body.trim();
if in_private_key_block {
if trimmed.starts_with("-----END") {
in_private_key_block = false;
out.push_str(line);
} else {
out.push_str(REDACTED);
if line.ends_with('\n') {
out.push('\n');
}
}
continue;
}
if is_private_key_block_start(trimmed) {
in_private_key_block = true;
out.push_str(line);
continue;
}
out.push_str(&redact_line(line, policy));
}
out
}
fn is_private_key_block_start(trimmed: &str) -> bool {
trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY")
}
/// Redact a single line (which may include a trailing newline).
fn redact_line(line: &str, policy: RedactionPolicy) -> String {
// Preserve any trailing newline so callers keep their line structure.
let (body, newline) = match line.strip_suffix('\n') {
Some(rest) => (rest, "\n"),
None => (line, ""),
};
if let Some(redacted) = redact_keyed_assignment(body, policy) {
return format!("{redacted}{newline}");
}
// Inline-assignment / bare-token pass: mask any whitespace-delimited word
// carrying a sensitive keyed value or a known bare secret prefix, plus the
// spaced `key = value` form that `redact_keyed_assignment` above only sees
// when the sensitive key owns the line's first separator.
let mut changed = false;
let mut spaced = SpacedAssignment::None;
let mut masked: Vec<String> = Vec::new();
for word in body.split(' ') {
let trimmed = trim_word_punctuation(word);
if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() {
match policy {
RedactionPolicy::KeyBased => {
// The value may run to the end of the line, so drop the
// remainder rather than masking one word and leaking the
// rest.
masked.push(REDACTED.to_string());
changed = true;
break;
}
RedactionPolicy::CredentialShaped => {
// Only a credential-shaped value is hidden, and only that
// word: the rest of the line stays quotable. An auth scheme
// word (`Bearer`) keeps the assignment open for its token.
if is_auth_scheme_word(trimmed) {
masked.push(word.to_string());
continue;
}
if value_looks_like_credential(trimmed) {
masked.push(word.replace(trimmed, REDACTED));
changed = true;
} else {
masked.push(word.to_string());
}
spaced = SpacedAssignment::None;
continue;
}
}
}
if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) {
changed = true;
masked.push(word.replace(trimmed, &redacted));
spaced = SpacedAssignment::None;
} else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
masked.push(word.replace(trimmed, REDACTED));
spaced = SpacedAssignment::None;
} else {
masked.push(word.to_string());
spaced = spaced.advance(trimmed);
}
}
if changed {
format!("{}{newline}", masked.join(" "))
} else {
format!("{body}{newline}")
}
}
/// Progress through a `key <space> <sep> <space> value` assignment as the
/// word-level pass walks a line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpacedAssignment {
None,
/// The previous word was a bare sensitive key awaiting its separator.
SensitiveKey,
/// A sensitive key and its separator are both behind us.
AwaitingValue,
}
impl SpacedAssignment {
fn advance(self, trimmed: &str) -> Self {
// Runs of spaces produce empty words; they neither start nor cancel an
// assignment.
if trimmed.is_empty() {
return self;
}
if matches!(trimmed, "=" | ":") {
return if self == Self::SensitiveKey {
Self::AwaitingValue
} else {
Self::None
};
}
// `api_key=` / `api_key:` with the value in the next word. A word whose
// separator is *not* final was already offered to
// `redact_inline_keyed_assignment`, so it is not an assignment we own.
if let Some(key) = trimmed
.strip_suffix('=')
.or_else(|| trimmed.strip_suffix(':'))
{
return if key_is_sensitive(key) {
Self::AwaitingValue
} else {
Self::None
};
}
if key_is_sensitive(trimmed) {
return Self::SensitiveKey;
}
Self::None
}
}
fn trim_word_punctuation(word: &str) -> &str {
word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'))
}
/// Whether `raw`, normalized the way a config/env/JSON key is, matches a
/// [`SENSITIVE_KEY_HINTS`] credential identifier.
fn key_is_sensitive(raw: &str) -> bool {
let key_norm = normalize_sensitive_key(raw);
!key_norm.is_empty()
&& SENSITIVE_KEY_HINTS
.iter()
.any(|hint| key_matches_sensitive_hint(&key_norm, hint))
}
/// Normalize the identifier boundaries commonly used by config, env, and JSON
/// keys without turning English plurals such as `tokens` into `token`.
///
/// Punctuation and case transitions become `_`, so `oauth.token`,
/// `accessToken`, and `APIKey` share the same matching surface as
/// `oauth_token`, `access_token`, and `api_key`.
fn normalize_sensitive_key(raw: &str) -> String {
let mut normalized = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut previous = None;
while let Some(ch) = chars.next() {
if ch.is_ascii_alphanumeric() {
let next = chars.peek().copied();
let starts_case_segment = ch.is_ascii_uppercase()
&& previous.is_some_and(|previous: char| {
previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase()
&& next.is_some_and(|next| next.is_ascii_lowercase()))
});
if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
normalized.push(ch.to_ascii_lowercase());
} else if !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
previous = Some(ch);
}
while normalized.ends_with('_') {
normalized.pop();
}
normalized
}
fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool {
if key_norm == hint {
return true;
}
// Compound hints already name a credential (`api_key`, `client_secret`).
// Substring is the right match: `openai_api_key` contains `api_key`.
if hint.contains('_') || hint.contains('-') {
return key_norm.contains(hint);
}
if hint == "token" {
// Camel-case normalization turns both credentials (`accessToken`) and
// ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented
// identifiers. A credential token is either the whole key, a suffix
// such as `access_token`, or an explicitly value-bearing `token_*`
// field. Metrics must stay visible in diagnostics and tool previews.
let is_metric_suffix = |suffix: &str| {
matches!(
suffix.split('_').next(),
Some(
"budget"
| "budgets"
| "count"
| "counts"
| "limit"
| "limits"
| "total"
| "totals"
| "usage"
| "used"
| "window"
| "windows"
)
)
};
if key_norm.ends_with("_token") {
return true;
}
if let Some(suffix) = key_norm.strip_prefix("token_") {
return !is_metric_suffix(suffix);
}
if let Some((_, suffix)) = key_norm.rsplit_once("_token_") {
return !is_metric_suffix(suffix);
}
return false;
}
// Single-word hints must be a whole identifier segment so `token`
// redacts `token` / `api_token` and not English `tokens`.
key_norm.split(['_', '-']).any(|segment| segment == hint)
}
fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option<String> {
let sep_idx = word.find(['=', ':'])?;
let (raw_key, rest) = word.split_at(sep_idx);
let raw_value = &rest[1..];
if raw_value.is_empty() {
return None;
}
if !key_is_sensitive(raw_key) {
return None;
}
match policy {
RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)),
RedactionPolicy::CredentialShaped => {
let (core, quote) = strip_value_quotes(raw_value);
if !value_looks_like_credential(core) {
return None;
}
Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1]))
}
}
}
/// Whether a word announces an HTTP auth scheme whose credential follows.
fn is_auth_scheme_word(word: &str) -> bool {
matches!(
word,
"Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token"
)
}
/// Split a matching pair of surrounding quotes off a value, returning the
/// inner text and the quote to restore (empty when unquoted or unbalanced).
fn strip_value_quotes(value: &str) -> (&str, &str) {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return (&value[1..value.len() - 1], &value[..1]);
}
}
// A leading quote without its partner (the word pass strips the outer
// punctuation of `"x",` to `"x`): treat the remainder as the value.
if let Some(inner) = value.strip_prefix(['"', '\'']) {
return (inner, "");
}
(value, "")
}
/// Extra bare prefixes that mark a value as a credential even though they are
/// too product-specific to mask as standalone words in prose.
const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[
"sk-ant-",
"AKIA",
"ASIA",
"AIza",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"github_pat_",
"glpat-",
"xoxa-",
"xoxb-",
"xoxp-",
"xoxr-",
"xoxs-",
"npm_",
"ya29.",
];
/// Whether a keyed value looks like credential material rather than code,
/// configuration, or prose.
///
/// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM
/// headers, and long opaque alphanumeric runs. False for short literals,
/// version strings, identifiers, property/call/env references, and the
/// redaction placeholder itself.
pub(crate) fn value_looks_like_credential(value: &str) -> bool {
let value = value
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
if value.is_empty() || value == REDACTED {
return false;
}
if looks_like_secret_token(value)
|| CREDENTIAL_VALUE_PREFIXES
.iter()
.any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix))
{
return true;
}
if value.starts_with("-----BEGIN") {
return true;
}
if let Some((scheme, rest)) = value.split_once(' ')
&& is_auth_scheme_word(scheme)
{
return value_looks_like_credential(rest);
}
if is_jwt_shaped(value) {
return true;
}
if value.len() < 16 {
return false;
}
if is_version_like(value) || is_reference_like(value) {
return false;
}
is_opaque_run(value)
}
fn is_jwt_shaped(value: &str) -> bool {
let mut parts = value.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(header), Some(payload), Some(signature), None) => {
header.starts_with("eyJ")
&& payload.starts_with("eyJ")
&& !signature.is_empty()
&& [header, payload, signature].iter().all(|part| {
part.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
_ => false,
}
}
fn is_version_like(value: &str) -> bool {
let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']);
!digits.is_empty()
&& digits
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
&& digits.chars().next().is_some_and(|c| c.is_ascii_digit())
}
fn is_reference_like(value: &str) -> bool {
// Property access, calls, template/env lookups, and plain identifiers are
// code, not credential material.
value.contains("?.")
|| value.contains('(')
|| value.contains("${")
|| value.contains("process.env")
|| value.contains("os.environ")
|| value.contains("getenv")
|| value.contains("://")
|| value
.chars()
.all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.')
}
fn is_opaque_run(value: &str) -> bool {
value.len() >= 20
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
&& value.chars().any(|c| c.is_ascii_alphabetic())
&& value.chars().any(|c| c.is_ascii_digit())
}
/// If `body` is a `key <sep> value` assignment with a sensitive key, return the
/// line with the value redacted; otherwise `None`.
fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
// Find the first `=` or `:` that separates a key from a value.
let sep_idx = body.find(['=', ':'])?;
let (raw_key, rest) = body.split_at(sep_idx);
let sep = &rest[..1];
let raw_value = &rest[1..];
let key_norm = raw_key
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'));
if !key_is_sensitive(key_norm) {
return None;
}
if policy == RedactionPolicy::CredentialShaped {
// Replace only the value span, keep the key bytes, separator spacing,
// quote style, and trailing punctuation, and only when the value is
// credential-shaped: the model must still be able to quote the line.
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
let value_core = value_rest.trim_end();
let trailing_ws = &value_rest[value_core.len()..];
let literal = value_core.trim_end_matches([',', ';']);
let trailer = &value_core[literal.len()..];
let (core, quote) = strip_value_quotes(literal);
if core.is_empty() || !value_looks_like_credential(core) {
return None;
}
return Some(format!(
"{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}"
));
}
// Keep leading whitespace of the key and the original separator spacing so
// the redacted line reads naturally.
let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
// If the value is empty, there is nothing to hide.
if value_rest.is_empty() {
return None;
}
// Preserve surrounding quotes so structured files stay parseable-looking.
let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
let replacement = if quoted {
format!("\"{REDACTED}\"")
} else {
REDACTED.to_string()
};
Some(format!(
"{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
raw_key.trim()
))
}
fn looks_like_secret_token(word: &str) -> bool {
SECRET_TOKEN_PREFIXES
.iter()
.any(|p| word.len() > p.len() + 6 && word.starts_with(p))
}
+365
View File
@@ -0,0 +1,365 @@
//! Pure output-sanitization primitives shared by portable command helpers
//! (FEAT-025 D4).
//!
//! These helpers previously lived in the TUI command, client, and OSC8
//! modules. Relocating the pure algorithms here gives `/export` and
//! `/structcopy` exactly one implementation with no TUI, client, or
//! configuration dependency. TUI callers delegate back to these functions so
//! behavior cannot drift.
use std::sync::OnceLock;
use regex::Regex;
use serde_json::Value;
use crate::redact::redact_secrets;
/// Strip ANSI/OSC/control sequences from `s` into `out`.
///
/// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS,
/// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers
/// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest.
pub fn strip_ansi_into(s: &str, out: &mut String) {
strip_ansi_impl(s, out, false);
}
/// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
/// underline, reset) pass through untouched so a renderer that understands
/// them can paint the output as the tool emitted it. Everything else — OSC
/// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control
/// bytes — is still removed; only the styling survives.
pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) {
strip_ansi_impl(s, out, true);
}
/// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
/// to `1` for continuation bytes / invalid leads so callers always make
/// forward progress.
pub fn utf8_seq_len(lead: u8) -> usize {
if lead < 0xc0 {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
}
}
fn strip_ansi_impl(s: &str, out: &mut String, keep_sgr: bool) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
let next = bytes[i + 1];
match next {
// CSI: ESC [ ... <final byte 0x40..=0x7E>
b'[' => {
let mut j = i + 2;
let mut final_byte = 0u8;
while j < bytes.len() {
let b = bytes[j];
if (0x40..=0x7e).contains(&b) {
final_byte = b;
j += 1;
break;
}
j += 1;
}
if keep_sgr
&& final_byte == b'm'
&& let Ok(seq) = std::str::from_utf8(&bytes[i..j])
{
out.push_str(seq);
}
i = j;
continue;
}
// OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL
b']' | b'P' | b'X' | b'^' | b'_' => {
let mut j = i + 2;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
// Standalone two-byte ESC sequence (RIS, charset selection, etc.)
_ => {
i += 2;
continue;
}
}
}
// Strip lone control bytes that ratatui would otherwise drop (and which
// mean nothing in transcript output) but keep \n, \r, \t as legitimate
// formatting.
let b = bytes[i];
if b < 0x80 {
if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' {
i += 1;
continue;
}
out.push(b as char);
i += 1;
} else {
// UTF-8 multi-byte sequence: copy the whole code point intact.
// Pushing `b as char` would mis-decode it as Latin-1 and mangle
// non-ASCII text (CJK, accented Latin, emoji, …).
let len = utf8_seq_len(b);
let end = (i + len).min(bytes.len());
if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
out.push_str(chunk);
}
i = end;
}
}
}
/// Mask credentials in a URL so it can appear in output or a report.
///
/// Userinfo is replaced with `***` and query values under sensitive keys are
/// masked. A URL that does not parse is returned unchanged.
pub fn redact_url_for_display(url: &str) -> String {
let Ok(mut parsed) = url::Url::parse(url) else {
return url.to_string();
};
if !parsed.username().is_empty() || parsed.password().is_some() {
let _ = parsed.set_username("***");
let _ = parsed.set_password(Some("***"));
}
if parsed.query().is_none() {
return parsed.to_string();
}
let pairs: Vec<(String, String)> = parsed
.query_pairs()
.map(|(key, value)| {
let value = if is_sensitive_url_query_key(&key) {
"***".to_string()
} else {
value.into_owned()
};
(key.into_owned(), value)
})
.collect();
parsed.set_query(None);
let mut query = parsed.query_pairs_mut();
for (key, value) in pairs {
query.append_pair(&key, &value);
}
drop(query);
parsed.to_string()
}
fn is_sensitive_url_query_key(key: &str) -> bool {
let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase();
matches!(
normalized.as_str(),
"api_key"
| "apikey"
| "access_token"
| "auth_token"
| "authorization"
| "bearer"
| "client_secret"
| "credential"
| "id_token"
| "password"
| "refresh_token"
| "secret"
| "token"
) || normalized.ends_with("_api_key")
|| normalized.ends_with("_authorization")
|| normalized.ends_with("_password")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
}
/// True when `role` names an internal, non-user-visible message role.
pub fn is_internal_role(role: &str) -> bool {
matches!(
role.trim().to_ascii_lowercase().as_str(),
"system" | "developer" | "internal"
)
}
/// True when a JSON/assignment key names a credential-bearing value.
///
/// Classification normalizes separators and quotes so obfuscated variants are
/// still caught; the vocabulary is shared with `/structcopy`.
pub fn is_sensitive_key(key: &str) -> bool {
let normalized = key
.trim()
.trim_matches(['\'', '"'])
.replace(['-', '.', ' '], "_")
.to_ascii_lowercase();
[
"api_key",
"apikey",
"secret",
"token",
"password",
"passwd",
"authorization",
"access_key",
"client_secret",
"private_key",
"cookie",
"session_key",
]
.iter()
.any(|hint| normalized.contains(hint))
}
/// Sanitize arbitrary text for safe export output.
///
/// Strips ANSI/control bytes, normalizes newlines, then applies private-key,
/// bearer-token, JWT, URL-credential, and keyed-secret redaction in the exact
/// established order.
pub fn sanitize_text(input: &str) -> String {
let mut visible = String::with_capacity(input.len());
strip_ansi_into(input, &mut visible);
let visible = visible.replace("\r\n", "\n").replace('\r', "\n");
let visible: String = visible
.chars()
.filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
.collect();
let private_keys = private_key_regex().replace_all(&visible, "[redacted private key]");
let bearer = bearer_regex().replace_all(&private_keys, "Bearer [redacted]");
let jwt = jwt_regex().replace_all(&bearer, "[redacted token]");
let urls = url_regex().replace_all(&jwt, |captures: &regex::Captures<'_>| {
redact_url_match(captures.get(0).map_or("", |value| value.as_str()))
});
redact_secrets(&urls)
}
/// Recursively redact a JSON value.
///
/// A value under a sensitive key is replaced wholesale; other strings pass
/// through [`sanitize_text`], and arrays/objects are traversed in place.
pub fn redact_json(value: &mut Value, key: Option<&str>) {
if key.is_some_and(is_sensitive_key) {
*value = Value::String("[redacted]".to_string());
return;
}
match value {
Value::String(text) => *text = sanitize_text(text),
Value::Array(items) => {
for item in items {
redact_json(item, None);
}
}
Value::Object(map) => {
for (key, value) in map {
redact_json(value, Some(key));
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
/// Collapse whitespace and neutralize inline backticks for a single-line
/// export field.
pub fn inline_text(input: &str) -> String {
sanitize_text(input)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('`', "'")
}
fn redact_url_match(raw: &str) -> String {
let trimmed = raw.trim_end_matches(['.', ',', ';', '!']);
let suffix = &raw[trimmed.len()..];
format!("{}{}", redact_url_for_display(trimmed), suffix)
}
fn private_key_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----",
)
.expect("private-key redaction regex")
})
}
fn bearer_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}").expect("bearer redaction regex")
})
}
fn jwt_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}(?:\.[a-zA-Z0-9_-]{5,})?\b")
.expect("JWT redaction regex")
})
}
fn url_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"https?://[^\s<>\"'`\]\[\)\(\}\{]+"#).expect("URL redaction regex")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_removes_control_sequences_but_keeps_text() {
let mut out = String::new();
strip_ansi_into("a\u{1b}[31mred\u{1b}[0m b", &mut out);
assert_eq!(out, "ared b");
}
#[test]
fn url_redaction_masks_userinfo_and_sensitive_query_values() {
assert_eq!(
redact_url_for_display(
"https://alice:[email protected]/path?token=very-secret&ok=1"
),
"https://***:***@example.com/path?token=***&ok=1"
);
}
#[test]
fn sensitive_keys_normalize_separators_and_quotes() {
assert!(is_sensitive_key("API-KEY"));
assert!(is_sensitive_key("\"client secret\""));
assert!(!is_sensitive_key("monkey"));
}
#[test]
fn sanitize_text_redacts_private_keys_bearer_and_jwt() {
// Assemble the PEM markers and the provider-token prefix at runtime so
// this source file never contains a literal private-key header (or a
// literal token) for a secret scanner to match. The runtime strings are
// identical to the real shapes, and this mirrors the convention the
// pre-move test used in `config::persistence` - moving that test into
// this crate silently dropped it, which is what GitGuardian caught.
let begin = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat();
let end = ["-----END RSA", " PRIVATE KEY-----"].concat();
let bearer = format!("{} abcdefghijklmnop", "Bearer");
let opaque = ["sk-", "abcdef1234567890"].concat();
let text = format!("{begin}\nMII\n{end}\nAuthorization: {bearer}\n{opaque}");
let out = sanitize_text(&text);
assert!(!out.contains("MII"), "{out}");
assert!(!out.contains("abcdefghijklmnop"), "{out}");
assert!(out.contains("[redacted]"), "{out}");
}
}
+6 -52
View File
@@ -1125,59 +1125,13 @@ fn validate_base_url_security(base_url: &str, provider_allows_insecure_http: boo
)
}
/// Mask credentials in a URL for display.
///
/// Delegates to the single shared implementation in
/// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so command sanitization and
/// client diagnostics cannot drift.
pub(crate) fn redact_url_for_display(url: &str) -> String {
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return url.to_string();
};
if !parsed.username().is_empty() || parsed.password().is_some() {
let _ = parsed.set_username("***");
let _ = parsed.set_password(Some("***"));
}
if parsed.query().is_none() {
return parsed.to_string();
}
let pairs: Vec<(String, String)> = parsed
.query_pairs()
.map(|(key, value)| {
let value = if is_sensitive_url_query_key(&key) {
"***".to_string()
} else {
value.into_owned()
};
(key.into_owned(), value)
})
.collect();
parsed.set_query(None);
let mut query = parsed.query_pairs_mut();
for (key, value) in pairs {
query.append_pair(&key, &value);
}
drop(query);
parsed.to_string()
}
fn is_sensitive_url_query_key(key: &str) -> bool {
let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase();
matches!(
normalized.as_str(),
"api_key"
| "apikey"
| "access_token"
| "auth_token"
| "authorization"
| "bearer"
| "client_secret"
| "credential"
| "id_token"
| "password"
| "refresh_token"
| "secret"
| "token"
) || normalized.ends_with("_api_key")
|| normalized.ends_with("_authorization")
|| normalized.ends_with("_password")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
codewhale_secrets::sanitize::redact_url_for_display(url)
}
pub(super) fn versioned_base_url(base_url: &str) -> String {
+328 -6
View File
@@ -14,7 +14,7 @@
//!
//! ## Authoritative host-proxy design (D1)
//!
//! `CommandContexts` holds fifteen independently borrowed facet objects, while
//! `CommandContexts` holds sixteen independently borrowed facet objects, while
//! important behavior (mode transitions, model invalidation, cost accounting,
//! skill refresh) is authoritative on `App`. The adapters therefore share a
//! synchronous TUI-owned host proxy. Each trait call borrows `App` only for the
@@ -54,6 +54,11 @@ use codewhale_command_contract::facets::{
SkillSourceKind, SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, SnapshotEntry,
TitleReport, TitleSource, TodoProjection, TreeBodyProjection,
};
use codewhale_command_contract::facets::{
CommandSessionExportContext, ConversationExportProjection, ExportBlock, ExportMessage,
ExportMetadata, HistoryEntry, RestorePointProjection, RestoreSnapshot, ToolCallerProjection,
TranscriptProjection, TurnHandoffProjection,
};
#[cfg(test)]
use codewhale_command_contract::handler::ContextParts;
use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts};
@@ -61,7 +66,7 @@ use codewhale_command_contract::types::{
CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort,
};
use codewhale_config::AppMode;
use codewhale_core::request::{Message, SystemPrompt};
use codewhale_core::request::{ContentBlock, Message, SystemPrompt};
use codewhale_execpolicy::ApprovalMode;
use crate::commands::groups::plugins::plugin_network_policy;
@@ -274,7 +279,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
/// Shared TUI host hidden behind the portable command facets.
///
/// The envelope needs fifteen independently borrowed facet objects, while the
/// The envelope needs sixteen independently borrowed facet objects, while the
/// authoritative mutation methods live on `App`. Each adapter therefore owns
/// an `Rc` clone of this synchronous host proxy. Trait calls borrow `App` only
/// for the duration of one method, delegate to the real TUI authority, and
@@ -1485,6 +1490,317 @@ fn import_session_container(
})
}
// ---------------------------------------------------------------------------
// Session export adapter (FEAT-025 D1/D2/D3/D5/D7/D8/D9)
//
// Sole host owner of concrete export machinery for `/export` and `/daochu`:
// metadata derivation, authoritative/visible-history projection, semantic
// restore-point projection, the shared `turn_handoff_markdown` renderer,
// clipboard mode/recovery/delivery, and protected destination resolution/
// writing. Every delegate reproduces the baseline order and returns portable
// data or the exact host-error text; no concrete `App`, clipboard, snapshot,
// history, filesystem, or turn-handoff type crosses the boundary. Hidden
// reasoning bodies, signatures, and inline/local image payloads are excluded
// while the projection is built (D9). The shared recovery writer and protected
// file services live in `commands::session_export_host` (outside the future
// portable group) so `/copy` and `/export` reuse one implementation (D5).
// ---------------------------------------------------------------------------
pub(crate) struct SessionExportAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandSessionExportContext for SessionExportAdapter<'_> {
/// Conversation export projection: metadata, transcript, and restore-point
/// state.
///
/// Memory note (FEAT-025 audit, finding F3): the projection is an *owned*
/// copy of the transcript, so peak use is roughly the live `api_messages`
/// plus this projection for the duration of one render. That copy is
/// structural, not an oversight: the facet must return owned data because
/// `SharedCommandHost` hands out `App` through a `RefCell`, so no borrow can
/// outlive this method, and a `dyn` facet cannot lend a projection tied to a
/// temporary `Ref`. The baseline rendered straight from `App` and cloned one
/// block at a time, so this is a deliberate D3 cost accepted for the
/// capability boundary. Removing it needs a host proxy that can lend a
/// borrowed projection (tracked with the FEAT-043/046 extraction work); it is
/// not something this slice can fix locally.
fn conversation_projection(&self) -> ConversationExportProjection {
let app = self.host.app.borrow();
ConversationExportProjection {
metadata: export_metadata(&app),
transcript: project_transcript(&app),
restore_points: project_restore_points(&app.workspace),
}
}
fn turn_handoff_projection(&self) -> TurnHandoffProjection {
let app = self.host.app.borrow();
TurnHandoffProjection {
markdown: crate::tui::ui::turn_handoff_markdown(&app),
workspace_path: app.workspace.to_string_lossy().into_owned(),
}
}
fn clipboard_requires_terminal_paste(&self) -> bool {
self.host.app.borrow().clipboard.requires_terminal_paste()
}
fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
crate::commands::session_export_host::write_last_copy(markdown)
}
fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
self.host
.app
.borrow_mut()
.clipboard
.write_text(markdown)
.map_err(|err| err.to_string())
}
fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
let app = self.host.app.borrow();
crate::commands::session_export_host::resolve_export_path(&app.workspace, raw)
}
fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
crate::commands::session_export_host::write_export_file(path, contents, force)
}
}
/// Maximum restore points listed in the export summary (baseline bound).
const RESTORE_POINT_SUMMARY_MAX: usize = 100;
/// Authoritative export metadata, reusing the baseline host derivations.
fn export_metadata(app: &App) -> ExportMetadata {
let message_count = if app.api_messages.is_empty() {
app.history.len()
} else {
app.api_messages.len()
};
let session_label = app
.current_session_id
.as_deref()
.map(crate::session_manager::truncate_id)
.unwrap_or("unsaved")
.to_string();
let workspace_name = app
.workspace
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("workspace")
.to_string();
ExportMetadata {
session_label,
provider: app.provider_identity_for_persistence().to_string(),
model: app.model_display_label(),
mode: app.mode.display_name().to_string(),
workspace_name,
message_count,
exported_at_unix: chrono::Utc::now().timestamp(),
}
}
/// Authoritative transcript when API messages exist, otherwise the visible
/// history fallback (D3 precedence).
fn project_transcript(app: &App) -> TranscriptProjection {
if app.api_messages.is_empty() {
TranscriptProjection::HistoryFallback(
app.history.iter().map(project_history_cell).collect(),
)
} else {
TranscriptProjection::Authoritative(app.api_messages.iter().map(project_message).collect())
}
}
fn project_message(message: &Message) -> ExportMessage {
ExportMessage {
role: message.role.as_str().to_string(),
// Exact enum identity, not a string comparison: `Role::Unrecognized("user")`
// must not be treated as a user turn (baseline parity, F6).
is_user_role: message.role == codewhale_models::Role::User,
blocks: message.content.iter().map(project_block).collect(),
prompt_snippet: first_text_block(message)
.and_then(crate::core::turn::snapshot_label_prompt_snippet),
}
}
fn first_text_block(message: &Message) -> Option<&str> {
message.content.iter().find_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
}
/// Project one content block; hidden payloads become typed omission markers
/// (D9) and never cross the boundary.
fn project_block(block: &ContentBlock) -> ExportBlock {
match block {
ContentBlock::Text { text, .. } => ExportBlock::Text { text: text.clone() },
ContentBlock::ImageUrl { image_url } => {
if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") {
ExportBlock::ImageReference {
url: image_url.url.clone(),
}
} else {
ExportBlock::ImageOmitted
}
}
ContentBlock::Thinking { .. } => ExportBlock::InternalReasoning,
ContentBlock::ToolUse {
id,
name,
input,
caller,
..
} => ExportBlock::ToolCall {
id: id.clone(),
name: name.clone(),
caller: caller.as_ref().map(|caller| ToolCallerProjection {
caller_type: caller.caller_type.clone(),
tool_id: caller.tool_id.clone(),
}),
input: input.clone(),
},
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
content_blocks,
} => ExportBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
is_error: is_error.unwrap_or(false),
structured: content_blocks.as_deref().map(|blocks| {
serde_json::Value::Array(
crate::image_attach::safe_tool_result_content_blocks(Some(blocks))
.unwrap_or_default(),
)
}),
},
ContentBlock::ServerToolUse { id, name, input } => ExportBlock::ServerToolCall {
id: id.clone(),
name: name.clone(),
input: input.clone(),
},
ContentBlock::ToolSearchToolResult {
tool_use_id,
content,
} => ExportBlock::ToolSearchResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
},
ContentBlock::CodeExecutionToolResult {
tool_use_id,
content,
} => ExportBlock::CodeExecutionResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
},
}
}
fn project_history_cell(cell: &HistoryCell) -> HistoryEntry {
match cell {
HistoryCell::User { content } => HistoryEntry::Sanitized {
role: "user".to_string(),
body: content.clone(),
},
HistoryCell::Assistant { content, .. } => HistoryEntry::Sanitized {
role: "assistant".to_string(),
body: content.clone(),
},
HistoryCell::System { .. } => HistoryEntry::Literal {
role: "system".to_string(),
body: "[internal context omitted]".to_string(),
},
HistoryCell::Error { message, severity } => HistoryEntry::Sanitized {
role: error_severity_role(*severity).to_string(),
body: message.clone(),
},
HistoryCell::Thinking { .. } => HistoryEntry::Literal {
role: "internal reasoning".to_string(),
body: "[internal reasoning omitted]".to_string(),
},
HistoryCell::Tool(tool) => HistoryEntry::Sanitized {
role: "tool".to_string(),
body: flatten_history_lines(tool.lines(120)),
},
HistoryCell::SubAgent(subagent) => HistoryEntry::Sanitized {
role: "sub-agent".to_string(),
body: flatten_history_lines(subagent.lines(120)),
},
HistoryCell::Automation(cell) => HistoryEntry::Sanitized {
role: "automation".to_string(),
body: flatten_history_lines(cell.render(120)),
},
HistoryCell::ArchivedContext {
level,
range,
summary,
..
} => HistoryEntry::Sanitized {
role: "archived context".to_string(),
body: format!("L{level} [{range}]: {summary}"),
},
}
}
fn error_severity_role(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
match severity {
crate::error_taxonomy::ErrorSeverity::Info => "info",
crate::error_taxonomy::ErrorSeverity::Warning => "warning",
crate::error_taxonomy::ErrorSeverity::Error => "error",
crate::error_taxonomy::ErrorSeverity::Critical => "critical error",
}
}
/// Flatten host UI lines/spans to plain text, preserving the baseline width
/// and joining behavior (D3). UI rendering stays behind the adapter.
fn flatten_history_lines(lines: Vec<ratatui::text::Line<'static>>) -> String {
lines
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
/// Read the workspace snapshot repository read-only and project its state
/// (D8): only an existing repo is opened, never created.
fn project_restore_points(workspace: &Path) -> RestorePointProjection {
match crate::snapshot::SnapshotRepo::open_existing(workspace) {
Ok(None) => RestorePointProjection::None,
Err(err) => RestorePointProjection::Unreadable {
reason: err.to_string(),
},
Ok(Some(repo)) => match repo.list(RESTORE_POINT_SUMMARY_MAX) {
Ok(snapshots) => RestorePointProjection::Recorded {
snapshots: snapshots.iter().map(project_restore_snapshot).collect(),
},
Err(err) => RestorePointProjection::Unreadable {
reason: err.to_string(),
},
},
}
}
fn project_restore_snapshot(snapshot: &crate::snapshot::Snapshot) -> RestoreSnapshot {
let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label);
RestoreSnapshot {
id: snapshot.id.as_str().to_string(),
label: snapshot.label.clone(),
timestamp_unix: snapshot.timestamp,
kind: parsed.kind,
sequence: parsed.seq,
prompt_snippet: parsed.prompt_snippet,
}
}
/// Session identity, messages, queue operations, and token totals.
pub(crate) struct SessionAdapter<'a> {
host: SharedCommandHost<'a>,
@@ -3940,7 +4256,7 @@ fn default_codewhale_tools_dir() -> Option<PathBuf> {
// Envelope construction (D1)
// ---------------------------------------------------------------------------
/// Owns fifteen facet objects sharing one synchronous TUI host proxy.
/// Owns sixteen facet objects sharing one synchronous TUI host proxy.
///
/// Handlers borrow only these adapters. Every method delegates to the real App
/// authority and releases its `RefCell` borrow before returning, so facets can
@@ -3961,6 +4277,7 @@ pub(crate) struct CommandContextBundle<'a> {
plugin: PluginAdapter<'a>,
lifecycle: SessionLifecycleAdapter<'a>,
control: SessionControlAdapter<'a>,
export: SessionExportAdapter<'a>,
}
impl<'a> CommandContextBundle<'a> {
@@ -4012,6 +4329,9 @@ impl<'a> CommandContextBundle<'a> {
if capabilities.contains(CommandCapabilities::SESSION_CONTROL) {
contexts = contexts.with_control(&mut self.control);
}
if capabilities.contains(CommandCapabilities::SESSION_EXPORT) {
contexts = contexts.with_export(&mut self.export);
}
contexts
}
@@ -4032,7 +4352,8 @@ impl<'a> CommandContextBundle<'a> {
.union(CommandCapabilities::SKILL_GROUP)
.union(CommandCapabilities::PLUGIN)
.union(CommandCapabilities::SESSION_LIFECYCLE)
.union(CommandCapabilities::SESSION_CONTROL);
.union(CommandCapabilities::SESSION_CONTROL)
.union(CommandCapabilities::SESSION_EXPORT);
self.contexts(all_test_capabilities).into_parts()
}
}
@@ -4059,7 +4380,8 @@ impl App {
skill_group: SkillGroupAdapter { host: host.clone() },
plugin: PluginAdapter { host: host.clone() },
lifecycle: SessionLifecycleAdapter { host: host.clone() },
control: SessionControlAdapter { host },
control: SessionControlAdapter { host: host.clone() },
export: SessionExportAdapter { host },
}
}
}
@@ -0,0 +1,129 @@
# Codewhale conversation export
- Exported: 2026-09-11T13:31:54Z
- Session: session-
- Provider: deepseek
- Model: deepseek-v4-pro
- Mode: Act
- Workspace: example
- Messages: 8
> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.
## Restore points
No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.
## 1. system
[internal context omitted]
## 2. user
### Content 1: Text
Please inspect this output
## 3. assistant
### Content 1: Internal reasoning
[internal reasoning and signature omitted]
### Content 2: Tool call
- ID: call-1
- Name: fetch_url
- Caller type: code_execution_20250825
- Caller tool ID: server-tool-1
Input:
```json
{
"url": "https://***:***@example.com/path?token=***&ok=1",
"api_key": "[redacted]",
"nested": {
"authorization": "[redacted]"
}
}
```
## 4. user
### Content 1: Tool result
- Tool call ID: call-1
- Error: false
Result:
Authorization: [redacted]
result ok
Structured result blocks:
```json
[
{
"type": "image",
"mime_type": "image/png",
"omission_code": "inline_or_local_image_payload",
"omitted_base64_bytes": 25
},
{
"session_token": "[redacted]",
"note": "keep me"
}
]
```
## 5. assistant
### Content 1: Image attachment
- Reference: https://example.com/visible.png?token=***
### Content 2: Image attachment
- Reference omitted (inline or local image payload)
## 6. assistant
### Content 1: Server tool call
- ID: srv-1
- Name: web_search
Input:
```json
{
"query": "secret token"
}
```
## 7. user
### Content 1: Tool-search result
- Tool call ID: srv-1
```json
{
"results": []
}
```
## 8. assistant
### Content 1: Code-execution result
- Tool call ID: srv-2
```json
{
"stdout": "ok"
}
```
@@ -0,0 +1,47 @@
# Codewhale conversation export
- Exported: 2026-09-11T14:39:58Z
- Session: session-
- Provider: deepseek
- Model: deepseek-v4-pro
- Mode: Act
- Workspace: f025-golden-workspace
- Messages: 3
> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.
## Restore points
The 3 most recent workspace restore points, newest first. `/restore <N>` restores by the index in this table and `/restore list` shows the live list.
> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift.
| N | Restore point | Recorded (UTC) | Label |
| --- | --- | --- | --- |
| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test |
| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 |
| 3 | `e24e175a7a57` | 2026-09-11T14:39:58Z | pre-turn:2: Fix the login test |
## 1. user
### Content 1: Text
Fix the login test
- Restore points: N1 `3d0ad76f0222` (pre-turn turn 3), N3 `e24e175a7a57` (pre-turn turn 2)
- More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring.
## 2. assistant
### Content 1: Text
Working on it.
## 3. user
### Content 1: Text
unrelated question
- Restore points: none recorded for this message within the listed window.
@@ -0,0 +1,34 @@
# Codewhale conversation export
- Exported: 2026-09-11T14:39:58Z
- Session: session-
- Provider: deepseek
- Model: deepseek-v4-pro
- Mode: Act
- Workspace: f025-golden-workspace
- Messages: 2
> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.
## Restore points
The 3 most recent workspace restore points, newest first. `/restore <N>` restores by the index in this table and `/restore list` shows the live list.
> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift.
| N | Restore point | Recorded (UTC) | Label |
| --- | --- | --- | --- |
| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test |
| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 |
| 3 | `e24e175a7a57` | 2026-09-11T14:39:58Z | pre-turn:2: Fix the login test |
> Structured API messages were unavailable; the entries below are a sanitized visible-history fallback.
## 1. user
Fix the login test
## 2. assistant
Done.
@@ -0,0 +1,23 @@
# Turn handoff
_Status: completed · generated 2026-09-11 15:31:54_
## Intent
Fix the flaky login test
## Files changed
—
## Turn timeline
- 1. user prompt: Fix the flaky login test
- 2. assistant result: Fixed the login test. — done
- 3. checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff
## Tests / verifier
—
## Model route + tokens/cost
- Route: DeepSeek · deepseek-v4-pro
## Result / status
- Status: completed
- Result: Fixed the login test.
+1 -1
View File
@@ -38,7 +38,7 @@ fn execute_copy(app: &mut App) -> CommandResult {
// Any native-host attempt may fall through to the asynchronous terminal
// transport. Preserve /export's durable recovery contract before the
// write so every optimistic receipt names (or explicitly lacks) a backup.
let recovery = crate::commands::groups::session::write_last_copy(&content);
let recovery = crate::commands::session_export_host::write_last_copy(&content);
match app.clipboard.write_text(&content) {
Ok(()) if terminal_client => match recovery {
Some(path) => CommandResult::message(
File diff suppressed because it is too large. Load diff
@@ -4,7 +4,6 @@
mod branch;
mod compact;
mod export;
pub(crate) use export::write_last_copy;
mod fork;
mod load;
mod new;
@@ -88,10 +87,10 @@ impl CommandGroup for SessionCommands {
ContextualCommand::from_contract::<remote_env::RemoteEnvCmd>()
.expect("remote_env registration")
),
Box::new(FunctionCommand::new(
export::ExportCmd::info(),
export::ExportCmd::execute,
)),
Box::new(
ContextualCommand::from_contract::<export::ExportCmd>()
.expect("export registration")
),
Box::new(FunctionCommand::new(
structcopy::StructcopyCmd::info(),
structcopy::StructcopyCmd::execute,
@@ -10,9 +10,9 @@
//! - Human-only. This is a slash command, never a model-visible tool, event,
//! or authority, and it writes nothing back into App/session/plan/workflow
//! state (see the registry/catalog contract test).
//! - Read-only projection over existing state. Redaction reuses the
//! transcript/export seams (`export::redact_json` for values,
//! `export::sanitize_text` for keys and status labels, which
//! - Read-only projection over existing state. Redaction reuses the shared
//! sanitizer seams in `codewhale_secrets::sanitize` (`redact_json` for
//! values, `sanitize_text` for keys and status labels, which
//! `redact_json` does not reach) plus a strict pass that strips URL
//! userinfo/query/fragment entirely and folds the workspace and home
//! prefixes to labels, removes other absolute paths, and handles generic
@@ -28,8 +28,8 @@
//! - It is not a general PII scrubber. Workspace/home paths retain a useful
//! labelled suffix; other absolute POSIX, drive-letter, and UNC paths are
//! replaced outright.
//! - Redaction is pattern-based (the export seam's private-key/bearer/JWT/
//! URL/secret regexes plus this module's strict URL pass). A secret that
//! - Redaction is pattern-based (the shared sanitizer's private-key/bearer/
//! JWT/URL/secret regexes plus this module's strict URL pass). A secret that
//! matches none of those patterns and sits under a non-sensitive key is
//! copied as-is.
//! - Delivery to the clipboard is not confirmed. Terminal-client transports
@@ -49,7 +49,10 @@ use codewhale_localization::{Locale, MessageId, tr};
use codewhale_models::{ContentBlock, Message};
use super::CommandResult;
use super::export::{is_internal_role, is_sensitive_key, redact_json, sanitize_text};
// FEAT-025 D4: the sanitizer helpers moved to the single shared portable
// implementation in `codewhale-secrets`; `/structcopy` stays legacy until
// FEAT-026 and only rewires its import.
use codewhale_secrets::sanitize::{is_internal_role, is_sensitive_key, redact_json, sanitize_text};
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "structcopy",
@@ -720,7 +723,7 @@ fn scrub_string(text: &str, labels: &PathLabels) -> String {
scrub_paths(&scrub_urls(&labelled))
}
/// Convert the prose placeholders owned by the shared export seam into stable
/// Convert the prose placeholders owned by the shared sanitizer into stable
/// language-neutral codes. Structural JSON is a machine artifact and must not
/// change with the UI locale.
fn normalize_redaction_codes(value: &mut Value) {
@@ -783,7 +786,7 @@ const URL_TRAILING_PUNCTUATION: &[char] = &[
/// Strip URL userinfo, query, and fragment entirely, leaving a
/// `scheme://host[:port]/path` label.
///
/// The export seam has already masked credentials in URLs it recognised;
/// The shared sanitizer has already masked credentials in URLs it recognised;
/// this pass enforces the stricter structural-copy contract that no
/// userinfo, query string, or fragment may survive at all — including for
/// URLs that are punctuation-wrapped (`(https://…)`, `<https://…>`,
+137 -14
View File
@@ -9,6 +9,12 @@
mod contract;
pub mod discovery;
mod groups;
// FEAT-025 host services for the session-export slice: the shared recovery
// writer and the protected export-destination resolver/writer. Declared at the
// `commands` root so they stay outside `groups/session`, which FEAT-043 moves
// to `codewhale-commands`.
mod session_export_host;
pub mod traits;
pub mod user_commands;
pub mod user_registry;
@@ -28,6 +34,16 @@ mod session_acceptance;
#[cfg(test)]
mod session_control_regression_tests;
#[cfg(test)]
mod session_export_regression_tests;
// FEAT-025 Phase 5: public command-surface parity lives at the `commands` root
// for the same extraction reason as the host regressions above.
#[cfg(test)]
mod session_export_surface_tests;
// FEAT-025 audit hardening: shared host-bound test support for both export
// test suites (timestamp normalisation and the exhaustive envelope check).
#[cfg(test)]
mod session_export_test_support;
#[cfg(test)]
mod session_lifecycle_regression_tests;
use std::sync::OnceLock;
@@ -2089,6 +2105,8 @@ mod tests {
"rc",
"remote-env",
"title",
// FEAT-025 session export slice.
"export",
];
for info in command_infos() {
if info.name == "feat015ctx" || MIGRATED_GROUPS.contains(&info.name) {
@@ -2766,13 +2784,11 @@ mod tests {
"/{name} must be pure (no host context bundle)"
);
}
// Out-of-scope session commands remain legacy for FEAT-025/026.
for name in ["export", "structcopy"] {
assert!(
!registry().has_contextual_handler(name),
"/{name} must stay on the legacy dispatch until its owning FEAT"
);
}
// Out-of-scope session command remains legacy for FEAT-026.
assert!(
!registry().has_contextual_handler("structcopy"),
"/structcopy must stay on the legacy dispatch until its owning FEAT"
);
}
// ---------------------------------------------------------------------
@@ -2817,13 +2833,11 @@ mod tests {
CommandCapabilities::SESSION_CONTROL.union(CommandCapabilities::PRESENTATION),
"/remote-env declares control plus presentation only"
);
// FEAT-025/FEAT-026 leaves remain legacy until their owning FEATs.
for name in ["export", "structcopy"] {
assert!(
!registry().has_contextual_handler(name),
"/{name} must stay on the legacy dispatch"
);
}
// FEAT-026 leaf remains legacy until its owning FEAT.
assert!(
!registry().has_contextual_handler("structcopy"),
"/structcopy must stay on the legacy dispatch"
);
}
#[test]
@@ -2936,4 +2950,113 @@ mod tests {
assert!(resume.action.is_none());
assert!(resume.message.is_none());
}
// ---------------------------------------------------------------------
// FEAT-025: session export entry registers through the portable bridge
// (D1/D3/D5). `/export` (alias `/daochu`) declares exactly SESSION_EXPORT;
// `/structcopy` remains a direct host handler for FEAT-026, so the root
// `session` frontier stays pending.
// ---------------------------------------------------------------------
#[test]
fn feat025_export_entry_registers_through_portable_bridge() {
use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
assert!(
registry().has_contextual_handler("export"),
"/export must register through the portable bridge"
);
assert!(
registry().has_contextual_handler("daochu"),
"/daochu must resolve to the same portable bridge entry"
);
let handler = registry()
.get("export")
.expect("entry")
.contextual_handler()
.expect("contextual handler");
let CommandHandler::Contextual { capabilities, .. } = handler else {
panic!("/export must be contextual");
};
assert_eq!(
capabilities,
CommandCapabilities::SESSION_EXPORT,
"/export declares export authority only"
);
// Least authority is catalogue-wide: no other registration may declare
// the session-export capability.
let export_declarers: Vec<&str> = registry()
.iter()
.filter(|command| {
command
.contextual_handler()
.is_some_and(|handler| match handler {
CommandHandler::Contextual { capabilities, .. } => {
capabilities.contains(CommandCapabilities::SESSION_EXPORT)
}
CommandHandler::Pure(_) => false,
})
})
.map(|command| command.info().name)
.collect();
assert_eq!(
export_declarers,
vec!["export"],
"exactly one registration may declare SESSION_EXPORT"
);
// The legacy function registration was removed for export only: the
// contextual entry has no direct host fallback, while `/structcopy`
// keeps its concrete-App `FunctionCommand` until FEAT-026.
let mut app = create_test_app();
let legacy = registry()
.get("export")
.expect("entry")
.execute(&mut app, None);
assert_eq!(
legacy.message.as_deref(),
Some("Error: command has no executable handler"),
"/export must not keep a legacy function registration"
);
assert!(
!registry().has_contextual_handler("structcopy"),
"/structcopy must stay on the legacy dispatch until FEAT-026"
);
}
#[test]
fn feat025_export_registered_handler_fails_safely_without_authority() {
// The dispatcher builds the envelope from the declared capabilities and
// calls this exact handler object. A narrower envelope that omits the
// export facet must return the safe error before parsing or performing
// any projection, clipboard, recovery, resolution, or write operation.
let handler = registry()
.get("export")
.expect("entry")
.contextual_handler()
.expect("contextual handler");
let codewhale_command_contract::handler::CommandHandler::Contextual {
handler: contextual,
..
} = handler
else {
panic!("/export must be contextual");
};
for arg in [None, Some("clipboard"), Some("file out.md")] {
let result = contextual(
codewhale_command_contract::handler::CommandContexts::empty(),
arg,
);
assert!(result.is_error, "{arg:?} must fail without authority");
assert_eq!(
result.message.as_deref(),
Some("Error: Command capability unavailable: session_export"),
"{arg:?} must keep the exact safe error"
);
assert!(result.action.is_none(), "{arg:?} must produce no action");
}
}
}
@@ -0,0 +1,273 @@
//! TUI-owned host services for the session-export slice (FEAT-025 D5/D7).
//!
//! This module deliberately lives outside `commands/groups/session`, which
//! FEAT-043 moves into `codewhale-commands`. It owns the two filesystem
//! services the `/export` slice needs from the host:
//!
//! * the shared `last-copy.md` recovery writer reused by `/export` and `/copy`
//! (D5), and
//! * the protected export-destination resolver/writer used only by `/export`
//! (D7).
//!
//! The algorithm, check order, error text, and platform behavior are the exact
//! baseline implementations relocated unchanged. The portable `/export`
//! handler reaches these services only through
//! `CommandSessionExportContext` — never by importing this module — so the
//! future portable group keeps no host dependency.
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Component, Path, PathBuf};
/// Write the export to a predictable last-copy file under the Codewhale home
/// (#5555): a clipboard-only export on SSH/headless must never dead-end the
/// user, so the same content lands at `<home>/exports/last-copy.md` and every
/// failure message names it. Returns the path when the write succeeded.
pub(crate) fn write_last_copy(markdown: &str) -> Option<PathBuf> {
let home = codewhale_paths::codewhale_home().ok().flatten()?;
let exports_dir = home.join("exports");
std::fs::create_dir_all(&exports_dir).ok()?;
let physical_home = std::fs::canonicalize(&home).ok()?;
let physical_exports = std::fs::canonicalize(&exports_dir).ok()?;
if !physical_exports.starts_with(&physical_home) {
return None;
}
write_last_copy_to(&exports_dir, markdown).ok()
}
fn write_last_copy_to(exports_dir: &Path, markdown: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(exports_dir)?;
let path = exports_dir.join("last-copy.md");
// Reuse the private atomic writer: random same-directory temp names,
// restrictive creation mode, symlink-safe replacement, and Windows
// replace retries are all part of the existing persistence contract.
crate::utils::write_atomic(&path, markdown.as_bytes())?;
Ok(path)
}
/// Resolve a requested export destination against the trusted workspace root.
///
/// Verbatim baseline algorithm (D7): trim; empty → `export path is empty`;
/// reject `..` components; canonicalize the workspace with its current
/// fallback; rebase workspace-absolute paths (raw or canonicalized); otherwise
/// join workspace-relative; require a file name. Errors are returned unwrapped
/// so the portable handler keeps the exact baseline text.
pub(crate) fn resolve_export_path(workspace: &Path, raw: &str) -> Result<PathBuf, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("export path is empty".to_string());
}
let requested = PathBuf::from(raw);
if requested
.components()
.any(|component| component == Component::ParentDir)
{
return Err(
"export paths may not contain `..`; use an explicit normalized absolute path instead"
.to_string(),
);
}
// Resolve the trusted workspace root once so platform aliases such as
// macOS `/var -> /private/var` do not make every workspace-relative
// export look like it traverses a user-controlled symlink. Requested
// components beneath that root remain lexical and are checked below.
let resolved_workspace =
fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
let path = if requested.is_absolute() {
if let Ok(relative) = requested.strip_prefix(workspace) {
resolved_workspace.join(relative)
} else if let Ok(relative) = requested.strip_prefix(&resolved_workspace) {
resolved_workspace.join(relative)
} else {
requested
}
} else {
resolved_workspace.join(requested)
};
if path.file_name().is_none() {
return Err(format!("export path must name a file: {}", path.display()));
}
Ok(path)
}
/// Write rendered export bytes to a resolved destination with the exact
/// baseline protections (D7): parent presence/directory checks, symlink
/// rejection (leaf and ancestors), no overwrite by default, non-regular-file
/// rejection, exclusive creation with `0o600` on Unix, `fsync`, cleanup after a
/// failed write, forced atomic replacement, and owner-only permissions.
pub(crate) fn write_export_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| format!("path has no parent directory: {}", path.display()))?;
let parent_metadata = fs::metadata(parent).map_err(|err| {
format!(
"parent directory {} is unavailable: {err}",
parent.display()
)
})?;
if !parent_metadata.is_dir() {
return Err(format!("parent is not a directory: {}", parent.display()));
}
reject_symlink_components(path)?;
match fs::symlink_metadata(path) {
Ok(_) if !force => {
return Err(format!(
"destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
path.display()
));
}
Ok(metadata) if !metadata.file_type().is_file() => {
return Err(format!(
"refusing to replace a non-regular file: {}",
path.display()
));
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(format!("could not inspect {}: {err}", path.display())),
}
if force {
crate::utils::write_atomic(path, contents).map_err(|err| err.to_string())?;
set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))?;
return Ok(());
}
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path).map_err(|err| {
if err.kind() == std::io::ErrorKind::AlreadyExists {
format!(
"destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
path.display()
)
} else {
err.to_string()
}
})?;
if let Err(err) = file.write_all(contents).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(path);
return Err(err.to_string());
}
set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))
}
fn reject_symlink_components(path: &Path) -> Result<(), String> {
for component_path in path.ancestors() {
match fs::symlink_metadata(component_path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!(
"refusing export through symlink component: {}",
component_path.display()
));
}
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"could not inspect path component {}: {err}",
component_path.display()
));
}
}
}
Ok(())
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn set_owner_only(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn last_copy_writes_the_export_without_leaving_a_temp_artifact() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path().join("exports");
let path = write_last_copy_to(&dir, "# export\n\nhello\n").expect("write");
assert_eq!(path, dir.join("last-copy.md"));
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"# export\n\nhello\n"
);
// The next export overwrites the same predictable path.
write_last_copy_to(&dir, "# second\n").expect("rewrite");
assert_eq!(std::fs::read_to_string(&path).expect("read"), "# second\n");
assert_eq!(
std::fs::read_dir(&dir).expect("read exports").count(),
1,
"the atomic writer must not leave a temp artifact"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o077, 0, "recovery copy must remain private");
}
}
#[test]
fn last_copy_stays_inside_an_explicit_codewhale_home() {
let ambient = TempDir::new().expect("ambient home");
let isolated = TempDir::new().expect("isolated Codewhale home");
let _env_lock = crate::test_support::lock_test_env();
let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path());
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path());
let path = write_last_copy("isolated response").expect("recovery copy");
assert_eq!(path, isolated.path().join("exports/last-copy.md"));
assert_eq!(
std::fs::read_to_string(&path).expect("read recovery copy"),
"isolated response"
);
assert!(
!ambient.path().join("exports/last-copy.md").exists(),
"explicit CODEWHALE_HOME must prevent ambient-home writes"
);
}
#[cfg(unix)]
#[test]
fn last_copy_refuses_an_exports_symlink_outside_codewhale_home() {
use std::os::unix::fs::symlink;
let ambient = TempDir::new().expect("ambient home");
let isolated = TempDir::new().expect("isolated Codewhale home");
let external = TempDir::new().expect("external dir");
symlink(external.path(), isolated.path().join("exports")).expect("exports symlink");
let _env_lock = crate::test_support::lock_test_env();
let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path());
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path());
assert_eq!(write_last_copy("must stay isolated"), None);
assert!(
!external.path().join("last-copy.md").exists(),
"recovery content must not escape through a nested symlink"
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,718 @@
//! FEAT-025 Phase 5: public command-surface parity coverage for `/export`.
//!
//! Phase 4 proved handler/rendering/adapter parity with fake facets and
//! relocated host regressions. This module proves the *observable command
//! surface* did not move when registration crossed the portable bridge:
//!
//! * registry metadata (name, alias, usage) and registry position,
//! * the `description_key` -> catalog bridge and its English/localized text,
//! * palette and slash-completion discovery, including the `/daochu` alias,
//! * least authority: exactly `SESSION_EXPORT` and no presentation facet,
//! * canonical-name/alias dispatch equivalence through the public `execute`
//! seam, with exact receipts and exact visible errors.
//!
//! Like the Phase 3 host regressions, this file deliberately lives at the
//! `commands` root — outside `groups/session`, which FEAT-043 moves into
//! `codewhale-commands`. No real terminal clipboard, GUI, or device is needed:
//! the harness uses the deterministic in-process clipboard and temporary
//! filesystem fixtures.
use tempfile::TempDir;
use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
use crate::commands::session_export_test_support::{
assert_only_export_facet_exposed, normalize_export_time,
};
use crate::commands::traits::CommandDiscovery;
use crate::commands::{CommandResult, execute};
use crate::config::{ApiProvider, Config};
use crate::test_support::{EnvVarGuard, TestEnvLock};
use crate::tui::app::{App, TuiOptions};
use crate::tui::clipboard::ClipboardHandler;
use crate::tui::command_palette;
use codewhale_localization::Locale;
use codewhale_models::{ContentBlock, Message, Role};
const EXPORT_NAME: &str = "export";
const EXPORT_ALIAS: &str = "daochu";
const EXPORT_USAGE: &str =
"/export [clipboard|file [--force] <path>|turn [clipboard|file [--force] <path>]]";
// Pinned to the authoritative catalog values (`crates/localization/locales/{en,fr}.json`,
// key `CmdExportDescription`). The `description_key` bridge must keep resolving the
// `/export` metadata to this catalog entry; the wording itself is owned by the catalog.
const EXPORT_ENGLISH: &str = "Copy a safe export, or write it to a file";
const EXPORT_FRENCH: &str =
"Copier un export sûr de la conversation, ou l'écrire dans un fichier explicite";
/// Shared host-isolated harness (same isolation order as the Phase 3
/// `ExportHarness`: env guards, then the lock, then the temporary directory).
struct SurfaceHarness {
app: App,
_home: EnvVarGuard,
_codewhale_home: EnvVarGuard,
_env_lock: TestEnvLock,
temp: TempDir,
}
impl SurfaceHarness {
fn new() -> Self {
let env_lock = crate::test_support::lock_test_env();
let temp = TempDir::new().expect("tempdir");
let home = temp.path().join("home");
std::fs::create_dir_all(&home).expect("home dir");
let _home = EnvVarGuard::set("HOME", &home);
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home);
let options = TuiOptions {
skills_dir: temp.path().join("skills"),
memory_path: temp.path().join("memory.md"),
notes_path: temp.path().join("notes.txt"),
mcp_config_path: temp.path().join("mcp.json"),
..crate::test_support::test_tui_options(temp.path())
};
let app = App::new(options, &Config::default());
Self {
app,
_home,
_codewhale_home,
_env_lock: env_lock,
temp,
}
}
fn last_copy_path(&self) -> std::path::PathBuf {
self.temp
.path()
.join("home")
.join("exports")
.join("last-copy.md")
}
}
fn text_message(role: Role, text: &str) -> Message {
Message {
role,
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
fn export_info() -> &'static crate::commands::CommandInfo {
crate::commands::get_command_info(EXPORT_NAME).expect("/export must be registered")
}
fn result_message(result: &CommandResult) -> &str {
result.message.as_deref().unwrap_or_default()
}
#[test]
fn export_registration_metadata_and_registry_position_are_unchanged() {
let info = export_info();
assert_eq!(info.name, EXPORT_NAME);
assert_eq!(info.aliases, &[EXPORT_ALIAS]);
assert_eq!(info.usage, EXPORT_USAGE);
// The alias resolves to the same registry entry and canonical metadata.
let registry = crate::commands::registry();
let via_alias = registry
.get(EXPORT_ALIAS)
.expect("/daochu must resolve to the export entry");
assert_eq!(via_alias.info().name, EXPORT_NAME);
assert_eq!(via_alias.info().usage, EXPORT_USAGE);
assert!(
registry.get(EXPORT_NAME).is_some(),
"canonical /export must remain registered"
);
// Registry order is preserved: remote-env -> export -> structcopy.
let names: Vec<&str> = crate::commands::command_infos()
.iter()
.map(|info| info.name)
.collect();
let position = |name: &str| {
names
.iter()
.position(|candidate| *candidate == name)
.unwrap_or_else(|| panic!("{name} must be registered; found {names:?}"))
};
assert!(
position("remote-env") < position(EXPORT_NAME),
"export must stay after remote-env in the session group order"
);
assert!(
position(EXPORT_NAME) < position("structcopy"),
"export must stay before structcopy in the session group order"
);
}
#[test]
fn export_declares_exactly_session_export_without_presentation_authority() {
let command = crate::commands::registry()
.get(EXPORT_NAME)
.expect("/export must be registered");
let handler = command
.contextual_handler()
.expect("/export must register through the portable bridge");
let CommandHandler::Contextual { capabilities, .. } = handler else {
panic!("/export must be a contextual handler")
};
assert_eq!(
capabilities,
CommandCapabilities::SESSION_EXPORT,
"/export declares exactly SESSION_EXPORT"
);
assert!(
!capabilities.contains(CommandCapabilities::PRESENTATION),
"export must not request presentation authority"
);
// The restricted projection exposes only the declared facet. The helper
// destructures all sixteen `ContextParts` slots, so this is exhaustive
// rather than a spot-check of the fields listed below by hand.
let mut harness = SurfaceHarness::new();
let mut bundle = harness.app.command_contexts();
let export_only = bundle
.contexts(CommandCapabilities::SESSION_EXPORT)
.into_parts();
assert_only_export_facet_exposed(export_only);
}
#[test]
fn export_description_bridge_preserves_english_localized_and_discovery_metadata() {
let info = export_info();
// `description_key` -> `key_to_message_id` -> catalog: English reference
// and the shipped French pack both resolve through the same bridge.
assert_eq!(&*info.description_for(Locale::En), EXPORT_ENGLISH);
assert_eq!(&*info.description_for(Locale::Fr), EXPORT_FRENCH);
// Palette text keeps the canonical description and advertises the alias.
let palette = info.palette_description_for(Locale::En);
assert!(
palette.contains(EXPORT_ENGLISH),
"palette description must keep the catalog text: {palette}"
);
assert!(
palette.contains(EXPORT_ALIAS),
"palette description must keep the alias: {palette}"
);
// Discovery classification and visibility are part of the surface.
assert_eq!(info.discovery(), CommandDiscovery::Primary);
assert!(!info.is_unlisted());
assert!(info.show_in_empty_discovery());
assert!(info.show_in_slash_completion("/exp"));
assert!(info.requires_argument());
}
#[test]
fn export_is_discoverable_by_name_and_alias_in_palette_and_slash_completion() {
// Pure discovery: a workspace path is all these surfaces need, so this test
// avoids the shared environment lock the host fixtures require.
let workspace_dir = TempDir::new().expect("tempdir");
let workspace = workspace_dir.path();
let skills_dir = workspace.join("skills");
let mcp_config = workspace.join("mcp.json");
let entries = command_palette::build_entries(
Locale::En,
&skills_dir,
false,
workspace,
&mcp_config,
None,
);
let export_row = entries
.iter()
.find(|entry| entry.label == "/export")
.expect("/export must appear in the command palette");
assert!(
export_row.description.contains(EXPORT_ENGLISH),
"palette row must carry the catalog description: {}",
export_row.description
);
assert!(
export_row.description.contains(EXPORT_ALIAS),
"palette row must advertise /daochu: {}",
export_row.description
);
let by_prefix = crate::tui::widgets::slash_completion_hints(
"/exp",
64,
&[],
Locale::En,
Some(workspace),
ApiProvider::Deepseek,
);
assert!(
by_prefix.iter().any(|hint| hint.name == "/export"),
"/exp must complete to /export"
);
let by_alias = crate::tui::widgets::slash_completion_hints(
"/daoc",
64,
&[],
Locale::En,
Some(workspace),
ApiProvider::Deepseek,
);
let alias_row = by_alias
.iter()
.find(|hint| hint.name == "/export")
.expect("/daoc must surface the export command");
assert_eq!(
alias_row.alias_hint.as_deref(),
Some(EXPORT_ALIAS),
"slash completion must explain the alias match"
);
// The alias token is not a second registry entry.
assert!(
!entries.iter().any(|entry| entry.label == "/daochu"),
"the alias must not create a duplicate palette row"
);
}
#[test]
fn public_dispatch_canonical_name_and_alias_are_byte_equivalent() {
let mut harness = SurfaceHarness::new();
let last_copy = harness.last_copy_path();
let app = &mut harness.app;
app.current_session_id = Some("session-987654321".to_string());
app.api_messages = vec![
text_message(Role::User, "Please export this conversation"),
text_message(Role::Assistant, "Exported on request."),
];
app.clipboard = ClipboardHandler::for_test(false, false);
let canonical = execute("/export clipboard", app);
assert!(!canonical.is_error, "{:?}", canonical.message);
let canonical_markdown = app
.clipboard
.last_written_text()
.expect("canonical clipboard payload")
.to_string();
let canonical_recovery = std::fs::read_to_string(&last_copy).expect("canonical recovery copy");
// Reset the deterministic clipboard so the second dispatch records its own
// delivery; state is otherwise identical.
app.clipboard = ClipboardHandler::for_test(false, false);
let alias = execute("/daochu clipboard", app);
assert!(!alias.is_error, "{:?}", alias.message);
let alias_markdown = app
.clipboard
.last_written_text()
.expect("alias clipboard payload")
.to_string();
let alias_recovery = std::fs::read_to_string(&last_copy).expect("alias recovery copy");
assert_eq!(
result_message(&canonical),
result_message(&alias),
"canonical and alias receipts must be identical"
);
assert_eq!(
normalize_export_time(&canonical_markdown),
normalize_export_time(&alias_markdown),
"canonical and alias clipboard payloads must be identical"
);
assert_eq!(
normalize_export_time(&canonical_recovery),
normalize_export_time(&alias_recovery),
"canonical and alias recovery copies must be identical"
);
assert!(canonical_markdown.contains("# Codewhale conversation export"));
assert!(canonical_markdown.contains("Please export this conversation"));
}
#[test]
fn public_dispatch_file_receipts_and_usage_errors_are_exact() {
let mut harness = SurfaceHarness::new();
let workspace = harness.temp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace");
let app = &mut harness.app;
app.workspace = workspace.clone();
app.api_messages = vec![text_message(Role::User, "file export body")];
let resolved = std::fs::canonicalize(&workspace)
.expect("canonical workspace")
.join("transcript.md");
let first = execute("/export file transcript.md", app);
assert!(!first.is_error, "{:?}", first.message);
assert_eq!(
result_message(&first),
format!("Conversation exported to {}", resolved.display())
);
let refused = execute("/export file transcript.md", app);
assert!(refused.is_error, "{:?}", refused.message);
assert_eq!(
result_message(&refused),
format!(
"Error: Failed to export Conversation to {}: destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
resolved.display(),
resolved.display()
)
);
let forced = execute("/export file --force transcript.md", app);
assert!(!forced.is_error, "{:?}", forced.message);
assert_eq!(
result_message(&forced),
format!(
"Conversation exported to {} (overwrite explicitly allowed)",
resolved.display()
)
);
let usage = export_info().usage;
for (arg, reason) in [
("file", "missing file path"),
("file --force", "missing file path"),
("clipboard extra.md", "clipboard does not accept a path"),
] {
let result = execute(&format!("/export {arg}"), app);
assert!(result.is_error, "{arg} must be rejected");
assert_eq!(
result_message(&result),
format!("Error: {reason}. Usage: {usage}"),
"{arg} must keep the baseline usage error"
);
}
}
// ---------------------------------------------------------------------------
// FEAT-025 Phase 7 (Task 7.2): extraction-readiness and scope audits.
//
// These audits protect the D4/D5/D8/D9 ownership contract that the portable
// source must satisfy before FEAT-043 can physically move the export slice into
// `codewhale-commands`. They are source-level checks, not runtime behavior
// tests, and they live at the `commands` root outside the movable group.
// ---------------------------------------------------------------------------
/// Production portion of the portable export source: everything before its
/// `#[cfg(test)]` module, with comments removed (full-line and trailing) so
/// rationale text (`Concrete `App`, clipboard, filesystem, ...`) is never
/// mistaken for an import or a concrete host symbol.
fn portable_production_source(source: &str) -> String {
let mut production = String::new();
for line in source.lines() {
if line.trim_start().starts_with("#[cfg(test)]") {
break;
}
let code = strip_line_comment(line);
if code.trim().is_empty() {
continue;
}
production.push_str(code.trim_end());
production.push('\n');
}
production
}
/// Strip a trailing `//` comment without touching `//` inside a string or
/// character literal.
///
/// A naive strip would be wrong in both directions: portable code carries URL
/// literals such as `https://…`, and a marker string could hide a real token.
/// Lifetime ticks (`&'a str`) are not treated as character literals so a
/// following comment is still removed.
fn strip_line_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'"' => {
i += 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => break,
_ => i += 1,
}
}
}
b'\'' => {
let opens_literal = matches!(
(bytes.get(i + 1), bytes.get(i + 2)),
(Some(b'\\'), _) | (Some(_), Some(b'\''))
);
if opens_literal {
i += 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'\'' => break,
_ => i += 1,
}
}
}
}
b'/' if bytes.get(i + 1) == Some(&b'/') => return &line[..i],
_ => {}
}
i += 1;
}
line
}
/// Word-boundary identifier match.
///
/// `production.contains("App")` fires on a legitimate `Append`, while an exact
/// equality check would miss `App::new`. `\b` matches the identifier token
/// only, so the audit fails for the right reason.
fn contains_identifier(haystack: &str, identifier: &str) -> bool {
let pattern = format!(r"\b{}\b", regex::escape(identifier));
regex::Regex::new(&pattern)
.expect("identifier regex")
.is_match(haystack)
}
/// The audits above are only as trustworthy as their scanners, so pin the two
/// behaviours that decide whether a finding is real: comment stripping must not
/// eat string literals, and identifier matching must not fire on a longer word.
#[test]
fn portable_source_audit_helpers_are_token_aware() {
assert_eq!(strip_line_comment("let x = 1; // App"), "let x = 1; ");
assert_eq!(strip_line_comment("// whole line"), "");
assert_eq!(
strip_line_comment("let url = \"https://example.test/a\";"),
"let url = \"https://example.test/a\";"
);
assert_eq!(
strip_line_comment("let c = '/'; let y = 1; // keep"),
"let c = '/'; let y = 1; "
);
// A lifetime tick must not swallow the rest of the line.
assert_eq!(
strip_line_comment("fn f<'a>(x: &'a str) {} // note"),
"fn f<'a>(x: &'a str) {} "
);
assert!(contains_identifier("let app = App::new();", "App"));
assert!(!contains_identifier("values.append(item);", "App"));
assert!(!contains_identifier("struct AppNew;", "App"));
assert!(contains_identifier("use ratatui::text::Line;", "ratatui"));
assert!(contains_identifier("unsafe { x }", "unsafe"));
}
#[test]
fn portable_export_source_has_no_host_dependency() {
let source = include_str!("groups/session/export.rs");
let production = portable_production_source(source);
// Only the external contract, the pure sanitizer, `serde_json`, `std`, and
// the temporary FEAT-037 `CommandResult` exception may be imported.
let allowed_use_prefixes = [
"use std::",
"use codewhale_command_contract",
"use codewhale_secrets",
"use serde_json",
"use super::CommandResult",
];
for line in production.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with("use ") {
continue;
}
assert!(
allowed_use_prefixes
.iter()
.any(|prefix| trimmed.starts_with(prefix)),
"portable /export import is not allowed: {trimmed}"
);
}
// No concrete host module or type may appear in portable production code.
// Syntactic tokens are matched literally; bare identifiers are matched on
// word boundaries so `Append` cannot false-trigger on `App`.
for token in [
"use crate::",
"crate::tui",
"crate::client",
"crate::config",
"crate::snapshot",
"crate::session_manager",
"std::fs",
"std::net",
"std::process",
] {
assert!(
!production.contains(token),
"portable /export must not reference {token}"
);
}
for identifier in [
"App",
"AppAction",
"ClipboardHandler",
"SnapshotRepo",
"SessionManager",
"HistoryCell",
"ContentBlock",
"OpenOptions",
"ratatui",
"crossterm",
] {
assert!(
!contains_identifier(&production, identifier),
"portable /export must not reference {identifier}"
);
}
// The bounded FEAT-037 exception is exactly `CommandResult`: no action
// payload, deferred effect, or host receipt type may cross the boundary.
assert!(
!production.contains("super::App"),
"only CommandResult may cross the FEAT-037 boundary"
);
}
#[test]
fn portable_export_source_carries_no_hidden_authority() {
let source = include_str!("groups/session/export.rs");
let production = portable_production_source(source);
// Host authority must not hide behind a callback, boxed closure, erased
// receipt, or unsafe escape hatch.
for token in [
"Box<",
"dyn Fn",
"impl Fn",
"fn(&mut dyn",
"&mut dyn",
"transmute",
] {
assert!(
!production.contains(token),
"portable /export must not contain {token}"
);
}
assert!(
!contains_identifier(&production, "unsafe"),
"portable /export must not contain unsafe"
);
// Missing authority fails with the exact safe error and never panics: the
// facet is destructured, not `.expect()`ed.
for token in [".expect(", ".unwrap(", "panic!(", "unreachable!(", "todo!("] {
assert!(
!production.contains(token),
"portable /export must not contain {token}"
);
}
assert!(
production.contains(
"return CommandResult::error(\"Command capability unavailable: session_export\".to_string());"
),
"portable /export must keep the exact safe missing-authority error"
);
}
#[test]
fn shared_sanitizer_is_one_pure_acyclic_implementation() {
// Both `/export` and the still-legacy `/structcopy` consume the single
// implementation in the pure `codewhale-secrets` crate.
let export_source = include_str!("groups/session/export.rs");
let structcopy_source = include_str!("groups/session/structcopy.rs");
assert!(
export_source.contains("use codewhale_secrets::sanitize::"),
"portable /export must consume the shared sanitizer"
);
assert!(
structcopy_source.contains("use codewhale_secrets::sanitize::"),
"/structcopy must consume the same shared sanitizer"
);
// The module prose must name the new owner: a stale `export::<helper>` seam
// reference is exactly the comment drift inherited from PR #5525.
for stale in ["export::redact_json", "export::sanitize_text"] {
assert!(
!structcopy_source.contains(stale),
"/structcopy comments must not cite the removed {stale} seam"
);
}
// Fast local tripwire only. The authoritative check is the graph scan in
// `scripts/check-command-crate-boundaries.py`, which asserts that neither
// `codewhale-command-contract` nor `codewhale-secrets` reaches the TUI; this
// manifest read just fails sooner when someone edits the manifest by hand.
let secrets_manifest = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../secrets/Cargo.toml"
))
.expect("the shared sanitizer crate manifest must be readable");
assert!(
!secrets_manifest.contains("codewhale-tui"),
"the shared sanitizer crate must not depend on codewhale-tui"
);
// There is no second sanitizer implementation hiding in the portable slice.
let production = portable_production_source(export_source);
for duplicated in [
"fn sanitize_text",
"fn redact_json",
"fn redact_url_for_display",
"fn strip_ansi",
] {
assert!(
!production.contains(duplicated),
"portable /export must not duplicate {duplicated}"
);
}
}
#[test]
fn host_bound_fixtures_stay_outside_the_movable_group() {
// FEAT-043 moves `groups/session` into `codewhale-commands`. Real-host
// fixtures and the shared recovery writer must therefore stay at the
// `commands` root, and the portable slice must not embed host fixtures.
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
for relative in [
"src/commands/session_export_regression_tests.rs",
"src/commands/session_export_surface_tests.rs",
"src/commands/session_export_test_support.rs",
"src/commands/session_export_host.rs",
] {
assert!(
manifest_dir.join(relative).exists(),
"host fixture {relative} must stay outside groups/session"
);
}
// The baseline-captured export goldens are host-bound (they include the
// host-derived metadata and redaction output only the real adapter can
// produce), so they must live at the `commands` root with the suites that
// consume them rather than inside the movable group.
for relative in [
"src/commands/fixtures/export_conversation_baseline.md",
"src/commands/fixtures/export_turn_baseline.md",
"src/commands/fixtures/export_history_fallback_recorded_baseline.md",
"src/commands/fixtures/export_correlation_recorded_baseline.md",
] {
assert!(
manifest_dir.join(relative).exists(),
"baseline golden {relative} must stay outside groups/session"
);
}
let export_source = include_str!("groups/session/export.rs");
for host_fixture in [
"SessionExportAdapter",
"ExportHarness",
"tempfile",
"ClipboardHandler",
"SnapshotRepo",
"HistoryCell",
] {
assert!(
!export_source.contains(host_fixture),
"portable /export must not embed the host fixture {host_fixture}"
);
}
}
@@ -0,0 +1,178 @@
//! Shared, host-bound test support for the FEAT-025 session-export slice.
//!
//! Lives at the `commands` root — outside `groups/session`, which FEAT-043
//! moves into `codewhale-commands` — so the public-surface and host-regression
//! suites reuse one implementation instead of drifting copies.
use codewhale_command_contract::handler::ContextParts;
use std::sync::OnceLock;
use regex::Regex;
/// Replace the host-derived `- Exported:` timestamp value so two exports of
/// identical state compare byte-for-byte. Production timestamp semantics stay
/// untouched (D3/observable-behavior rule: host metadata derivation is
/// preserved; tests control comparison, not the adapter).
pub(crate) fn normalize_export_time(markdown: &str) -> String {
let mut normalized = String::with_capacity(markdown.len());
for line in markdown.split_inclusive('\n') {
if let Some(rest) = line.strip_prefix("- Exported: ") {
let newline = if rest.ends_with('\n') { "\n" } else { "" };
normalized.push_str("- Exported: <time>");
normalized.push_str(newline);
} else {
normalized.push_str(line);
}
}
normalized
}
/// Replace the turn-handoff wall-clock generation stamp so a captured golden
/// compares byte-for-byte.
///
/// `turn_handoff_markdown` stamps the header with `generated <now>`; the
/// renderer itself is TUI-owned and deliberately not migrated (D2), so tests
/// normalise the stamp instead of changing production timestamp semantics.
pub(crate) fn normalize_turn_generated_at(markdown: &str) -> String {
let mut normalized = String::with_capacity(markdown.len());
for line in markdown.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
let newline = &line[trimmed.len()..];
if let Some(head) = trimmed.strip_prefix("_Status: ")
&& let Some((status, _)) = head.split_once(" \u{b7} generated ")
{
normalized.push_str("_Status: ");
normalized.push_str(status);
normalized.push_str(" \u{b7} generated <timestamp>_");
normalized.push_str(newline);
continue;
}
normalized.push_str(line);
}
normalized
}
/// Normalise the two volatile fields a recorded-restore-point export carries:
/// the snapshot id (a git SHA over a commit whose date is wall-clock) and the
/// `Recorded (UTC)` table cell.
///
/// Snapshot commits are created with `git commit-tree` and no pinned
/// author/committer date, so both the SHA and the timestamp change on every run.
/// Everything else in the document - the table structure, the 12-character id
/// truncation, the correlation wording, the ambiguity warning, and the
/// no-match line - is compared verbatim.
pub(crate) fn normalize_snapshot_identity(markdown: &str) -> String {
fn snapshot_id_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"`[0-9a-f]{12}`").expect("snapshot-id regex"))
}
fn recorded_time_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\| \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \|").expect("recorded-time regex")
})
}
let with_ids = snapshot_id_regex().replace_all(markdown, "`<snapshot-id>`");
recorded_time_regex()
.replace_all(&with_ids, "| <recorded> |")
.into_owned()
}
/// Normalisation for a document captured from the recorded-restore-point state:
/// the export stamp plus the snapshot identity.
pub(crate) fn normalize_recorded_export(markdown: &str) -> String {
normalize_snapshot_identity(&normalize_export_time(markdown))
}
/// Assert that an envelope built from exactly `SESSION_EXPORT` exposes the
/// export facet and no other facet.
///
/// The exhaustive destructuring is deliberate. `ContextParts` is not
/// `#[non_exhaustive]`, so adding a facet fails to compile here until the new
/// slot is classified, and every slot is asserted absent in one place. That
/// turns the "exposure of no unrelated facet" acceptance criterion into a
/// structural guarantee rather than a spot-check of a chosen few fields.
pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) {
let ContextParts {
session,
model,
cost,
mode_policy,
system_prompt,
skills,
workspace,
presentation,
media,
memory,
project,
skill_group,
plugin,
lifecycle,
control,
export,
} = parts;
assert!(export.is_some(), "the export facet must be exposed");
let unrelated = [
("session", session.is_some()),
("model", model.is_some()),
("cost", cost.is_some()),
("mode_policy", mode_policy.is_some()),
("system_prompt", system_prompt.is_some()),
("skills", skills.is_some()),
("workspace", workspace.is_some()),
("presentation", presentation.is_some()),
("media", media.is_some()),
("memory", memory.is_some()),
("project", project.is_some()),
("skill_group", skill_group.is_some()),
("plugin", plugin.is_some()),
("lifecycle", lifecycle.is_some()),
("control", control.is_some()),
];
for (facet, present) in unrelated {
assert!(
!present,
"{facet} must stay unavailable to a SESSION_EXPORT-only envelope"
);
}
}
/// The recorded-golden normaliser must replace *only* the wall-clock fields; a
/// normaliser that over-matched would turn the golden comparison into a
/// tautology, so pin its exact behaviour.
#[test]
fn recorded_golden_normalisers_replace_only_volatile_fields() {
let doc = concat!(
"- Exported: 2026-09-11T14:39:58Z\n",
"\n",
"| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test |\n",
"- Restore points: N1 `3d0ad76f0222` (pre-turn turn 3)\n",
"| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 |\n",
);
let out = normalize_recorded_export(doc);
assert!(out.contains("- Exported: <time>\n"), "{out}");
assert!(
out.contains("| 1 | `<snapshot-id>` | <recorded> | pre-turn:3: Fix the login test |"),
"{out}"
);
assert!(
out.contains("- Restore points: N1 `<snapshot-id>` (pre-turn turn 3)"),
"{out}"
);
assert!(
out.contains("| 2 | `<snapshot-id>` | <recorded> | tool:call-1 |"),
"{out}"
);
// Nothing volatile survives, and nothing static was touched.
assert!(!out.contains("3d0ad76f0222"), "{out}");
assert!(!out.contains("6a3bc0698866"), "{out}");
assert!(!out.contains("2026-09-11T14:39:58Z"), "{out}");
assert!(out.contains("pre-turn:3: Fix the login test"), "{out}");
assert!(out.contains("(pre-turn turn 3)"), "{out}");
}
+21 -2
View File
@@ -1528,8 +1528,27 @@ fi
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
let text = read_text_with_wlpaste_using_argv(script.to_str().unwrap())
.expect("read text through wl-paste helper");
// A freshly written helper script can transiently report ETXTBSY
// ("Text file busy") when a concurrent test in this parallel suite
// forks while the write descriptor is still inherited. Retry the
// exec briefly so this assertion exercises the helper contract
// rather than the fork/exec window; the bound is load tolerance,
// not the behavior under test (same class as #5929).
let mut attempts = 0;
let text = loop {
match read_text_with_wlpaste_using_argv(script.to_str().unwrap()) {
Ok(text) => break text,
Err(error) => {
attempts += 1;
let busy = error.to_string().contains("Text file busy");
assert!(
busy && attempts < 100,
"read text through wl-paste helper: {error:#}"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
};
assert_eq!(text, "from-wayland");
}
+9 -98
View File
@@ -234,18 +234,13 @@ pub fn take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
/// Strip every ANSI escape sequence from `s` into `out`, preserving only the
/// visible characters. ratatui's buffer drops the leading `ESC` byte but
/// happily paints every other byte of an escape (`[`, `0`, `;`, `m`, OSC
/// payloads, etc.) into a buffer cell, drifting columns. Tool stdout that
/// includes ANSI (e.g. `gh`/`git` with color forced on, anything run through
/// a PTY) must be sanitized before it enters the transcript.
/// Strip ANSI/OSC/control sequences from `s` into `out`.
///
/// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS,
/// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers
/// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest.
/// Delegates to the single shared implementation in
/// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so `/export`, `/structcopy`,
/// and the renderer cannot drift.
pub fn strip_ansi_into(s: &str, out: &mut String) {
strip_ansi_impl(s, out, false);
codewhale_secrets::sanitize::strip_ansi_into(s, out);
}
/// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
@@ -254,100 +249,16 @@ pub fn strip_ansi_into(s: &str, out: &mut String) {
/// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control
/// bytes — is still removed; only the styling survives.
pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) {
strip_ansi_impl(s, out, true);
}
fn strip_ansi_impl(s: &str, out: &mut String, keep_sgr: bool) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
let next = bytes[i + 1];
match next {
// CSI: ESC [ ... <final byte 0x40..=0x7E>
b'[' => {
let mut j = i + 2;
let mut final_byte = 0u8;
while j < bytes.len() {
let b = bytes[j];
if (0x40..=0x7e).contains(&b) {
final_byte = b;
j += 1;
break;
}
j += 1;
}
if keep_sgr
&& final_byte == b'm'
&& let Ok(seq) = std::str::from_utf8(&bytes[i..j])
{
out.push_str(seq);
}
i = j;
continue;
}
// OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL
b']' | b'P' | b'X' | b'^' | b'_' => {
let mut j = i + 2;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
// Standalone two-byte ESC sequence (RIS, charset selection, etc.)
_ => {
i += 2;
continue;
}
}
}
// Strip lone control bytes that ratatui would otherwise drop (and which
// mean nothing in transcript output) but keep \n, \r, \t as legitimate
// formatting.
let b = bytes[i];
if b < 0x80 {
if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' {
i += 1;
continue;
}
out.push(b as char);
i += 1;
} else {
// UTF-8 multi-byte sequence: copy the whole code point intact.
// Pushing `b as char` would mis-decode it as Latin-1 and mangle
// non-ASCII text (CJK, accented Latin, emoji, …).
let len = utf8_seq_len(b);
let end = (i + len).min(bytes.len());
if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
out.push_str(chunk);
}
i = end;
}
}
codewhale_secrets::sanitize::strip_ansi_keep_sgr_into(s, out);
}
/// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back
/// to `1` for continuation bytes / invalid leads so callers always make
/// forward progress.
///
/// Delegates to the shared implementation in [`codewhale_secrets::sanitize`].
fn utf8_seq_len(lead: u8) -> usize {
if lead < 0xc0 {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
}
codewhale_secrets::sanitize::utf8_seq_len(lead)
}
/// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
+33 -20
View File
@@ -3,8 +3,11 @@
Enforces the EPIC-006 boundary contract:
1. `codewhale-command-contract` may not transitively depend on
`codewhale-tui` (normal edges, via `cargo metadata`).
1. `codewhale-command-contract` and `codewhale-secrets` may not transitively
depend on `codewhale-tui` (normal edges, via `cargo metadata`).
`codewhale-secrets` owns the shared output sanitizer that portable command
helpers consume (FEAT-025 D4), so it must stay TUI-free before
`codewhale-commands` depends on it.
2. `codewhale-command-contract` source may not import the concrete `App`,
widget/renderer/view/event-loop surfaces, or `ratatui`/`crossterm`.
3. No composite `CommandContext` symbol (supertrait/struct/enum) may exist in
@@ -33,6 +36,13 @@ CONTRACT_DIR = REPO_ROOT / "crates" / "command-contract" / "src"
CONTRACT_PACKAGE = "codewhale-command-contract"
FORBIDDEN_TUI_PACKAGE = "codewhale-tui"
# Workspace packages that must stay free of any (normal) path to the TUI. The
# contract carries the portable shapes; `codewhale-secrets` owns the shared pure
# sanitizer those shapes' handlers consume. Both are prerequisites for
# `codewhale-commands` (FEAT-016/043), so a TUI edge here would silently drag
# the whole TUI into the extracted command crate.
TUI_FREE_PACKAGES = (CONTRACT_PACKAGE, "codewhale-secrets")
# Import lines that must never appear in the contract (narrowly scoped: real
# imports only, comments never match because they do not start with `use`).
FORBIDDEN_IMPORT_PATTERNS = [
@@ -123,24 +133,27 @@ def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool:
def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation]:
"""The prototype contract must not reach codewhale-tui."""
if CONTRACT_PACKAGE not in graph:
return [
BoundaryViolation(
"dependency-graph",
CONTRACT_PACKAGE,
"workspace package missing from the cargo metadata graph",
"""No TUI-free package may reach codewhale-tui through normal edges."""
violations: list[BoundaryViolation] = []
for package in TUI_FREE_PACKAGES:
if package not in graph:
violations.append(
BoundaryViolation(
"dependency-graph",
package,
"workspace package missing from the cargo metadata graph",
)
)
]
if reaches_tui(CONTRACT_PACKAGE, graph):
return [
BoundaryViolation(
"dependency-graph",
CONTRACT_PACKAGE,
f"transitively depends on {FORBIDDEN_TUI_PACKAGE}",
continue
if reaches_tui(package, graph):
violations.append(
BoundaryViolation(
"dependency-graph",
package,
f"transitively depends on {FORBIDDEN_TUI_PACKAGE}",
)
)
]
return []
return violations
def check_contract_source_text(text: str, display_path: str) -> list[BoundaryViolation]:
@@ -211,8 +224,8 @@ def main(argv: list[str] | None = None) -> int:
print(f" {violation}", file=sys.stderr)
return 1
print(
f"[command-crate-boundaries] PASS: {CONTRACT_PACKAGE} has no "
f"{FORBIDDEN_TUI_PACKAGE} edge; "
f"[command-crate-boundaries] PASS: "
f"{', '.join(TUI_FREE_PACKAGES)} have no {FORBIDDEN_TUI_PACKAGE} edge; "
"no forbidden import, composite context, or boxed handler in the contract"
)
return 0
@@ -21,6 +21,8 @@ def valid_graph() -> dict[str, set[str]]:
return {
"codewhale-command-contract": {"codewhale-core"},
"codewhale-core": set(),
"codewhale-secrets": {"codewhale-paths"},
"codewhale-paths": set(),
"codewhale-tui": set(),
}
@@ -46,6 +48,28 @@ class DependencyTests(unittest.TestCase):
self.assertEqual(len(violations), 1)
self.assertIn("missing", str(violations[0]))
def test_missing_sanitizer_fails(self) -> None:
graph = valid_graph()
del graph["codewhale-secrets"]
violations = mod.check_dependency_graph(graph)
self.assertEqual(len(violations), 1)
self.assertIn("codewhale-secrets", str(violations[0]))
def test_sanitizer_reaching_tui_fails(self) -> None:
# The shared sanitizer is consumed by portable command helpers, so a TUI
# edge would pull the whole TUI into the extracted command crate.
graph = valid_graph()
graph["codewhale-paths"].add("codewhale-tui")
violations = mod.check_dependency_graph(graph)
self.assertEqual(len(violations), 1)
self.assertIn("codewhale-secrets", str(violations[0]))
def test_both_packages_reaching_tui_fails_twice(self) -> None:
graph = valid_graph()
graph["codewhale-core"].add("codewhale-tui")
graph["codewhale-paths"].add("codewhale-tui")
self.assertEqual(len(mod.check_dependency_graph(graph)), 2)
def test_dev_dependency_is_not_a_normal_edge(self) -> None:
metadata = {"packages": [
{"name": "codewhale-command-contract", "dependencies": [
@@ -53,6 +77,7 @@ class DependencyTests(unittest.TestCase):
{"name": "codewhale-core", "kind": None},
]},
{"name": "codewhale-core", "dependencies": []},
{"name": "codewhale-secrets", "dependencies": []},
{"name": "codewhale-tui", "dependencies": []},
]}
graph = mod.dependency_graph(metadata)