use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::{catalog, paths, record, span};
pub fn export_recording(id: &str, out: &Path, include_raw: bool, redact: bool) -> Result<()> {
std::fs::create_dir_all(out)
.with_context(|| format!("could not create export directory {}", out.display()))?;
let rec_path = paths::recording_file(id)?;
let recording_json = std::fs::read_to_string(&rec_path)
.with_context(|| format!("recording {id} not found. Did you run `galdr rec stop`?"))?;
let recording: record::Recording = serde_json::from_str(&recording_json)?;
write_json(&out.join("recording.json"), &recording, redact)?;
let conn = catalog::open_in_memory_indexed()?;
if let Some(detail) = catalog::show_recording(&conn, id)? {
let mut steps = String::new();
steps.push_str("# galdr export\n\n");
steps.push_str(&format!(
"- rec_id: `{}`\n- name: `{}`\n- steps: {}\n\n",
recording.rec_id,
maybe_redact_text(&recording.name, redact),
detail.steps.len()
));
steps.push_str("## Steps\n\n");
for step in detail.steps {
steps.push_str(&format!(
"{}. **{}** — {}\n",
step.seq + 1,
step.tool_name,
maybe_redact_text(&step.summary, redact)
));
}
std::fs::write(out.join("steps.md"), steps)?;
}
let skills: Vec<_> = catalog::list_skills(&conn)?
.into_iter()
.filter(|skill| skill.rec_id.as_deref() == Some(id))
.collect();
write_json(&out.join("skills.json"), &skills, redact)?;
let usages: Vec<_> = catalog::list_skill_usage(&conn, None)?
.into_iter()
.filter(|usage| usage.rec_id == id)
.collect();
write_json(&out.join("usage.json"), &usages, redact)?;
let outcomes: Vec<_> = catalog::list_skill_outcomes(&conn, None)?
.into_iter()
.filter(|outcome| outcome.rec_id.as_deref() == Some(id))
.collect();
write_json(&out.join("outcomes.json"), &outcomes, redact)?;
if include_raw || redact {
if include_raw && !redact {
eprintln!("warning: exporting raw tool payloads; treat the output as sensitive");
}
let span_path = paths::span_file(id)?;
let events = span::read_span(&span_path)?;
let file_name = if redact {
"raw.redacted.jsonl"
} else {
"raw.jsonl"
};
let mut file = std::fs::File::create(out.join(file_name))?;
for mut event in events {
if redact {
redact_value(&mut event.tool_input);
redact_value(&mut event.tool_response);
if let Some(human) = &mut event.human {
redact_human_event(human);
}
}
writeln!(file, "{}", serde_json::to_string(&event)?)?;
}
}
println!("export written to {}", out.display());
Ok(())
}
fn write_json<T: serde::Serialize>(path: &Path, value: &T, redact: bool) -> Result<()> {
let mut json = serde_json::to_value(value)?;
if redact {
redact_value(&mut json);
}
std::fs::write(path, serde_json::to_string_pretty(&json)?)?;
Ok(())
}
fn maybe_redact_text(text: &str, redact: bool) -> String {
if redact {
redact_secrets_in_text(text).unwrap_or_else(|| text.to_string())
} else {
text.to_string()
}
}
fn redact_value(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (key, value) in map {
if is_sensitive_key(key) {
*value = serde_json::Value::String("[REDACTED]".to_string());
} else {
redact_value(value);
}
}
}
serde_json::Value::Array(values) => {
for value in values {
redact_value(value);
}
}
serde_json::Value::String(text) => {
if let Some(clean) = redact_secrets_in_text(text) {
*text = clean;
}
}
_ => {}
}
}
fn redact_human_event(human: &mut span::HumanEvent) {
redact_human_source(&mut human.source);
if let Some(target) = &mut human.target {
redact_human_target(target);
}
if let Some(value) = &mut human.value {
redact_human_value(value);
}
if let Some(hint) = &mut human.verification_hint {
redact_string(hint);
}
if let Some(frame_ref) = &mut human.frame_ref {
redact_string(frame_ref);
}
}
fn redact_human_source(source: &mut span::HumanSource) {
match source {
span::HumanSource::Browser { url, title, tab_id } => {
redact_optional_string(url);
redact_optional_string(title);
redact_optional_string(tab_id);
}
span::HumanSource::MacApp { app, window_title } => {
redact_optional_string(app);
redact_optional_string(window_title);
}
}
}
fn redact_human_target(target: &mut span::HumanTarget) {
redact_locator(&mut target.primary);
for locator in &mut target.alternates {
redact_locator(locator);
}
redact_optional_string(&mut target.role);
redact_optional_string(&mut target.name);
redact_optional_string(&mut target.text);
redact_optional_string(&mut target.label);
redact_optional_string(&mut target.placeholder);
redact_optional_string(&mut target.element_summary);
}
fn redact_locator(locator: &mut span::TargetLocator) {
match locator {
span::TargetLocator::Role { role, name } => {
redact_string(role);
redact_optional_string(name);
}
span::TargetLocator::Label { value }
| span::TargetLocator::Placeholder { value }
| span::TargetLocator::TestId { value }
| span::TargetLocator::Css { value }
| span::TargetLocator::XPath { value } => redact_string(value),
}
}
fn redact_human_value(value: &mut span::HumanValue) {
match value {
span::HumanValue::Literal { value: literal } => {
let chars = literal.chars().count();
*value = span::HumanValue::Redacted {
kind: "literal".to_string(),
chars: Some(chars),
};
}
span::HumanValue::Omitted { reason } => redact_string(reason),
span::HumanValue::Redacted { kind, .. } => redact_string(kind),
}
}
fn redact_optional_string(value: &mut Option<String>) {
if let Some(value) = value {
redact_string(value);
}
}
fn redact_string(value: &mut String) {
*value = redact_text(value);
}
fn is_sensitive_key(key: &str) -> bool {
name_is_sensitive(key)
}
const SECRET_PREFIXES: &[&str] = &[
"sk-", "sk_live_", "sk_test_", "ghp_", "gho_", "ghu_", "ghs_", "github_pat_", "xoxb-", "xoxp-", "xoxa-", "xoxr-", "AKIA", "ASIA", "AIza", "ya29.", "glpat-", "npm_", "shpat_", "shpss_", "eyJ", "-----BEGIN", ];
pub(crate) fn redact_text(text: &str) -> String {
redact_secrets_in_text(text).unwrap_or_else(|| text.to_string())
}
pub(crate) fn redact_secrets_in_text(text: &str) -> Option<String> {
if let Some(pem_free) = redact_pem_blocks(text) {
return Some(redact_secrets_in_text(&pem_free).unwrap_or(pem_free));
}
let keyed = redact_keyed_secrets(text);
let base = keyed.as_deref().unwrap_or(text);
match redact_prefixed_secrets(base) {
Some(redacted) => Some(redacted),
None => keyed,
}
}
fn redact_prefixed_secrets(text: &str) -> Option<String> {
if !text
.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '=' | ':' | ',' | '(' | ')'))
.any(looks_like_secret)
{
return None;
}
let redacted: String = text
.split_inclusive(|c: char| {
c.is_whitespace() || matches!(c, '"' | '\'' | '=' | ':' | ',' | '(' | ')')
})
.map(|chunk| {
let (token, delim) = match chunk.char_indices().last() {
Some((i, c))
if c.is_whitespace()
|| matches!(c, '"' | '\'' | '=' | ':' | ',' | '(' | ')') =>
{
(&chunk[..i], &chunk[i..])
}
_ => (chunk, ""),
};
if looks_like_secret(token) {
format!("[REDACTED]{delim}")
} else {
chunk.to_string()
}
})
.collect();
Some(redacted)
}
fn looks_like_secret(token: &str) -> bool {
SECRET_PREFIXES
.iter()
.any(|prefix| token.starts_with(prefix))
}
const SENSITIVE_KEY_NEEDLES: &[&str] = &[
"password",
"passwd",
"token",
"secret",
"api_key",
"apikey",
"authorization",
"credential",
"access_key",
"private_key",
"client_secret",
];
fn name_is_sensitive(name: &str) -> bool {
let name = name.to_ascii_lowercase();
SENSITIVE_KEY_NEEDLES
.iter()
.any(|needle| name.contains(needle))
}
fn redact_keyed_secrets(text: &str) -> Option<String> {
const MIN_VALUE: usize = 8;
let words: Vec<&str> = text.split_whitespace().collect();
let mut out: Vec<String> = Vec::with_capacity(words.len());
let mut changed = false;
let mut i = 0;
while i < words.len() {
let word = words[i];
if let Some((key, sep, value)) = split_assignment(word)
&& name_is_sensitive(key)
&& value_is_substantial(value, MIN_VALUE)
{
out.push(format!("{key}{sep}[REDACTED]"));
changed = true;
i += 1;
continue;
}
let bare = word.trim_end_matches([':', '=']);
if bare.len() < word.len()
&& name_is_sensitive(bare)
&& let Some(next) = words.get(i + 1)
&& value_is_substantial(next, MIN_VALUE)
{
out.push(word.to_string());
out.push("[REDACTED]".to_string());
changed = true;
i += 2;
continue;
}
out.push(word.to_string());
i += 1;
}
changed.then(|| out.join(" "))
}
fn split_assignment(word: &str) -> Option<(&str, &str, &str)> {
let idx = word.find(['=', ':'])?;
if idx == 0 {
return None;
}
Some((&word[..idx], &word[idx..idx + 1], &word[idx + 1..]))
}
fn value_is_substantial(value: &str, min: usize) -> bool {
let v = value.trim_matches(|c| matches!(c, '"' | '\'' | '`'));
v.len() >= min && v != "[REDACTED]"
}
pub(crate) fn contains_secret(text: &str) -> bool {
redact_secrets_in_text(text).is_some()
}
fn redact_pem_blocks(text: &str) -> Option<String> {
const BEGIN: &str = "-----BEGIN";
if !text.contains(BEGIN) {
return None;
}
let mut out = String::new();
let mut rest = text;
let mut redacted_any = false;
while let Some(start) = rest.find(BEGIN) {
out.push_str(&rest[..start]);
let after = &rest[start..];
if let Some(end_marker) = after.find("-----END")
&& let Some(end_dashes) = after[end_marker..].find("-----\n")
{
let block_end = end_marker + end_dashes + "-----".len();
out.push_str("[REDACTED PEM BLOCK]");
rest = &after[block_end..];
redacted_any = true;
} else if let Some(end_marker) = after.find("-----END")
&& let Some(end_dashes) = after[end_marker..].rfind("-----")
{
let block_end = end_marker + end_dashes + "-----".len();
out.push_str("[REDACTED PEM BLOCK]");
rest = &after[block_end..];
redacted_any = true;
} else {
out.push_str(after);
rest = "";
break;
}
}
out.push_str(rest);
redacted_any.then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_a_secret_embedded_in_a_command_string() {
let mut v = serde_json::json!({ "command": "curl -H 'Authorization: Bearer sk-abc123XYZ' https://api" });
redact_value(&mut v);
let s = v["command"].as_str().unwrap();
assert!(s.contains("[REDACTED]"), "got: {s}");
assert!(!s.contains("sk-abc123XYZ"));
assert!(s.contains("curl"));
}
#[test]
fn leaves_ordinary_strings_untouched() {
let mut v = serde_json::json!({ "command": "git status --short && cargo build" });
let before = v.clone();
redact_value(&mut v);
assert_eq!(v, before, "no secret shape, nothing to redact");
}
#[test]
fn redacts_a_sensitive_keyed_value_with_no_prefix() {
let leak = "export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
assert!(contains_secret(leak), "keyed secret should be detected");
let redacted = redact_text(leak);
assert!(redacted.contains("[REDACTED]"));
assert!(!redacted.contains("wJalrXUtnFEMI"));
assert!(redacted.contains("AWS_SECRET_ACCESS_KEY"));
}
#[test]
fn redacts_a_sensitive_value_in_the_next_word() {
let leak = "password: hunter2-very-long-passphrase";
assert!(contains_secret(leak));
assert!(!redact_text(leak).contains("hunter2-very-long-passphrase"));
}
#[test]
fn leaves_short_or_non_sensitive_assignments_alone() {
assert!(!contains_secret(
"output_path=/Users/me/build/artifact.tar.gz"
));
assert!(!contains_secret("secret=on"));
}
#[test]
fn still_redacts_by_sensitive_key() {
let mut v = serde_json::json!({ "api_key": "whatever-it-is", "ok": true });
redact_value(&mut v);
assert_eq!(v["api_key"], serde_json::json!("[REDACTED]"));
assert_eq!(v["ok"], serde_json::json!(true));
}
#[test]
fn recognizes_common_secret_prefixes() {
for token in [
"ghp_0123456789",
"AKIAIOSFODNN7EXAMPLE",
"xoxb-12345",
"glpat-abcdefghij",
"ya29.aBcDeF",
"npm_0123456789",
"eyJhbGciOiJIUzI1NiJ9",
] {
assert!(
looks_like_secret(token),
"{token} should look like a secret"
);
}
assert!(!looks_like_secret("git"));
assert!(!looks_like_secret("status"));
}
#[test]
fn redacts_a_whole_pem_private_key_block() {
let text = "key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEoQIDsecretAQAB\n-----END RSA PRIVATE KEY-----\ndone";
let out = redact_secrets_in_text(text).expect("PEM should trigger redaction");
assert!(out.contains("[REDACTED PEM BLOCK]"));
assert!(!out.contains("MIIEoQID"));
assert!(out.contains("done"));
}
#[test]
fn redact_text_helper_only_acts_when_asked() {
let secret = "run with token ghp_SECRETabc123";
assert!(maybe_redact_text(secret, false).contains("ghp_SECRETabc123"));
let redacted = maybe_redact_text(secret, true);
assert!(!redacted.contains("ghp_SECRETabc123"));
assert!(redacted.contains("[REDACTED]"));
}
}