Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions crates/agent-tui-adapter/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,15 +481,15 @@ fn resolve_rows(
}
RowSpec::Dynamic(RowDynamic::AboveLastNonEmpty) => {
let row = last_non_empty_row(snap, rows, cols)?;
(row > 0).then_some((0, row - 1))
row.checked_sub(1).map(|above| (0, above))
}
RowSpec::Dynamic(RowDynamic::Cursor) => {
let row = usize::from(snap.grid.cursor.0).min(rows - 1);
Some((row, row))
}
RowSpec::Dynamic(RowDynamic::AboveCursor) => {
let row = usize::from(snap.grid.cursor.0).min(rows - 1);
(row > 0).then_some((0, row - 1))
row.checked_sub(1).map(|above| (0, above))
}
}
}
Expand Down Expand Up @@ -793,6 +793,32 @@ mod tests {
assert_eq!(resolve_range([-100, 100], 24), (0, 23));
}

#[test]
fn dynamic_above_row_selectors_do_not_underflow_on_first_row() {
let snap = snap("> ");
let rows = usize::from(snap.grid.rows);
let cols = usize::from(snap.grid.cols);

assert_eq!(
resolve_rows(
&RowSpec::Dynamic(RowDynamic::AboveLastNonEmpty),
&snap,
rows,
cols,
),
None
);
assert_eq!(
resolve_rows(
&RowSpec::Dynamic(RowDynamic::AboveCursor),
&snap,
rows,
cols
),
None
);
}

