From 458d520d532f5fee305c17f748cee98e61f8f383 Mon Sep 17 00:00:00 2001 From: Hayato Kawai Date: Sat, 14 Mar 2026 18:43:37 +0900 Subject: [PATCH 1/3] audit: add `--cwd` option to filter entries by working directory Audit logs record `metadata.cwd` for each entry, but there was no way to filter by it from the CLI. This makes it difficult to view only the commands executed in a specific project directory. Add `--cwd ` flag that filters entries whose recorded cwd matches the given path or is a subdirectory of it. The path is canonicalized before matching to handle relative paths and symlinks correctly. Also introduce `ResolvedFilter` struct to reduce parameter count on `read_file` and `matches_filter`, fixing a clippy too_many_arguments warning. --- docs/src/content/docs/cli/audit.md | 4 + src/audit/filter.rs | 4 + src/audit/reader.rs | 149 +++++++++++++++++++++++------ src/cli/mod.rs | 16 +++- src/main.rs | 11 +++ 5 files changed, 152 insertions(+), 32 deletions(-) diff --git a/docs/src/content/docs/cli/audit.md b/docs/src/content/docs/cli/audit.md index 134da38..651c3ee 100644 --- a/docs/src/content/docs/cli/audit.md +++ b/docs/src/content/docs/cli/audit.md @@ -31,6 +31,10 @@ Show only entries before the given time. Same format as `--since`. Filter entries whose command string contains the given substring. +### `--cwd ` + +Filter entries by working directory. Only shows entries executed in the given directory or its subdirectories. The path is resolved to its canonical (absolute, symlink-resolved) form before matching. + ### `--limit ` Maximum number of entries to display. diff --git a/src/audit/filter.rs b/src/audit/filter.rs index 2b71dde..a8414a1 100644 --- a/src/audit/filter.rs +++ b/src/audit/filter.rs @@ -93,6 +93,8 @@ pub struct AuditFilter { pub until: Option, /// Filter by command substring match. pub command_pattern: Option, + /// Filter by working directory (prefix match, includes subdirectories). + pub cwd: Option, /// Maximum number of entries to return (default: 50). pub limit: usize, } @@ -104,6 +106,7 @@ impl Default for AuditFilter { since: None, until: None, command_pattern: None, + cwd: None, limit: 50, } } @@ -187,5 +190,6 @@ mod tests { assert!(filter.since.is_none()); assert!(filter.until.is_none()); assert!(filter.command_pattern.is_none()); + assert!(filter.cwd.is_none()); } } diff --git a/src/audit/reader.rs b/src/audit/reader.rs index 2efa206..b896371 100644 --- a/src/audit/reader.rs +++ b/src/audit/reader.rs @@ -9,6 +9,15 @@ use super::log_rotator::parse_log_date; use super::model::{AuditEntry, SerializableAction}; use crate::config::ActionKind; +/// Time-resolved filter criteria ready for matching against entries. +struct ResolvedFilter<'a> { + action: &'a Option, + since: Option>, + until: Option>, + command_pattern: &'a Option, + cwd: &'a Option, +} + /// Reads and filters audit log entries from JSONL date-partitioned files. pub struct AuditReader { log_dir: PathBuf, @@ -34,22 +43,20 @@ impl AuditReader { filter: &AuditFilter, now: DateTime, ) -> Result, anyhow::Error> { - let since = filter.since.as_ref().map(|ts| ts.resolve(now)); - let until = filter.until.as_ref().map(|ts| ts.resolve(now)); + let resolved = ResolvedFilter { + action: &filter.action, + since: filter.since.as_ref().map(|ts| ts.resolve(now)), + until: filter.until.as_ref().map(|ts| ts.resolve(now)), + command_pattern: &filter.command_pattern, + cwd: &filter.cwd, + }; - let date_files = self.collect_date_files(since)?; + let date_files = self.collect_date_files(resolved.since)?; let mut entries = Vec::new(); for path in &date_files { - self.read_file( - path, - &filter.action, - since, - until, - &filter.command_pattern, - &mut entries, - )?; + self.read_file(path, &resolved, &mut entries)?; } // Partial sort: find the newest `limit` entries, then sort ascending (oldest first) @@ -106,10 +113,7 @@ impl AuditReader { fn read_file( &self, path: &Path, - action_filter: &Option, - since: Option>, - until: Option>, - command_pattern: &Option, + resolved: &ResolvedFilter, entries: &mut Vec, ) -> Result<(), anyhow::Error> { let file = fs::File::open(path)?; @@ -146,7 +150,7 @@ impl AuditReader { } }; - if !Self::matches_filter(&entry, action_filter, since, until, command_pattern) { + if !Self::matches_filter(&entry, resolved) { continue; } @@ -156,23 +160,17 @@ impl AuditReader { Ok(()) } - fn matches_filter( - entry: &AuditEntry, - action_filter: &Option, - since: Option>, - until: Option>, - command_pattern: &Option, - ) -> bool { + fn matches_filter(entry: &AuditEntry, filter: &ResolvedFilter) -> bool { // Check timestamp filters; malformed timestamps cannot satisfy time filters - if since.is_some() || until.is_some() { + if filter.since.is_some() || filter.until.is_some() { match entry.timestamp.parse::>() { Ok(ts) => { - if let Some(since_dt) = since + if let Some(since_dt) = filter.since && ts < since_dt { return false; } - if let Some(until_dt) = until + if let Some(until_dt) = filter.until && ts > until_dt { return false; @@ -183,19 +181,32 @@ impl AuditReader { } // Check action filter - if let Some(action_kind) = action_filter + if let Some(action_kind) = filter.action && !Self::action_matches(&entry.action, action_kind) { return false; } // Check command pattern (substring match) - if let Some(pattern) = command_pattern + if let Some(pattern) = filter.command_pattern && !entry.command.contains(pattern.as_str()) { return false; } + // Check cwd filter (prefix match, includes subdirectories) + if let Some(filter_cwd) = filter.cwd { + match &entry.metadata.cwd { + Some(entry_cwd) => { + if entry_cwd != filter_cwd && !entry_cwd.starts_with(&format!("{filter_cwd}/")) + { + return false; + } + } + None => return false, + } + } + true } @@ -240,6 +251,21 @@ mod tests { } } + fn make_entry_with_cwd( + timestamp: &str, + command: &str, + action: SerializableAction, + cwd: Option<&str>, + ) -> AuditEntry { + AuditEntry { + metadata: AuditMetadata { + cwd: cwd.map(|s| s.to_owned()), + ..AuditMetadata::default() + }, + ..make_entry(timestamp, command, action) + } + } + fn write_jsonl(dir: &Path, filename: &str, entries: &[AuditEntry]) { let path = dir.join(filename); let mut file = fs::File::create(path).unwrap(); @@ -564,6 +590,73 @@ mod tests { assert_eq!(result[0].command, "echo good"); } + #[rstest] + fn filter_by_cwd(temp_log_dir: TempDir) { + let entries = vec![ + make_entry_with_cwd( + "2026-02-25T10:00:00Z", + "echo project", + SerializableAction::Allow, + Some("/home/user/project"), + ), + make_entry_with_cwd( + "2026-02-25T11:00:00Z", + "echo subdir", + SerializableAction::Allow, + Some("/home/user/project/src"), + ), + make_entry_with_cwd( + "2026-02-25T12:00:00Z", + "echo other", + SerializableAction::Allow, + Some("/home/user/other"), + ), + make_entry_with_cwd( + "2026-02-25T13:00:00Z", + "echo no-cwd", + SerializableAction::Allow, + None, + ), + ]; + write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries); + + let reader = AuditReader::new(temp_log_dir.path().to_path_buf()); + let mut filter = AuditFilter::new(); + filter.cwd = Some("/home/user/project".to_owned()); + let result = reader.read(&filter).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].command, "echo project"); + assert_eq!(result[1].command, "echo subdir"); + } + + #[rstest] + fn filter_by_cwd_no_false_prefix_match(temp_log_dir: TempDir) { + let entries = vec![ + make_entry_with_cwd( + "2026-02-25T10:00:00Z", + "echo project", + SerializableAction::Allow, + Some("/home/user/project"), + ), + make_entry_with_cwd( + "2026-02-25T11:00:00Z", + "echo project2", + SerializableAction::Allow, + Some("/home/user/project2"), + ), + ]; + write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries); + + let reader = AuditReader::new(temp_log_dir.path().to_path_buf()); + let mut filter = AuditFilter::new(); + filter.cwd = Some("/home/user/project".to_owned()); + let result = reader.read(&filter).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].command, "echo project"); + } + #[rstest] fn skip_empty_lines(temp_log_dir: TempDir) { let valid_entry = make_entry( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 715ad7c..0bae8ed 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -147,6 +147,10 @@ pub struct AuditArgs { #[arg(long)] pub command: Option, + /// Filter by working directory (includes subdirectories) + #[arg(long)] + pub cwd: Option, + /// Maximum number of entries to show #[arg(long, default_value_t = 50)] pub limit: usize, @@ -210,19 +214,23 @@ mod tests { )] #[case::audit_default( &["runok", "audit"], - Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, cwd: None, limit: 50, json: false }), )] #[case::audit_with_action( &["runok", "audit", "--action", "deny"], - Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, cwd: None, limit: 50, json: false }), )] #[case::audit_with_since( &["runok", "audit", "--since", "1h"], - Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, cwd: None, limit: 50, json: false }), + )] + #[case::audit_with_cwd( + &["runok", "audit", "--cwd", "/home/user/project"], + Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, cwd: Some("/home/user/project".into()), limit: 50, json: false }), )] #[case::audit_with_all_options( &["runok", "audit", "--action", "allow", "--since", "7d", "--until", "1h", "--command", "git", "--limit", "10", "--json"], - Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), limit: 10, json: true }), + Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), cwd: None, limit: 10, json: true }), )] #[case::init_defaults( &["runok", "init"], diff --git a/src/main.rs b/src/main.rs index 3c8c15d..000a6b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -325,6 +325,17 @@ fn run_audit(args: AuditArgs, cwd: &std::path::Path) -> i32 { filter.command_pattern = args.command; + if let Some(cwd_arg) = args.cwd { + let cwd_path = std::path::Path::new(&cwd_arg); + match cwd_path.canonicalize() { + Ok(canonical) => filter.cwd = Some(canonical.to_string_lossy().into_owned()), + Err(e) => { + eprintln!("runok: failed to resolve cwd path '{}': {e}", cwd_arg); + return 1; + } + } + } + let reader = AuditReader::new(log_dir); let entries = match reader.read(&filter) { Ok(e) => e, From df4fc00f97a837b055d0160c73fb551e0b28dc71 Mon Sep 17 00:00:00 2001 From: Hayato Kawai Date: Sat, 14 Mar 2026 18:47:34 +0900 Subject: [PATCH 2/3] audit: rename `--cwd` option to `--dir` `--cwd` implies "current working directory" which is misleading for an option that takes an arbitrary path as a value. `--dir` is a more accurate name for filtering by directory. --- docs/src/content/docs/cli/audit.md | 2 +- src/cli/mod.rs | 16 ++++++++-------- src/main.rs | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/cli/audit.md b/docs/src/content/docs/cli/audit.md index 651c3ee..78f7d1a 100644 --- a/docs/src/content/docs/cli/audit.md +++ b/docs/src/content/docs/cli/audit.md @@ -31,7 +31,7 @@ Show only entries before the given time. Same format as `--since`. Filter entries whose command string contains the given substring. -### `--cwd ` +### `--dir ` Filter entries by working directory. Only shows entries executed in the given directory or its subdirectories. The path is resolved to its canonical (absolute, symlink-resolved) form before matching. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0bae8ed..82ee890 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -149,7 +149,7 @@ pub struct AuditArgs { /// Filter by working directory (includes subdirectories) #[arg(long)] - pub cwd: Option, + pub dir: Option, /// Maximum number of entries to show #[arg(long, default_value_t = 50)] @@ -214,23 +214,23 @@ mod tests { )] #[case::audit_default( &["runok", "audit"], - Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, cwd: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, dir: None, limit: 50, json: false }), )] #[case::audit_with_action( &["runok", "audit", "--action", "deny"], - Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, cwd: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: Some("deny".into()), since: None, until: None, command: None, dir: None, limit: 50, json: false }), )] #[case::audit_with_since( &["runok", "audit", "--since", "1h"], - Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, cwd: None, limit: 50, json: false }), + Commands::Audit(AuditArgs { action: None, since: Some("1h".into()), until: None, command: None, dir: None, limit: 50, json: false }), )] - #[case::audit_with_cwd( - &["runok", "audit", "--cwd", "/home/user/project"], - Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, cwd: Some("/home/user/project".into()), limit: 50, json: false }), + #[case::audit_with_dir( + &["runok", "audit", "--dir", "/home/user/project"], + Commands::Audit(AuditArgs { action: None, since: None, until: None, command: None, dir: Some("/home/user/project".into()), limit: 50, json: false }), )] #[case::audit_with_all_options( &["runok", "audit", "--action", "allow", "--since", "7d", "--until", "1h", "--command", "git", "--limit", "10", "--json"], - Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), cwd: None, limit: 10, json: true }), + Commands::Audit(AuditArgs { action: Some("allow".into()), since: Some("7d".into()), until: Some("1h".into()), command: Some("git".into()), dir: None, limit: 10, json: true }), )] #[case::init_defaults( &["runok", "init"], diff --git a/src/main.rs b/src/main.rs index 000a6b0..4b01586 100644 --- a/src/main.rs +++ b/src/main.rs @@ -325,12 +325,12 @@ fn run_audit(args: AuditArgs, cwd: &std::path::Path) -> i32 { filter.command_pattern = args.command; - if let Some(cwd_arg) = args.cwd { - let cwd_path = std::path::Path::new(&cwd_arg); - match cwd_path.canonicalize() { + if let Some(dir_arg) = args.dir { + let dir_path = std::path::Path::new(&dir_arg); + match dir_path.canonicalize() { Ok(canonical) => filter.cwd = Some(canonical.to_string_lossy().into_owned()), Err(e) => { - eprintln!("runok: failed to resolve cwd path '{}': {e}", cwd_arg); + eprintln!("runok: failed to resolve directory path '{}': {e}", dir_arg); return 1; } } From 7c211c4cbed747677b012c3ed59d7c8ac94ed560 Mon Sep 17 00:00:00 2001 From: Hayato Kawai Date: Sat, 14 Mar 2026 18:52:37 +0900 Subject: [PATCH 3/3] audit: use Path::starts_with for cwd filter matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit String-based prefix match with format!("{filter_cwd}/") broke when the filter path was root "/" — it produced "//" which never matches any standard Unix path, incorrectly excluding all subdirectory entries. Path::starts_with handles component-level matching correctly for all paths including root, and also prevents false prefix matches like "/home/user/project2" matching "/home/user/project". --- src/audit/reader.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/audit/reader.rs b/src/audit/reader.rs index b896371..642c702 100644 --- a/src/audit/reader.rs +++ b/src/audit/reader.rs @@ -198,8 +198,7 @@ impl AuditReader { if let Some(filter_cwd) = filter.cwd { match &entry.metadata.cwd { Some(entry_cwd) => { - if entry_cwd != filter_cwd && !entry_cwd.starts_with(&format!("{filter_cwd}/")) - { + if !Path::new(entry_cwd).starts_with(filter_cwd) { return false; } } @@ -630,6 +629,34 @@ mod tests { assert_eq!(result[1].command, "echo subdir"); } + #[rstest] + fn filter_by_cwd_root_directory(temp_log_dir: TempDir) { + let entries = vec![ + make_entry_with_cwd( + "2026-02-25T10:00:00Z", + "echo root", + SerializableAction::Allow, + Some("/"), + ), + make_entry_with_cwd( + "2026-02-25T11:00:00Z", + "echo subdir", + SerializableAction::Allow, + Some("/home/user"), + ), + ]; + write_jsonl(temp_log_dir.path(), "audit-2026-02-25.jsonl", &entries); + + let reader = AuditReader::new(temp_log_dir.path().to_path_buf()); + let mut filter = AuditFilter::new(); + filter.cwd = Some("/".to_owned()); + let result = reader.read(&filter).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].command, "echo root"); + assert_eq!(result[1].command, "echo subdir"); + } + #[rstest] fn filter_by_cwd_no_false_prefix_match(temp_log_dir: TempDir) { let entries = vec![