1#[cfg(test)]
2macro_rules! safe {
3 ($($name:ident: $cmd:expr),* $(,)?) => {
4 $(#[test] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
5 };
6}
7
8#[cfg(test)]
9macro_rules! denied {
10 ($($name:ident: $cmd:expr),* $(,)?) => {
11 $(#[test] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
12 };
13}
14
15#[cfg(test)]
16macro_rules! inert {
17 ($($name:ident: $cmd:expr),* $(,)?) => {
18 $(#[test] fn $name() {
19 assert_eq!(
20 crate::command_verdict($cmd),
21 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
22 "expected Inert: {}", $cmd,
23 );
24 })*
25 };
26}
27
28#[cfg(test)]
29macro_rules! safe_read {
30 ($($name:ident: $cmd:expr),* $(,)?) => {
31 $(#[test] fn $name() {
32 assert_eq!(
33 crate::command_verdict($cmd),
34 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
35 "expected SafeRead: {}", $cmd,
36 );
37 })*
38 };
39}
40
41#[cfg(test)]
42macro_rules! safe_write {
43 ($($name:ident: $cmd:expr),* $(,)?) => {
44 $(#[test] fn $name() {
45 assert_eq!(
46 crate::command_verdict($cmd),
47 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
48 "expected SafeWrite: {}", $cmd,
49 );
50 })*
51 };
52}
53
54pub mod cli;
55#[cfg(test)]
56mod composition;
57pub mod cst;
58#[cfg(test)]
59mod handler_property_tests;
60pub mod docs;
61pub mod engine;
62mod handlers;
63pub mod parse;
64pub mod pathctx;
65pub mod pathgate;
66pub mod policy;
67pub mod registry;
68pub mod suggest;
69pub mod allowlist;
70pub mod targets;
71pub mod verdict;
72
73pub use verdict::{SafetyLevel, Verdict};
74
75pub fn is_safe_command(command: &str) -> bool {
76 command_verdict(command).is_allowed()
77}
78
79pub fn command_verdict(command: &str) -> Verdict {
80 cst::command_verdict(command)
81}
82
83pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
90 let _guard = engine::bridge::enter_eval_level(level);
91 cst::command_verdict(command)
92}
93
94pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
98 if !matches!(name, "local-admin" | "network-admin" | "yolo") {
99 return None;
100 }
101 engine::authoring::default_levels().iter().find(|l| l.name == name)
102}
103
104pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
111 let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
112 let canonical = legacy_of.unwrap_or(name);
113 let engine_level = match canonical {
119 "editor" | "local-admin" | "network-admin" | "yolo" => {
120 engine::authoring::default_levels().iter().find(|l| l.name == canonical)
121 }
122 _ => None,
123 };
124 Some((ceiling, engine_level))
125}
126
127pub fn command_verdict_ceilinged(
133 command: &str,
134 threshold: SafetyLevel,
135 engine_level: Option<&'static engine::level::Level>,
136) -> Verdict {
137 let verdict = match engine_level {
138 Some(level) => command_verdict_at_level(command, level),
139 None => command_verdict(command),
140 };
141 match verdict {
142 Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
143 _ => Verdict::Denied,
144 }
145}
146
147pub fn explain_with_coverage_at_level(
154 command: &str,
155 engine_level: Option<&'static engine::level::Level>,
156) -> cst::Explanation {
157 let patterns = allowlist::Matcher::load();
158 let _guard = engine_level.map(engine::bridge::enter_eval_level);
159 cst::explain_with_coverage(command, &patterns)
160}
161
162pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
167 registry::user_config_level()
168 .and_then(|name| level_ceiling(&name))
169 .unwrap_or((SafetyLevel::SafeWrite, None))
170}
171
172pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
176 let _guard = pathctx::enter(ctx);
177 cst::command_verdict(command)
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ReachReason {
186 Credential,
188 HiddenPeer,
191 OutsideWorkspace,
193}
194
195impl ReachReason {
196 pub fn message(self, path: &str) -> String {
199 match self {
200 ReachReason::Credential => format!(
201 "it reaches `{path}`, a credential store the agent should almost certainly not touch. \
202 If this was not intended, stop it"
203 ),
204 ReachReason::HiddenPeer => format!(
205 "it reaches `{path}`, a HIDDEN file inside a co-located peer project. The peer's \
206 ordinary source is readable, but its hidden files (`.env`, `.git`, `.aws`, …) are \
207 shielded — this is a deliberate guard, not a path error. To reach it, grant that \
208 path in ~/.config/safe-chains.toml, or run the agent from the peer's parent \
209 directory so the peer counts as in-workspace"
210 ),
211 ReachReason::OutsideWorkspace => match pathctx::cwd() {
212 Some(cwd) => format!(
213 "it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
214 running from the wrong directory — an easy thing to forget — relaunch it where \
215 you meant to be; to allow it from here, grant that path in \
216 ~/.config/safe-chains.toml"
217 ),
218 None => format!(
219 "it reaches `{path}`, outside the working directory. To allow it, grant that \
220 path in ~/.config/safe-chains.toml"
221 ),
222 },
223 }
224 }
225}
226
227pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
234 let tokens = shell_words::split(command).ok()?;
235 tokens.into_iter().find_map(|t| {
236 if !policy::looks_like_path(&t) {
237 return None;
238 }
239 let resolved = pathctx::resolve(&t).into_owned();
240 let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
241 && (!engine::resolve::read_content_verdict(&resolved).is_allowed()
242 || !engine::resolve::write_target_verdict(&resolved).is_allowed());
243 if !outside {
244 return None;
245 }
246 let reason = if engine::resolve::reads_secret(&resolved) {
247 ReachReason::Credential
248 } else if engine::resolve::hidden_peer_reach(&t) {
249 ReachReason::HiddenPeer
250 } else {
251 ReachReason::OutsideWorkspace
252 };
253 Some((t, reason))
254 })
255}
256
257#[cfg(test)]
258mod tests;