fn snap(content: &str) -> EngineSnapshot {
let lines: Vec<&str> = content.split('\n').collect();
let cols = u16::try_from(
Expand Down
167 changes: 140 additions & 27 deletions crates/agent-tui-daemon/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! OPA-WASM (`agent-tui --policy <file.rego>`) lands in a follow-on cycle.

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use agent_tui_protocol::action::{Action, ActionDetail, ActionKind, CallerInfo, Decision, Verdict};
Expand Down Expand Up @@ -53,11 +54,16 @@ impl Evaluator for AllowAllEvaluator {
}

/// Binary allowlist enforcement on `Spawn`; pass-through for other action
/// kinds. Empty allowlist means "everything allowed" (effective baseline
/// for development); the explicit `*` wildcard means "anything but I want
/// the audit log to know about it".
/// kinds. Entries without path separators are command names and only match
/// bare `argv[0]` values such as `git`; entries with path separators are
/// canonical executable paths and only match path-style invocations such as
/// `/usr/bin/git` or `./tool`. Empty allowlist means "everything allowed"
/// (effective baseline for development); the explicit `*` wildcard means
/// "anything but I want the audit log to know about it".
pub struct AllowlistEvaluator {
binaries: HashSet<String>,
paths: HashSet<PathBuf>,
unresolved_paths: HashSet<String>,
wildcard: bool,
}

Expand All @@ -67,12 +73,29 @@ impl AllowlistEvaluator {
#[must_use]
pub fn new(binaries: Vec<String>) -> Self {
let wildcard = binaries.iter().any(|s| s == "*");
let binaries = binaries
.into_iter()
.filter(|s| s != "*")
.map(|s| basename(&s).to_string())
.collect();
Self { binaries, wildcard }
let mut command_names = HashSet::new();
let mut paths = HashSet::new();
let mut unresolved_paths = HashSet::new();
for binary in binaries.into_iter().filter(|s| s != "*") {
if contains_path_separator(&binary) {
match canonicalize_allowlist_path(&binary) {
Ok(path) => {
paths.insert(path);
}
Err(_) => {
unresolved_paths.insert(binary);
}
}
} else {
command_names.insert(binary);
}
}
Self {
binaries: command_names,
paths,
unresolved_paths,
wildcard,
}
}

/// Parse a CSV string into an `AllowlistEvaluator`.
Expand All @@ -92,38 +115,62 @@ impl AllowlistEvaluator {
impl Evaluator for AllowlistEvaluator {
async fn evaluate(&self, action: &Action) -> Decision {
let audit_id = Uuid::new_v4();
if let ActionDetail::Spawn { argv, .. } = &action.detail {
let comm = argv
.first()
.map(|s| basename(s).to_string())
.unwrap_or_default();
if let ActionDetail::Spawn { argv, cwd } = &action.detail {
let requested = argv.first().map(String::as_str).unwrap_or_default();
let comm = spawn_display_name(requested);
if self.wildcard {
return Decision {
audit_id,
verdict: Verdict::Allow,
reason: format!("wildcard allowlist; spawn={comm}"),
};
}
if self.binaries.is_empty() {
let empty_allowlist = self.binaries.is_empty()
&& self.paths.is_empty()
&& self.unresolved_paths.is_empty();
if empty_allowlist {
return Decision {
audit_id,
verdict: Verdict::Allow,
reason: "empty allowlist (development mode)".into(),
};
}
if self.binaries.contains(&comm) {
if contains_path_separator(requested) {
return match canonicalize_spawn_path(requested, cwd) {
Ok(path) if self.paths.contains(&path) => Decision {
audit_id,
verdict: Verdict::Allow,
reason: format!("allowlisted path: {}", path.display()),
},
Ok(path) => Decision {
audit_id,
verdict: Verdict::Deny,
reason: format!(
"binary path {} not in allowlist; permitted: {}",
path.display(),
self.allowed_summary()
),
},
Err(e) => Decision {
audit_id,
verdict: Verdict::Deny,
reason: format!("binary path {requested} could not be resolved: {e}"),
},
};
}
if self.binaries.contains(requested) {
return Decision {
audit_id,
verdict: Verdict::Allow,
reason: format!("allowlisted: {comm}"),
reason: format!("allowlisted command: {requested}"),
};
}
return Decision {
audit_id,
verdict: Verdict::Deny,
reason: format!(
"binary {comm} not in allowlist; permitted: {}",
self.binaries.iter().cloned().collect::<Vec<_>>().join(", ")
self.allowed_summary()
),
};
}
Expand All @@ -135,8 +182,52 @@ impl Evaluator for AllowlistEvaluator {
}
}

fn basename(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
impl AllowlistEvaluator {
fn allowed_summary(&self) -> String {
let mut entries = self.binaries.iter().cloned().collect::<Vec<_>>();
entries.extend(self.paths.iter().map(|p| p.display().to_string()));
entries.extend(
self.unresolved_paths
.iter()
.map(|p| format!("{p} (unresolved)")),
);
entries.sort();
if entries.is_empty() {
"(none)".to_string()
} else {
entries.join(", ")
}
}
}

fn spawn_display_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}

fn contains_path_separator(path: &str) -> bool {
path.contains('/') || path.contains('\\')
}

fn canonicalize_allowlist_path(path: &str) -> std::io::Result<PathBuf> {
let path = Path::new(path);
if path.is_absolute() {
path.canonicalize()
} else {
std::env::current_dir()?.join(path).canonicalize()
}
}

fn canonicalize_spawn_path(path: &str, cwd: &str) -> std::io::Result<PathBuf> {
let path = Path::new(path);
if path.is_absolute() {
return path.canonicalize();
}
let base = if cwd.trim().is_empty() {
std::env::current_dir()?
} else {
PathBuf::from(cwd)
};
base.join(path).canonicalize()
}

/// Per-daemon governance state.
Expand Down Expand Up @@ -251,14 +342,38 @@ mod tests {
async fn allowlist_allows_known_binary_by_basename() {
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec!["bash".into()])));
let d = g
.check(build::spawn(
vec!["/bin/bash".into(), "-i".into()],
"/".into(),
))
.check(build::spawn(vec!["bash".into(), "-i".into()], "/".into()))
.await;
assert_eq!(d.verdict, Verdict::Allow);
}

#[tokio::test]
async fn basename_allowlist_does_not_allow_path_invocation() {
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec!["git".into()])));
let d = g
.check(build::spawn(vec!["./git".into()], "/".into()))
.await;
assert_eq!(d.verdict, Verdict::Deny);
}

#[tokio::test]
async fn unresolved_path_entry_does_not_make_allowlist_permissive() {
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec![
"/definitely/missing/agent-tui-test-binary".into(),
])));
let d = g.check(build::spawn(vec!["bash".into()], "/".into())).await;
assert_eq!(d.verdict, Verdict::Deny);
}

#[tokio::test]
async fn path_allowlist_allows_exact_canonical_path() {
let exe = std::env::current_exe().expect("current test binary");
let exe_str = exe.to_string_lossy().into_owned();
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec![exe_str.clone()])));
let d = g.check(build::spawn(vec![exe_str], "/".into())).await;
assert_eq!(d.verdict, Verdict::Allow);
}

#[tokio::test]
async fn allowlist_wildcard_allows_anything() {
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec!["*".into()])));
Expand All @@ -281,9 +396,7 @@ mod tests {
async fn audit_event_emitted_on_check() {
let g = Governance::new(Arc::new(AllowlistEvaluator::new(vec!["bash".into()])));
let mut sub = g.subscribe();
let _ = g
.check(build::spawn(vec!["/bin/bash".into()], "/".into()))
.await;
let _ = g.check(build::spawn(vec!["bash".into()], "/".into())).await;
let evt = sub.recv().await.expect("event");
assert_eq!(evt.action_kind, ActionKind::Spawn);
assert_eq!(evt.verdict, Verdict::Allow);
Expand Down
Loading
Loading