From 6212208061734fbc36934cd0f395f5e49ab1992b Mon Sep 17 00:00:00 2001 From: snchimata <38873813+snchimata@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:42:44 -0400 Subject: [PATCH 1/3] feat(core): add staged raw->RTK->tokenfold receipt types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PipelineReport + PipelineStageReport and the additive CompressionReport.pipeline field (serde default, backward-compatible) per INTERFACES.md §1.9/§2. Separates savings and recoverability across composed stages so RTK's savings are never credited to tokenfold. F-055. --- crates/tokenfold-core/src/report.rs | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/tokenfold-core/src/report.rs b/crates/tokenfold-core/src/report.rs index 311483d..ae7bef6 100644 --- a/crates/tokenfold-core/src/report.rs +++ b/crates/tokenfold-core/src/report.rs @@ -16,6 +16,10 @@ pub struct CompressionReport { pub format: String, pub task_scope: String, pub request_id: Option, + /// F-055: staged `raw -> RTK -> tokenfold` accounting. `None` for the common + /// single-stage path; populated only by RTK-composed `wrap --rtk` runs. + #[serde(default)] + pub pipeline: Option, pub quality: Option, pub budget: Option, pub cache: Option, @@ -61,6 +65,7 @@ impl CompressionReport { format, task_scope, request_id: None, + pipeline: None, quality: None, budget: None, cache: None, @@ -82,6 +87,53 @@ pub struct EstimatorInfo { pub is_exact: bool, } +/// F-055: separates savings and recoverability across composed stages (RTK then +/// tokenfold) so RTK's savings are never credited to tokenfold. The top-level +/// `original_tokens` keeps its v1 meaning — tokens *entering* `tokenfold_core`, +/// which is the post-RTK count when composed. See INTERFACES.md §1.9 / §2. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipelineReport { + /// Pre-RTK byte count. `Some` only when a complete raw capture was observed. + pub raw_input_bytes: Option, + /// Pre-RTK token count. `Some` only when raw capture is complete. + pub raw_input_tokens: Option, + pub final_output_bytes: usize, + /// Equals top-level `compressed_tokens`. + pub final_output_tokens: usize, + /// Populated only when raw and final counts use the same estimator. + pub total_saved_tokens: Option, + /// `"complete"`, `"partial"`, `"unavailable"`, or `"not_applicable"`. + pub raw_capture: String, + /// `"full"`, `"tokenfold_only"`, `"none"`, or `"not_applicable"`. + pub upstream_recoverability: String, + pub stages: Vec, +} + +/// F-055: one composed stage. Count fields are nullable because an unavailable +/// stage or missing pre-stage capture cannot be measured honestly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PipelineStageReport { + /// `"rtk"` or `"tokenfold"`. + pub id: String, + pub version: Option, + pub input_bytes: Option, + pub output_bytes: Option, + pub saved_bytes: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub saved_tokens: Option, + pub estimator: Option, + /// `"applied"`, `"passthrough"`, `"unavailable"`, `"incompatible"`, or `"failed"`. + pub status: String, + pub duration_ms: Option, + pub bypass_reason: Option, + /// e.g. `"external:rtk@0.4.1"` or `"tokenfold_core"`. + pub provenance: String, + /// `"full"`, `"partial"`, `"none"`, or `"not_applicable"`. + pub recoverability: String, + pub evidence_ref: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BudgetReport { pub target_tokens: Option, From 7f804cee52bbff28d293f2517ccf2aa442a1fec8 Mon Sep 17 00:00:00 2001 From: snchimata <38873813+snchimata@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:42:44 -0400 Subject: [PATCH 2/3] feat(cli): compose external RTK in wrap with preflight, doctor, and CCR capture F-054/F-055: new tokenfold-cli/src/rtk.rs resolves an external, optional rtk executable (TOKENFOLD_RTK_BIN override or PATH), version-checks it before the child runs, and falls open to the tokenfold-only path when missing/incompatible. wrap --rtk routes command output through RTK, skips tokenfold's overlapping command filter (no double-filtering), keeps mandatory redaction/generic-compress/never_worse, and never reruns a started child. --rtk-capture-raw opts into a permission-restricted per-run RTK_TEE_DIR tee, ingests the pre-RTK raw through the redaction/persistence gate (secret-matched bytes never stored), and deletes the transient capture. doctor --json/human gains an additive rtk object. Staged raw->RTK->tokenfold receipt via PipelineReport. 10 integration tests with a fake rtk cover the ENGINEERING v0.3 gate list. --- crates/tokenfold-cli/Cargo.toml | 3 + crates/tokenfold-cli/src/main.rs | 358 ++++++++++++++++++++++++--- crates/tokenfold-cli/src/rtk.rs | 394 ++++++++++++++++++++++++++++++ crates/tokenfold-cli/tests/rtk.rs | 331 +++++++++++++++++++++++++ 4 files changed, 1056 insertions(+), 30 deletions(-) create mode 100644 crates/tokenfold-cli/src/rtk.rs create mode 100644 crates/tokenfold-cli/tests/rtk.rs diff --git a/crates/tokenfold-cli/Cargo.toml b/crates/tokenfold-cli/Cargo.toml index 3af89a3..814b993 100644 --- a/crates/tokenfold-cli/Cargo.toml +++ b/crates/tokenfold-cli/Cargo.toml @@ -19,3 +19,6 @@ clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/tokenfold-cli/src/main.rs b/crates/tokenfold-cli/src/main.rs index a380391..9fd312d 100644 --- a/crates/tokenfold-cli/src/main.rs +++ b/crates/tokenfold-cli/src/main.rs @@ -4,12 +4,13 @@ mod diff; mod format; mod mcp; mod render; +mod rtk; mod stats_cmd; use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; -use tokenfold_core::report::CommandReport; +use tokenfold_core::report::{CommandReport, EstimatorInfo, PipelineReport, PipelineStageReport}; use tokenfold_core::token_estimator::{ByteHeuristicEstimator, TiktokenEstimator, TokenEstimator}; use tokenfold_core::{CompressionInput, CompressionPolicy, InputFormat, TokenFoldError}; @@ -97,6 +98,14 @@ enum Command { /// F-045: namespace stored originals are keyed under (see `tokenfold retrieve`). #[arg(long = "retrieve-namespace")] retrieve_namespace: Option, + /// F-054: route command output through an external RTK before tokenfold's generic + /// pipeline. Falls open to the tokenfold-only path if RTK is missing/incompatible. + #[arg(long = "rtk")] + rtk: bool, + /// F-055: opt into CCR — hand RTK a permission-restricted per-run tee dir, ingest the + /// pre-RTK raw capture through redaction/persistence, then delete it. Requires `--rtk`. + #[arg(long = "rtk-capture-raw", requires = "rtk")] + rtk_capture_raw: bool, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] argv: Vec, }, @@ -282,8 +291,17 @@ fn main() { Command::Wrap { store_originals, retrieve_namespace, + rtk, + rtk_capture_raw, argv, - } => cmd_wrap(&global, argv, store_originals, retrieve_namespace), + } => cmd_wrap( + &global, + argv, + store_originals, + retrieve_namespace, + rtk, + rtk_capture_raw, + ), Command::Benchmark { fixtures, format } => cmd_benchmark(&global, fixtures, format), Command::Init { agent, dry_run } => cmd_init(&global, agent, dry_run), Command::Uninit { agent } => cmd_uninit(&global, agent), @@ -743,11 +761,175 @@ fn apply_mode_change( Ok(()) } +/// Builds a token estimator matching `info`, so per-stage token counts use the same backend as +/// `compress()` (a precondition for reporting `total_saved_tokens`). +fn estimator_for(info: &EstimatorInfo) -> Box { + if info.backend != "tiktoken" { + return Box::new(ByteHeuristicEstimator); + } + match TiktokenEstimator::o200k_base() { + Ok(e) => Box::new(e), + Err(_) => Box::new(ByteHeuristicEstimator), + } +} + +/// `(combined_output, exit_code, duration_ms, stdout_bytes, stderr_bytes)` from a wrapped child. +type ChildRun = (Vec, Option, u64, usize, usize); + +/// Runs a child command directly (the tokenfold-only path). +fn run_child(program: &str, args: &[String]) -> Result { + let start = std::time::Instant::now(); + let child_output = std::process::Command::new(program) + .args(args) + .output() + .map_err(|e| TokenFoldError::InvalidInput(format!("failed to launch `{program}`: {e}")))?; + let duration_ms = start.elapsed().as_millis() as u64; + // ponytail: combines stdout+stderr by concatenation (stdout then stderr), not true + // chronological interleaving. Good enough for compression; upgrade to a real merged pipe + // (or add `--passthrough-stderr` to skip the merge) if interleaving order ever matters. + let mut raw = child_output.stdout.clone(); + raw.extend_from_slice(&child_output.stderr); + Ok(( + raw, + child_output.status.code(), + duration_ms, + child_output.stdout.len(), + child_output.stderr.len(), + )) +} + +/// Inputs to [`build_pipeline_report`]. Grouped into a struct because there are too many for a +/// readable positional signature. +struct BuildPipeline<'a> { + rtk_ran: bool, + rtk_version: Option<&'a str>, + rtk_duration_ms: Option, + rtk_stage_status: &'a str, + rtk_bypass_reason: Option<&'a str>, + rtk_capture_raw: bool, + raw_capture_completeness: Option<&'a str>, + raw_input_bytes: Option, + raw_input_tokens: Option, + raw_stored: bool, + rtk_evidence_ref: Option, + post_rtk_bytes: usize, + final_output_bytes: usize, + store_originals: bool, + compress_ms: f64, + report: &'a tokenfold_core::report::CompressionReport, +} + +/// F-055: assemble the staged `raw -> RTK -> tokenfold` receipt. RTK's savings live only in the +/// RTK stage and in `total_saved_tokens`; they are never folded into the top-level +/// `saved_tokens`, which stays scoped to tokenfold_core. +fn build_pipeline_report(p: BuildPipeline) -> PipelineReport { + let report = p.report; + + // --- RTK stage --- + // Post-RTK bytes/tokens are what enters compress(); tokens == report.original_tokens. + let (rtk_output_bytes, rtk_output_tokens) = if p.rtk_ran { + (Some(p.post_rtk_bytes), Some(report.original_tokens)) + } else { + (None, None) + }; + let rtk_saved_bytes = match (p.raw_input_bytes, rtk_output_bytes) { + (Some(i), Some(o)) => Some(i.saturating_sub(o)), + _ => None, + }; + let rtk_saved_tokens = match (p.raw_input_tokens, rtk_output_tokens) { + (Some(i), Some(o)) => Some(i.saturating_sub(o)), + _ => None, + }; + let rtk_recoverability = if p.raw_stored { + "full" + } else if p.rtk_ran && p.rtk_capture_raw { + "none" + } else { + "not_applicable" + }; + let rtk_stage = PipelineStageReport { + id: "rtk".to_string(), + version: p.rtk_version.map(str::to_string), + input_bytes: p.raw_input_bytes, + output_bytes: rtk_output_bytes, + saved_bytes: rtk_saved_bytes, + input_tokens: p.raw_input_tokens, + output_tokens: rtk_output_tokens, + saved_tokens: rtk_saved_tokens, + estimator: p.raw_input_tokens.map(|_| report.estimator.clone()), + status: p.rtk_stage_status.to_string(), + duration_ms: p.rtk_duration_ms, + bypass_reason: p.rtk_bypass_reason.map(str::to_string), + provenance: match p.rtk_version { + Some(v) => format!("external:rtk@{v}"), + None => "external:rtk".to_string(), + }, + recoverability: rtk_recoverability.to_string(), + evidence_ref: p.rtk_evidence_ref, + }; + + // --- tokenfold stage --- + let tf_status = if report.compressed_tokens < report.original_tokens { + "applied" + } else { + "passthrough" + }; + let tf_stage = PipelineStageReport { + id: "tokenfold".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + input_bytes: Some(p.post_rtk_bytes), + output_bytes: Some(p.final_output_bytes), + saved_bytes: Some(p.post_rtk_bytes.saturating_sub(p.final_output_bytes)), + input_tokens: Some(report.original_tokens), + output_tokens: Some(report.compressed_tokens), + saved_tokens: Some(report.saved_tokens), + estimator: Some(report.estimator.clone()), + status: tf_status.to_string(), + duration_ms: Some(p.compress_ms), + bypass_reason: None, + provenance: "tokenfold_core".to_string(), + recoverability: if p.store_originals { "full" } else { "none" }.to_string(), + evidence_ref: None, + }; + + let raw_capture = if !p.rtk_ran || !p.rtk_capture_raw { + "not_applicable" + } else { + p.raw_capture_completeness.unwrap_or("unavailable") + }; + let upstream_recoverability = if !p.rtk_ran { + "not_applicable" + } else if p.raw_stored { + "full" + } else if p.store_originals { + "tokenfold_only" + } else { + "none" + }; + + PipelineReport { + raw_input_bytes: p.raw_input_bytes, + raw_input_tokens: p.raw_input_tokens, + final_output_bytes: p.final_output_bytes, + final_output_tokens: report.compressed_tokens, + // Same estimator across raw and final (estimator_for mirrors report.estimator). + total_saved_tokens: p + .raw_input_tokens + .map(|r| r.saturating_sub(report.compressed_tokens)), + raw_capture: raw_capture.to_string(), + upstream_recoverability: upstream_recoverability.to_string(), + stages: vec![rtk_stage, tf_stage], + } +} + +#[allow(clippy::too_many_arguments)] fn cmd_wrap( global: &GlobalFlags, argv: Vec, store_originals: bool, retrieve_namespace: Option, + use_rtk: bool, + rtk_capture_raw: bool, ) -> Result { let Some((program, args)) = argv.split_first() else { return Err(TokenFoldError::InvalidInput( @@ -768,21 +950,38 @@ fn cmd_wrap( validate_enable_requires_experimental(&resolved.effective)?; let policy = build_policy(&resolved.effective)?; - let start = std::time::Instant::now(); - let child_output = std::process::Command::new(program) - .args(args) - .output() - .map_err(|e| TokenFoldError::InvalidInput(format!("failed to launch `{program}`: {e}")))?; - let duration_ms = start.elapsed().as_millis() as u64; - let child_exit_code = child_output.status.code(); - - // ponytail: combines stdout+stderr by concatenation (stdout then stderr), not true - // chronological interleaving. Good enough for compression; upgrade to a real merged pipe - // (or add `--passthrough-stderr` to skip the merge) if interleaving order ever matters. - let mut raw = child_output.stdout.clone(); - raw.extend_from_slice(&child_output.stderr); - let stdout_bytes = child_output.stdout.len(); - let stderr_bytes = child_output.stderr.len(); + // --- Stage 1: acquire command output, RTK-composed or direct. --- + // F-054: RTK preflight runs *before* the child. If RTK is missing/incompatible we fail open + // to the tokenfold-only path here, before any side-effectful command has started. Once RTK + // (or the child) has been spawned we never rerun it — its output and exit code are final. + let mut rtk_ran = false; + let mut rtk_version: Option = None; + let mut rtk_duration_ms: Option = None; + let mut rtk_raw_capture: Option = None; + let mut rtk_stage_status = "not_applicable".to_string(); + let mut rtk_bypass_reason: Option = None; + + let (raw, child_exit_code, duration_ms, stdout_bytes, stderr_bytes) = if use_rtk { + match rtk::preflight() { + rtk::Preflight::Ready { path, version } => { + let run = rtk::run(&path, &version, &argv, rtk_capture_raw)?; + rtk_ran = true; + rtk_version = Some(run.version.clone()); + rtk_duration_ms = Some(run.duration_ms); + rtk_raw_capture = run.raw_capture; + rtk_stage_status = run.stage_status.clone(); + let n = run.output.len(); + (run.output, run.exit_code, run.duration_ms as u64, n, 0) + } + rtk::Preflight::FailOpen { status, note } => { + rtk_stage_status = status; + rtk_bypass_reason = Some(note); + run_child(program, args)? + } + } + } else { + run_child(program, args)? + }; // F-047: check for a trusted filter pack matching the invoked argv *before* the generic // compress() pipeline runs. Composition choice (see ROADMAP.md F-047, "runs before or @@ -793,18 +992,25 @@ fn cmd_wrap( // `CompressionReport.original_tokens`/`saved_tokens` reflect the post-filter input to // compress(), not the true pre-filter raw size — `CommandReport.raw_output_bytes` below // still reports the true raw byte count for that visibility. - let filter_match = - tokenfold_core::filters::resolve_matching_filter(&tokenfold_core::filters::FilterLookup { - argv: &argv, - raw_output: &raw, - enabled: resolved.effective.filters_enabled, - project_filters_path: Some(&resolved.effective.filters_project_filters_path), - user_filters_path: Some(&resolved.effective.filters_user_filters_path), - trust_store_path: &resolved.effective.filters_trust_store_path, - trust_project_filters: resolved.effective.filters_trust_project_filters, - }); - - let (pipeline_input, filter_pack_id, filter_version, filter_never_worse_reverted) = + // + // F-054 double-filtering avoidance: when RTK ran, it already owns command-specific + // filtering, so tokenfold's overlapping filter pack is skipped. Mandatory redaction, + // generic compression, safety rollback, and the compress-level never_worse guard still run + // (they live inside compress()). + let (pipeline_input, filter_pack_id, filter_version, filter_never_worse_reverted) = if rtk_ran { + (raw.clone(), None, None, false) + } else { + let filter_match = tokenfold_core::filters::resolve_matching_filter( + &tokenfold_core::filters::FilterLookup { + argv: &argv, + raw_output: &raw, + enabled: resolved.effective.filters_enabled, + project_filters_path: Some(&resolved.effective.filters_project_filters_path), + user_filters_path: Some(&resolved.effective.filters_user_filters_path), + trust_store_path: &resolved.effective.filters_trust_store_path, + trust_project_filters: resolved.effective.filters_trust_project_filters, + }, + ); match &filter_match { Some(matched) => { let filtered = matched.filter.apply(&raw)?; @@ -817,14 +1023,17 @@ fn cmd_wrap( ) } None => (raw.clone(), None, None, false), - }; + } + }; let resolved_format = resolve_format(resolved.effective.format, &pipeline_input, true); let compression_input = CompressionInput { format: resolved_format, bytes: pipeline_input.clone(), }; + let compress_start = std::time::Instant::now(); let mut output = tokenfold_core::compress(compression_input, &policy)?; + let compress_ms = compress_start.elapsed().as_secs_f64() * 1000.0; let compress_never_worse = output.report.compressed_tokens > output.report.original_tokens; if compress_never_worse { @@ -832,6 +1041,76 @@ fn cmd_wrap( } let never_worse_applied = filter_never_worse_reverted || compress_never_worse; + // --- Stage 2 accounting: CCR raw-capture ingestion (F-055). --- + // The transient tee file was already deleted by rtk::run's RAII guard once its bytes were + // read into memory; here we only persist those in-memory bytes through the normal + // redaction/persistence gate. `RetrievalStore::store` refuses secret-matched bytes, so a + // secret capture is never persisted. + let mut raw_input_bytes: Option = None; + let mut raw_input_tokens: Option = None; + let mut rtk_evidence_ref: Option = None; + let mut raw_stored = false; + if let Some(cap) = &rtk_raw_capture { + if cap.completeness == "complete" { + let est = estimator_for(&output.report.estimator); + raw_input_bytes = Some(cap.bytes.len()); + raw_input_tokens = Some(est.count_bytes(&cap.bytes)); + let store = tokenfold_core::retrieval_store::RetrievalStore::open( + &resolved.effective.retrieval_backend, + "sha256", + resolved.effective.retrieval_store_path.clone(), + )?; + match store.store( + &cap.bytes, + &resolved.effective.retrieval_namespace, + resolved.effective.retrieval_ttl_seconds, + ) { + Ok(marker) => { + raw_stored = true; + rtk_evidence_ref = Some(marker.hash); + } + Err(TokenFoldError::SafetyViolation(_)) => { + // Never persisted; the receipt labels the pre-RTK stage unrecoverable. + rtk_bypass_reason = Some( + "pre-RTK raw capture matched a secret pattern and was not persisted" + .to_string(), + ); + } + Err(e) => { + rtk_bypass_reason = Some(format!("failed to persist pre-RTK raw capture: {e}")); + } + } + } else { + rtk_bypass_reason = Some(format!( + "pre-RTK raw capture is {}; upstream detail is unrecoverable", + cap.completeness + )); + } + } + + if use_rtk { + let final_output_bytes = output.bytes.len(); + let pipeline = build_pipeline_report(BuildPipeline { + rtk_ran, + rtk_version: rtk_version.as_deref(), + rtk_duration_ms, + rtk_stage_status: &rtk_stage_status, + rtk_bypass_reason: rtk_bypass_reason.as_deref(), + rtk_capture_raw, + raw_capture_completeness: rtk_raw_capture.as_ref().map(|c| c.completeness.as_str()), + raw_input_bytes, + raw_input_tokens, + raw_stored, + rtk_evidence_ref, + post_rtk_bytes: raw.len(), + final_output_bytes, + store_originals, + compress_ms, + report: &output.report, + }); + output.report.pipeline = Some(pipeline); + } + output.report.command = Some(CommandReport { command_family: None, child_exit_code, @@ -970,6 +1249,9 @@ fn cmd_doctor(global: &GlobalFlags, agent: Option) -> Result (None, Some(e.to_string())), }; + // F-054: additive RTK health. RTK is optional, so `missing` is a warning, never a failure. + let rtk = rtk::doctor_probe(); + if global.json { println!( "{}", @@ -978,6 +1260,14 @@ fn cmd_doctor(global: &GlobalFlags, agent: Option) -> Result) -> Result println!(" rtk: {} ({})", rtk.status, v), + None => println!(" rtk: {} (optional)", rtk.status), + } + println!(" rtk raw-capture: {}", rtk.raw_capture); + for note in &rtk.notes { + println!(" - {note}"); + } } Ok(if config_error.is_some() { 5 } else { 0 }) } diff --git a/crates/tokenfold-cli/src/rtk.rs b/crates/tokenfold-cli/src/rtk.rs new file mode 100644 index 0000000..42db104 --- /dev/null +++ b/crates/tokenfold-cli/src/rtk.rs @@ -0,0 +1,394 @@ +//! F-054 / F-055: external RTK composition, preflight, and secure raw capture. +//! +//! RTK is an *external, optional* executable. tokenfold never bundles it, never edits the +//! user's RTK configuration, and always falls open to the tokenfold-only path when RTK is +//! missing or incompatible. See INTERFACES.md §1.9 and ENGINEERING.md "v0.3 RTK Composition". +//! +//! The invocation contract is `rtk `: RTK runs the child, performs its own +//! command-specific filtering, and writes the filtered result to stdout. tokenfold then treats +//! that as the input to its generic compression pipeline. Once RTK has been spawned, its output +//! and exit status are authoritative — tokenfold never reruns the child (it may have side +//! effects). + +use std::path::{Path, PathBuf}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use tokenfold_core::TokenFoldError; + +/// Env var overriding the RTK executable path (skips PATH resolution). Also the seam tests use +/// to point at a fake `rtk`. +const RTK_BIN_ENV: &str = "TOKENFOLD_RTK_BIN"; +/// Env var that disables RTK integration entirely (doctor reports `status="disabled"`). +const RTK_DISABLED_ENV: &str = "TOKENFOLD_RTK_DISABLED"; +/// Env var tokenfold sets to hand RTK a fresh per-run capture directory for CCR. +const RTK_TEE_DIR_ENV: &str = "RTK_TEE_DIR"; +/// Marker file an RTK integrator may drop in the tee dir to signal a truncated capture. +const TRUNCATED_MARKER: &str = "truncated"; + +/// Doctor-facing RTK health, serialized additively under `doctor --json`'s `rtk` object. +pub struct RtkDoctor { + pub status: String, // "disabled" | "available" | "missing" | "incompatible" + pub path: Option, + pub version: Option, + pub compatible: bool, + pub raw_capture: String, // "complete" | "partial" | "unavailable" | "not_applicable" + pub notes: Vec, +} + +/// Result of resolving + version-checking RTK before the child runs. +pub enum Preflight { + /// RTK is present and compatible; safe to compose. + Ready { path: PathBuf, version: String }, + /// Fall open to the tokenfold-only path. `status` is `"unavailable"` or `"incompatible"`. + FailOpen { status: String, note: String }, +} + +/// Completeness of a CCR raw capture. +pub struct RawCapture { + pub bytes: Vec, + pub completeness: String, // "complete" | "partial" | "unavailable" +} + +/// Outcome of running RTK as the filtering stage. +pub struct RtkRun { + /// Filtered output (RTK stdout, with stderr appended — same convention as bare `wrap`). + pub output: Vec, + pub exit_code: Option, + pub duration_ms: f64, + pub version: String, + /// Present only when CCR raw capture was requested. + pub raw_capture: Option, + /// `"applied"` when RTK produced output, `"failed"` when it could not be launched at all. + pub stage_status: String, +} + +fn nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) +} + +/// Extracts the first `MAJOR.MINOR[.PATCH]` token from `--version` output (tolerates a leading +/// `v` and vendor prefixes like `rtk 0.4.1`). +fn parse_version(text: &str) -> Option { + for raw in text.split(|c: char| c.is_whitespace()) { + let tok = raw.trim().trim_start_matches('v'); + let mut parts = tok.split('.'); + let (Some(a), Some(b)) = (parts.next(), parts.next()) else { + continue; + }; + if !a.is_empty() + && a.bytes().all(|c| c.is_ascii_digit()) + && !b.is_empty() + && b.bytes().all(|c| c.is_ascii_digit()) + { + // Keep major.minor.patch if present; otherwise major.minor. + let patch = parts + .next() + .filter(|p| p.bytes().all(|c| c.is_ascii_digit())); + return Some(match patch { + Some(p) if !p.is_empty() => format!("{a}.{b}.{p}"), + _ => format!("{a}.{b}"), + }); + } + } + None +} + +/// ponytail: any parseable version is accepted in v0.3 — RTK's stable wire contract isn't +/// pinned yet, so an unparseable/absent version is the only "incompatible" signal. Raise this +/// floor once RTK ships a compatibility guarantee. +fn is_compatible(_version: &str) -> bool { + true +} + +/// Locates the RTK executable: `TOKENFOLD_RTK_BIN` override first, then a PATH scan for +/// `rtk` (plus `PATHEXT` variants on Windows). +fn resolve_rtk() -> Option { + if let Some(explicit) = std::env::var_os(RTK_BIN_ENV) { + let p = PathBuf::from(explicit); + return p.is_file().then_some(p); + } + let path = std::env::var_os("PATH")?; + let exts: Vec = if cfg!(windows) { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string()) + .split(';') + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .collect() + } else { + vec![String::new()] + }; + for dir in std::env::split_paths(&path) { + for ext in &exts { + let candidate = dir.join(format!("rtk{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +/// Runs ` --version` and returns the parsed version, or `None` if it can't be run or +/// produces no parseable version. +fn query_version(path: &Path) -> Option { + let out = std::process::Command::new(path) + .arg("--version") + .output() + .ok()?; + // Some tools print the version to stderr; check both, stdout first. + let stdout = String::from_utf8_lossy(&out.stdout); + parse_version(&stdout).or_else(|| parse_version(&String::from_utf8_lossy(&out.stderr))) +} + +/// Preflight resolution + version/compat check. Runs **before** the child so a fail-open never +/// leaves a side-effectful command half-run. +pub fn preflight() -> Preflight { + if is_disabled() { + return Preflight::FailOpen { + status: "unavailable".to_string(), + note: format!("RTK disabled via {RTK_DISABLED_ENV}"), + }; + } + let Some(path) = resolve_rtk() else { + return Preflight::FailOpen { + status: "unavailable".to_string(), + note: "rtk executable not found on PATH".to_string(), + }; + }; + match query_version(&path) { + Some(version) if is_compatible(&version) => Preflight::Ready { path, version }, + Some(version) => Preflight::FailOpen { + status: "incompatible".to_string(), + note: format!("rtk {version} is not a supported version"), + }, + None => Preflight::FailOpen { + status: "incompatible".to_string(), + note: "could not determine rtk version".to_string(), + }, + } +} + +fn is_disabled() -> bool { + std::env::var_os(RTK_DISABLED_ENV).is_some_and(|v| v != "0" && !v.is_empty()) +} + +/// Creates a fresh, per-run, owner-only directory for RTK's raw tee. Deleted after ingestion by +/// [`CaptureDir`]'s `Drop`. +fn make_capture_dir() -> Result { + let dir = + std::env::temp_dir().join(format!("tokenfold-rtk-{}-{}", std::process::id(), nanos())); + std::fs::create_dir(&dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; + } + // ponytail: Windows relies on the per-user temp dir's default ACL rather than an explicit + // 0700; tighten with a SetNamedSecurityInfo call only if a shared-temp threat model demands. + Ok(dir) +} + +/// RAII guard that removes the transient capture directory no matter how the run exits — the +/// transient tee must never outlive the run (INTERFACES.md §1.9). +struct CaptureDir(PathBuf); + +impl Drop for CaptureDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Reads the raw capture RTK wrote to the tee dir. Completeness is conservative: a non-empty +/// capture is `"complete"` unless RTK left a `truncated` marker; an empty/absent capture is +/// `"unavailable"`. ponytail: filesystem tee can't self-report mid-file truncation, so +/// completeness leans on the marker convention; a real RTK fd/side-channel supersedes this. +fn read_capture(dir: &Path, filtered_len: usize) -> RawCapture { + let truncated_marker = dir.join(TRUNCATED_MARKER).exists(); + let mut bytes = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + // Deterministic concatenation order; skip the marker file itself. + let mut files: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && p.file_name().is_some_and(|n| n != TRUNCATED_MARKER)) + .collect(); + files.sort(); + for f in files { + if let Ok(mut content) = std::fs::read(&f) { + bytes.append(&mut content); + } + } + } + let completeness = if bytes.is_empty() { + "unavailable" + } else if truncated_marker || bytes.len() < filtered_len { + // Filtering reduces size, so a raw capture smaller than the filtered output is a strong + // truncation signal. + "partial" + } else { + "complete" + }; + RawCapture { + bytes, + completeness: completeness.to_string(), + } +} + +/// Runs RTK over the child argv. `capture_raw` opts into the CCR tee handshake. +/// +/// The child argv is passed to RTK verbatim as separate process arguments (no shell), so +/// argument boundaries and special characters are preserved exactly. +pub fn run( + path: &Path, + version: &str, + child_argv: &[String], + capture_raw: bool, +) -> Result { + let mut cmd = std::process::Command::new(path); + cmd.args(child_argv); + + // Own the capture dir for the whole run; it is deleted when `_capture_dir` drops. + let capture_dir = if capture_raw { + let dir = make_capture_dir()?; + cmd.env(RTK_TEE_DIR_ENV, &dir); + Some(dir) + } else { + None + }; + let _capture_dir = capture_dir.as_ref().map(|d| CaptureDir(d.clone())); + + let start = Instant::now(); + let output = cmd.output().map_err(|e| { + TokenFoldError::InvalidInput(format!("failed to launch rtk `{}`: {e}", path.display())) + })?; + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + + // Same stdout-then-stderr concatenation convention as bare `wrap`. + let mut filtered = output.stdout; + filtered.extend_from_slice(&output.stderr); + + let raw_capture = capture_dir + .as_ref() + .map(|dir| read_capture(dir, filtered.len())); + + Ok(RtkRun { + output: filtered, + exit_code: output.status.code(), + duration_ms, + version: version.to_string(), + raw_capture, + stage_status: "applied".to_string(), + }) +} + +/// Best-effort doctor probe. Never edits RTK configuration; when RTK is present it notes that a +/// complete CCR capture requires the user to enable RTK's `[tee]` section. +pub fn doctor_probe() -> RtkDoctor { + if is_disabled() { + return RtkDoctor { + status: "disabled".to_string(), + path: None, + version: None, + compatible: false, + raw_capture: "not_applicable".to_string(), + notes: vec![format!("RTK integration disabled via {RTK_DISABLED_ENV}")], + }; + } + let Some(path) = resolve_rtk() else { + return RtkDoctor { + status: "missing".to_string(), + path: None, + version: None, + compatible: false, + raw_capture: "not_applicable".to_string(), + notes: vec![ + "rtk not found on PATH; wrap --rtk falls open to the tokenfold-only path" + .to_string(), + ], + }; + }; + let path_str = path.display().to_string(); + match query_version(&path) { + Some(version) if is_compatible(&version) => RtkDoctor { + status: "available".to_string(), + path: Some(path_str), + version: Some(version), + compatible: true, + // Tee is off by default; we can't read RTK's config, so raw capture is not ready + // until the user opts in. Report honestly rather than claim readiness. + raw_capture: "unavailable".to_string(), + notes: vec![ + "enable RTK [tee] (enabled=true, mode=\"always\") to allow CCR raw capture; \ + tokenfold supplies a per-run RTK_TEE_DIR" + .to_string(), + ], + }, + Some(version) => RtkDoctor { + status: "incompatible".to_string(), + path: Some(path_str), + version: Some(version), + compatible: false, + raw_capture: "not_applicable".to_string(), + notes: vec!["rtk version is not supported".to_string()], + }, + None => RtkDoctor { + status: "incompatible".to_string(), + path: Some(path_str), + version: None, + compatible: false, + raw_capture: "not_applicable".to_string(), + notes: vec!["`rtk --version` produced no parseable version".to_string()], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_common_version_shapes() { + assert_eq!(parse_version("rtk 0.4.1").as_deref(), Some("0.4.1")); + assert_eq!(parse_version("v1.2").as_deref(), Some("1.2")); + assert_eq!( + parse_version("rtk version 2.10.0\n").as_deref(), + Some("2.10.0") + ); + assert_eq!(parse_version("no version here"), None); + assert_eq!(parse_version(""), None); + } + + #[test] + fn capture_completeness_classification() { + let dir = make_capture_dir().unwrap(); + let _g = CaptureDir(dir.clone()); + + // Absent capture -> unavailable. + assert_eq!(read_capture(&dir, 10).completeness, "unavailable"); + + // Non-empty, at least as large as filtered -> complete. + std::fs::write(dir.join("raw.log"), b"the full raw output").unwrap(); + assert_eq!(read_capture(&dir, 5).completeness, "complete"); + + // Smaller than filtered -> partial (truncation signal). + assert_eq!(read_capture(&dir, 9999).completeness, "partial"); + + // Explicit truncation marker -> partial. + std::fs::write(dir.join(TRUNCATED_MARKER), b"").unwrap(); + assert_eq!(read_capture(&dir, 1).completeness, "partial"); + } + + #[test] + fn capture_dir_is_removed_on_drop() { + let dir = make_capture_dir().unwrap(); + assert!(dir.exists()); + { + let _g = CaptureDir(dir.clone()); + } + assert!(!dir.exists()); + } +} diff --git a/crates/tokenfold-cli/tests/rtk.rs b/crates/tokenfold-cli/tests/rtk.rs new file mode 100644 index 0000000..2f094fd --- /dev/null +++ b/crates/tokenfold-cli/tests/rtk.rs @@ -0,0 +1,331 @@ +//! F-054 / F-055 integration tests for `wrap --rtk` composition. +//! +//! RTK is faked with a tiny script the test writes and points `TOKENFOLD_RTK_BIN` at, driven by +//! env vars (`FAKE_RTK_VERSION`, `FAKE_RTK_STDOUT`, `FAKE_RTK_EXIT`, `FAKE_RTK_TEE`) that the +//! test sets on the tokenfold child and tokenfold passes through to RTK. Covers the ENGINEERING +//! v0.3 RTK gate list: missing/incompatible preflight fail-open, staged receipts, double-filter +//! avoidance, nonzero-child propagation, and CCR complete/secret/absent capture. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +use serde_json::Value; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_tokenfold") +} + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +fn unique_dir(tag: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("tf_rtk_{tag}_{}_{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +const FAKE_RTK_SH: &str = r#"#!/bin/sh +if [ "$1" = "--version" ]; then + echo "${FAKE_RTK_VERSION:-rtk 0.4.1}" + exit 0 +fi +if [ -n "$RTK_TEE_DIR" ] && [ -n "$FAKE_RTK_TEE" ]; then + printf '%s' "$FAKE_RTK_TEE" > "$RTK_TEE_DIR/raw.log" +fi +printf '%s' "${FAKE_RTK_STDOUT:-FILTERED}" +exit "${FAKE_RTK_EXIT:-0}" +"#; + +const FAKE_RTK_CMD: &str = "@echo off\r\n\ +if \"%~1\"==\"--version\" (\r\n\ + if defined FAKE_RTK_VERSION (echo %FAKE_RTK_VERSION%) else (echo rtk 0.4.1)\r\n\ + exit /b 0\r\n\ +)\r\n\ +if defined RTK_TEE_DIR if defined FAKE_RTK_TEE >\"%RTK_TEE_DIR%\\raw.log\" echo %FAKE_RTK_TEE%\r\n\ +if defined FAKE_RTK_STDOUT (echo %FAKE_RTK_STDOUT%) else (echo FILTERED)\r\n\ +if defined FAKE_RTK_EXIT exit /b %FAKE_RTK_EXIT%\r\n\ +exit /b 0\r\n"; + +/// Writes a platform-appropriate fake `rtk` into `dir` and returns its path. +fn fake_rtk(dir: &Path) -> PathBuf { + if cfg!(windows) { + let p = dir.join("rtk.cmd"); + std::fs::write(&p, FAKE_RTK_CMD).unwrap(); + p + } else { + let p = dir.join("rtk"); + std::fs::write(&p, FAKE_RTK_SH).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + p + } +} + +/// A tokenfold command with an isolated data home, so the ledger/retrieval store never touch the +/// developer's real home directory. +fn tf(dir: &Path) -> Command { + let mut c = Command::new(bin()); + c.env("XDG_DATA_HOME", dir.join("xdg")) + .env("HOME", dir.join("home")); + c +} + +fn report_from_stderr(stderr: &[u8]) -> Value { + serde_json::from_slice(stderr) + .unwrap_or_else(|e| panic!("stderr not JSON ({e}): {}", String::from_utf8_lossy(stderr))) +} + +/// Recursively collects `*.bin` files under `root` (the retrieval store layout). +fn stored_bins(root: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + out.extend(stored_bins(&p)); + } else if p.extension().is_some_and(|x| x == "bin") { + out.push(p); + } + } + } + out +} + +#[test] +fn rtk_missing_falls_open_to_tokenfold_only() { + let dir = unique_dir("missing"); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", dir.join("does-not-exist")) + .args(["wrap", "--rtk", "--json", "--", "git", "--version"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report = report_from_stderr(&out.stderr); + let stages = report["pipeline"]["stages"].as_array().unwrap(); + assert_eq!(stages[0]["id"], "rtk"); + assert_eq!(stages[0]["status"], "unavailable"); + assert_eq!(stages[1]["id"], "tokenfold"); + // The child still ran (tokenfold-only path), so a payload was produced. + assert!(!out.stdout.is_empty()); +} + +#[test] +fn rtk_incompatible_version_falls_open() { + let dir = unique_dir("incompat"); + let rtk = fake_rtk(&dir); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .env("FAKE_RTK_VERSION", "garbage-no-version") + .args(["wrap", "--rtk", "--json", "--", "git", "--version"]) + .output() + .unwrap(); + assert!(out.status.success()); + let report = report_from_stderr(&out.stderr); + assert_eq!(report["pipeline"]["stages"][0]["status"], "incompatible"); +} + +#[test] +fn rtk_runs_and_composes_staged_receipt() { + let dir = unique_dir("compose"); + let rtk = fake_rtk(&dir); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .args(["wrap", "--rtk", "--json", "--", "git", "--version"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report = report_from_stderr(&out.stderr); + let stages = report["pipeline"]["stages"].as_array().unwrap(); + assert_eq!(stages[0]["id"], "rtk"); + assert_eq!(stages[0]["status"], "applied"); + assert_eq!(stages[0]["provenance"], "external:rtk@0.4.1"); + assert_eq!(stages[1]["id"], "tokenfold"); + // tokenfold compressed RTK's filtered output, never git's real output. + let payload = String::from_utf8_lossy(&out.stdout); + assert!(payload.contains("FILTERED"), "payload: {payload}"); + assert!( + !payload.contains("git version"), + "tokenfold must see RTK output, not raw git: {payload}" + ); +} + +#[test] +fn rtk_run_skips_overlapping_tokenfold_filter() { + let dir = unique_dir("nofilter"); + let rtk = fake_rtk(&dir); + // `git diff` matches a built-in tokenfold filter pack; --rtk must skip it (no double filter). + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .args(["wrap", "--rtk", "--json", "--", "git", "diff"]) + .output() + .unwrap(); + let report = report_from_stderr(&out.stderr); + assert!( + report["command"]["filter_pack_id"].is_null(), + "tokenfold filter must be skipped when RTK ran: {}", + report["command"] + ); +} + +#[test] +fn rtk_nonzero_child_exit_is_propagated_and_output_preserved() { + let dir = unique_dir("exit"); + let rtk = fake_rtk(&dir); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .env("FAKE_RTK_EXIT", "3") + .args(["wrap", "--rtk", "--", "git", "--version"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(3)); + assert!(!out.stdout.is_empty()); +} + +#[test] +fn ccr_complete_capture_persists_and_reports_full() { + let dir = unique_dir("ccr_ok"); + let rtk = fake_rtk(&dir); + let store = dir.join("store"); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .env( + "FAKE_RTK_TEE", + "the complete pre-RTK raw output, longer than the filtered stdout", + ) + .env("TOKENFOLD_RETRIEVAL_STORE_PATH", &store) + .args([ + "wrap", + "--rtk", + "--rtk-capture-raw", + "--json", + "--", + "git", + "--version", + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report = report_from_stderr(&out.stderr); + let pipeline = &report["pipeline"]; + assert_eq!(pipeline["raw_capture"], "complete"); + assert_eq!(pipeline["upstream_recoverability"], "full"); + assert_eq!(pipeline["stages"][0]["recoverability"], "full"); + assert!(!pipeline["stages"][0]["evidence_ref"].is_null()); + assert!(pipeline["raw_input_bytes"].as_u64().unwrap() > 0); + assert!( + !stored_bins(&store).is_empty(), + "expected a stored raw capture under {store:?}" + ); +} + +#[test] +fn ccr_secret_capture_is_never_persisted() { + let dir = unique_dir("ccr_secret"); + let rtk = fake_rtk(&dir); + let store = dir.join("store"); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + // AWS-access-key-shaped secret, longer than the filtered stdout so capture is "complete". + .env( + "FAKE_RTK_TEE", + "AKIAIOSFODNN7EXAMPLE plus more raw output text", + ) + .env("TOKENFOLD_RETRIEVAL_STORE_PATH", &store) + .args([ + "wrap", + "--rtk", + "--rtk-capture-raw", + "--json", + "--", + "git", + "--version", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let report = report_from_stderr(&out.stderr); + let pipeline = &report["pipeline"]; + // Capture was complete, but persistence was refused -> unrecoverable. + assert_eq!(pipeline["upstream_recoverability"], "none"); + assert_eq!(pipeline["stages"][0]["recoverability"], "none"); + let reason = pipeline["stages"][0]["bypass_reason"] + .as_str() + .unwrap_or_default(); + assert!(reason.contains("secret"), "bypass_reason: {reason}"); + assert!( + stored_bins(&store).is_empty(), + "secret-matched bytes must never be persisted" + ); +} + +#[test] +fn ccr_absent_capture_reports_unrecoverable() { + let dir = unique_dir("ccr_absent"); + let rtk = fake_rtk(&dir); // FAKE_RTK_TEE unset -> RTK writes no tee file. + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .args([ + "wrap", + "--rtk", + "--rtk-capture-raw", + "--json", + "--", + "git", + "--version", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let pipeline = report_from_stderr(&out.stderr)["pipeline"].clone(); + assert_eq!(pipeline["raw_capture"], "unavailable"); + assert_eq!(pipeline["upstream_recoverability"], "none"); + assert!(pipeline["raw_input_bytes"].is_null()); +} + +#[test] +fn doctor_reports_rtk_missing_without_failing() { + let dir = unique_dir("doc_missing"); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", dir.join("nope")) + .args(["doctor", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + let report: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["rtk"]["status"], "missing"); + assert_eq!(report["rtk"]["compatible"], false); +} + +#[test] +fn doctor_reports_rtk_available_with_capture_guidance() { + let dir = unique_dir("doc_avail"); + let rtk = fake_rtk(&dir); + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", &rtk) + .args(["doctor", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + let report: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report["rtk"]["status"], "available"); + assert_eq!(report["rtk"]["compatible"], true); + assert_eq!(report["rtk"]["version"], "0.4.1"); + // Tee is off by default; capture is not ready until the user enables RTK's [tee] section. + assert_eq!(report["rtk"]["raw_capture"], "unavailable"); +} From bb7137a83f4058f25476ccef61256fa3f120f0c2 Mon Sep 17 00:00:00 2001 From: snchimata <38873813+snchimata@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:06:41 -0400 Subject: [PATCH 3/3] fix(cli): tokenfold pipeline stage must measure post-filter compress input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found a receipt-honesty bug: on the wrap --rtk fail-open path (rtk missing/incompatible), the command filter pack still runs, so pipeline_input is smaller than raw. build_pipeline_report was passed raw.len() as the tokenfold stage's input, making its byte savings over-claim the filter's reduction while its token savings (from report.original_tokens, counted post-filter) did not — internally inconsistent. Rename post_rtk_bytes -> compress_input_bytes and pass pipeline_input.len() (equal to raw.len() on the RTK-ran path). CommandReport.raw_output_bytes still reports the true raw byte count. Adds a regression test driving fail-open + a trusted git-version filter that removes a deterministic 5 bytes; verified it fails without the fix. --- crates/tokenfold-cli/src/main.rs | 20 ++++++--- crates/tokenfold-cli/tests/rtk.rs | 73 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/tokenfold-cli/src/main.rs b/crates/tokenfold-cli/src/main.rs index 9fd312d..33a9562 100644 --- a/crates/tokenfold-cli/src/main.rs +++ b/crates/tokenfold-cli/src/main.rs @@ -812,7 +812,11 @@ struct BuildPipeline<'a> { raw_input_tokens: Option, raw_stored: bool, rtk_evidence_ref: Option, - post_rtk_bytes: usize, + /// Bytes actually entering `compress()` (post-RTK on the RTK path, post-filter on the + /// fail-open path). Must match what `report.original_tokens` was counted over, so the + /// tokenfold stage's byte and token savings stay consistent and RTK/filter reductions are + /// never credited to tokenfold_core. + compress_input_bytes: usize, final_output_bytes: usize, store_originals: bool, compress_ms: f64, @@ -826,9 +830,10 @@ fn build_pipeline_report(p: BuildPipeline) -> PipelineReport { let report = p.report; // --- RTK stage --- - // Post-RTK bytes/tokens are what enters compress(); tokens == report.original_tokens. + // When RTK ran there is no tokenfold filter between it and compress(), so RTK's output *is* + // the compress input; tokens == report.original_tokens. let (rtk_output_bytes, rtk_output_tokens) = if p.rtk_ran { - (Some(p.post_rtk_bytes), Some(report.original_tokens)) + (Some(p.compress_input_bytes), Some(report.original_tokens)) } else { (None, None) }; @@ -877,9 +882,9 @@ fn build_pipeline_report(p: BuildPipeline) -> PipelineReport { let tf_stage = PipelineStageReport { id: "tokenfold".to_string(), version: Some(env!("CARGO_PKG_VERSION").to_string()), - input_bytes: Some(p.post_rtk_bytes), + input_bytes: Some(p.compress_input_bytes), output_bytes: Some(p.final_output_bytes), - saved_bytes: Some(p.post_rtk_bytes.saturating_sub(p.final_output_bytes)), + saved_bytes: Some(p.compress_input_bytes.saturating_sub(p.final_output_bytes)), input_tokens: Some(report.original_tokens), output_tokens: Some(report.compressed_tokens), saved_tokens: Some(report.saved_tokens), @@ -1102,7 +1107,10 @@ fn cmd_wrap( raw_input_tokens, raw_stored, rtk_evidence_ref, - post_rtk_bytes: raw.len(), + // What compress() actually sees: post-RTK bytes on the RTK path, post-filter bytes on + // the fail-open path (pipeline_input == raw.clone() when rtk ran). The true pre-filter + // raw byte count stays visible via CommandReport.raw_output_bytes. + compress_input_bytes: pipeline_input.len(), final_output_bytes, store_originals, compress_ms, diff --git a/crates/tokenfold-cli/tests/rtk.rs b/crates/tokenfold-cli/tests/rtk.rs index 2f094fd..9342ac9 100644 --- a/crates/tokenfold-cli/tests/rtk.rs +++ b/crates/tokenfold-cli/tests/rtk.rs @@ -329,3 +329,76 @@ fn doctor_reports_rtk_available_with_capture_guidance() { // Tee is off by default; capture is not ready until the user enables RTK's [tee] section. assert_eq!(report["rtk"]["raw_capture"], "unavailable"); } + +/// Regression: on the `--rtk` fail-open path the command-specific filter pack still runs, so the +/// tokenfold pipeline stage must measure bytes over the *post-filter* compress input (not the raw +/// command output). Otherwise the filter's byte reduction is wrongly credited to tokenfold_core +/// and the stage's saved_bytes disagrees with its saved_tokens. +#[test] +fn failopen_filter_reduction_is_not_credited_to_tokenfold_stage() { + let dir = unique_dir("failopen_filter"); + let project = dir.join("proj.toml"); + let trust = dir.join("trust.json"); + let config = dir.join("cfg.toml"); + // A trusted project filter matching `git --version` that replaces "git version" (11 bytes) + // with "GITVER" (6 bytes): a deterministic 5-byte reduction regardless of the version number. + std::fs::write( + &project, + "schema_version = \"1.0\"\n\n[pack]\nid = \"failopen-pack\"\nversion = \"1.0.0\"\n\n\ + [[filters]]\nid = \"gv\"\nversion = \"1.0.0\"\nmatch_command = [\"git\", \"--version\"]\n\n\ + [[filters.stages]]\ntype = \"replace\"\npattern = \"git version\"\nreplacement = \"GITVER\"\n", + ) + .unwrap(); + std::fs::write( + &config, + format!( + "[filters]\nproject_filters = {:?}\ntrust_store = {:?}\n", + project.to_string_lossy().replace('\\', "/"), + trust.to_string_lossy().replace('\\', "/"), + ), + ) + .unwrap(); + + let out = tf(&dir) + .args(["filters", "trust", "--config"]) + .arg(&config) + .arg(&project) + .output() + .unwrap(); + assert!( + out.status.success(), + "trust failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // rtk missing -> fail open; the git-version filter still runs on the raw command output. + let out = tf(&dir) + .env("TOKENFOLD_RTK_BIN", dir.join("no-such-rtk")) + .args(["--json", "wrap", "--rtk", "--config"]) + .arg(&config) + .args(["--", "git", "--version"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report = report_from_stderr(&out.stderr); + // Fail-open still filtered (double-filter avoidance only applies when RTK actually ran). + assert_eq!(report["command"]["filter_pack_id"], "failopen-pack"); + let raw_bytes = report["command"]["raw_output_bytes"].as_u64().unwrap(); + let tf_stage = &report["pipeline"]["stages"][1]; + assert_eq!(tf_stage["id"], "tokenfold"); + let tf_in = tf_stage["input_bytes"].as_u64().unwrap(); + let tf_out = tf_stage["output_bytes"].as_u64().unwrap(); + // The tokenfold stage saw the post-filter input (5 bytes smaller than raw), not the raw output. + assert_eq!( + tf_in, + raw_bytes - 5, + "tokenfold stage must measure the post-filter compress input, not raw command output" + ); + // Its byte savings therefore reflect only tokenfold's own work (input - output), never the + // filter's reduction. + assert_eq!(tf_stage["saved_bytes"].as_u64().unwrap(), tf_in - tf_out); +}