use std::fs;
use std::io::Write;
use std::path::PathBuf;
use crate::ui;
pub const FORMAT: &str = "amont-agent-v1";
const MAX_RECORD: usize = 512;
const MAX_EXCERPT: usize = 200;
const MAX_BYTES: u64 = 256 * 1024;
pub fn dir() -> Option<PathBuf> {
Some(crate::settings::config_dir()?.join("amont-agent"))
}
pub fn path() -> Option<PathBuf> {
Some(dir()?.join("journal.log"))
}
pub struct Entry<'a> {
pub rule: &'a str,
pub stance: &'a str,
pub outcome: &'a str,
pub session: &'a str,
pub repo: &'a str,
pub mode: &'a str,
pub excerpt: &'a str,
}
pub const ENV_PROBE: &str = "AMONT_AGENT_PROBE";
pub fn record(entry: &Entry) {
if std::env::var_os(ENV_PROBE).is_some() {
return;
}
let _ = try_record(entry);
}
fn try_record(entry: &Entry) -> Option<()> {
let path = path()?;
let dir = path.parent()?;
fs::create_dir_all(dir).ok()?;
let fresh = !path.exists();
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
if fresh {
f.write_all(format!("{FORMAT}\n").as_bytes()).ok()?;
}
let line = line_for(entry);
f.write_all(line.as_bytes()).ok()?;
if f.metadata().ok()?.len() > MAX_BYTES {
compact(&path);
}
Some(())
}
pub fn line_for(entry: &Entry) -> String {
let excerpt = redact_and_trim(entry.excerpt);
let mut line = format!(
"F {} {} {} {} {} {} {} {}\n",
now(),
field(entry.rule),
field(entry.stance),
field(entry.outcome),
field(&entry.session.chars().take(8).collect::<String>()),
field(entry.repo),
field(entry.mode),
excerpt
);
if line.len() > MAX_RECORD {
let keep = MAX_RECORD - 2;
let cut = (0..=keep)
.rev()
.find(|i| line.is_char_boundary(*i))
.unwrap_or(0);
line.truncate(cut);
line.push('\n');
}
line
}
fn field(s: &str) -> String {
let cleaned: String = ui::sanitize(s)
.chars()
.map(|c| if c.is_whitespace() { '_' } else { c })
.collect();
if cleaned.is_empty() {
"-".to_string()
} else {
cleaned
}
}
pub fn redact_and_trim(text: &str) -> String {
let mut s = redact(text);
s = ui::sanitize(&s);
s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.chars().count() > MAX_EXCERPT {
s = s.chars().take(MAX_EXCERPT).collect::<String>() + "…";
}
s
}
pub fn redact(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for word in text.split_inclusive(char::is_whitespace) {
out.push_str(&redact_word(word));
}
out
}
fn redact_word(word: &str) -> String {
let trimmed = word.trim_end();
let tail = &word[trimmed.len()..];
if let Some(scheme) = trimmed.find("://") {
let mut out = String::from(&trimmed[..scheme + 3]);
let rest = &trimmed[scheme + 3..];
let rest = match rest.find('@') {
Some(at) if !rest[..at].contains('/') => {
out.push_str("***:***");
&rest[at..]
}
_ => rest,
};
out.push_str(&redact_query(rest));
return out + tail;
}
if let Some(eq) = trimmed.find('=') {
let (name, _) = trimmed.split_at(eq);
if !name.contains('/') && !name.contains(':') && secret_named(name) {
return format!("{name}=***") + tail;
}
}
for prefix in [
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"github_pat_",
"sk-",
"AKIA",
] {
if trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 8 {
return format!("{prefix}***{tail}");
}
}
word.to_string()
}
fn secret_named(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
["TOKEN", "SECRET", "PASSWORD", "PASSWD", "KEY", "CREDENTIAL"]
.iter()
.any(|needle| upper.contains(needle))
}
fn redact_query(rest: &str) -> String {
let Some(q) = rest.find('?') else {
return rest.to_string();
};
let (base, query) = rest.split_at(q + 1);
let mut out = String::from(base);
for (i, param) in query.split('&').enumerate() {
if i > 0 {
out.push('&');
}
match param.split_once('=') {
Some((name, value)) if secret_named(name) => {
let keep: String = value
.chars()
.rev()
.take_while(|c| !c.is_alphanumeric() && *c != '-' && *c != '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
out.push_str(name);
out.push_str("=***");
out.push_str(&keep);
}
_ => out.push_str(param),
}
}
out
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn compact(path: &std::path::Path) {
let Ok(text) = fs::read_to_string(path) else {
return;
};
let lines: Vec<&str> = text.lines().filter(|l| l.starts_with("F ")).collect();
let keep = lines.len().saturating_sub(lines.len() / 2);
let mut out = String::from(FORMAT);
out.push('\n');
for l in &lines[lines.len() - keep..] {
out.push_str(l);
out.push('\n');
}
let _ = fs::write(path, out);
}
#[cfg(test)]
mod tests {
use super::*;
fn entry<'a>(excerpt: &'a str) -> Entry<'a> {
Entry {
rule: "pipe-to-tail",
stance: "deny",
outcome: "denied",
session: "0123456789abcdef",
repo: "amont",
mode: "default",
excerpt,
}
}
#[test]
fn a_record_is_exactly_one_line() {
let line = line_for(&entry("git push \n\n origin main | tail -1"));
assert_eq!(line.matches('\n').count(), 1);
assert!(line.ends_with('\n'));
assert!(line.len() <= MAX_RECORD);
}
#[test]
fn an_enormous_command_still_fits_one_write() {
let line = line_for(&entry(&"x".repeat(20_000)));
assert!(line.len() <= MAX_RECORD, "{} bytes", line.len());
assert_eq!(line.matches('\n').count(), 1);
}
#[test]
fn a_credential_never_reaches_the_journal() {
for (raw, must_not_contain) in [
("git push https://fred:hunter2@github.com/x", "hunter2"),
(
"GITHUB_TOKEN=ghp_abcdefghijklmnop1234 git push",
"ghp_abcdefghijklmnop1234",
),
("curl -H x --password=s3cr3t https://x", "s3cr3t"),
(
"gh auth login --with-token ghp_zzzzzzzzzzzzzzzzzzzz",
"ghp_zzzzzzzzzzzzzzzzzzzz",
),
] {
let got = redact_and_trim(raw);
assert!(
!got.contains(must_not_contain),
"leaked {must_not_contain:?} from {raw:?} → {got}"
);
}
let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
let got = redact_and_trim(&format!("aws --key {key} s3 ls"));
assert!(!got.contains(&key), "leaked an access key id → {got}");
}
#[test]
fn redaction_leaves_the_command_readable() {
let got = redact_and_trim("GITHUB_TOKEN=ghp_abcdefghijklmnop1234 git push | tail -1");
assert!(got.contains("git push"), "{got}");
assert!(got.contains("tail"), "{got}");
}
#[test]
fn control_bytes_are_escaped_not_stored_raw() {
let got = redact_and_trim("git push \u{1b}[8m hidden");
assert!(!got.contains('\u{1b}'), "{got}");
}
#[test]
fn an_api_path_is_not_a_secret_assignment() {
let raw = r#"curl -sS "http://localhost:3000/api/v1/repos/x/actions/tasks?limit=5" | jq"#;
assert_eq!(redact(raw), raw, "a benign URL was rewritten");
}
#[test]
fn a_secret_query_parameter_is_still_redacted() {
let got = redact("curl \"https://hooks.example.com/x?token=abc123def456&limit=5\"");
assert!(!got.contains("abc123def456"), "{got}");
assert!(
got.contains("limit=5"),
"the benign parameter survived: {got}"
);
assert!(got.ends_with('"'), "the closing quote survived: {got}");
}
#[test]
fn redaction_leaves_a_command_the_lexer_can_still_read() {
for raw in [
r#"curl -sS "http://x/api/v1/tasks?limit=5" | jq '.total'"#,
r#"GITHUB_TOKEN=ghp_aaaaaaaaaaaaaaaaaaaa git push origin main"#,
r#"git commit -m "msg" --no-verify && git push"#,
] {
let redacted = redact(raw);
assert!(
!matches!(
crate::shell::lex(&redacted),
crate::shell::Parsed::Opaque(_)
),
"redaction made this unreadable: {redacted}"
);
}
}
#[test]
fn a_field_never_contains_a_space() {
assert!(!field("two words").contains(' '));
assert_eq!(field(""), "-");
}
}