1- use std:: collections:: HashSet ;
21use std:: path:: Component ;
32use std:: path:: Path ;
43use std:: path:: PathBuf ;
54
65use codex_protocol:: protocol:: SandboxPolicy ;
7- use dirs:: home_dir;
86use dunce:: canonicalize as canonicalize_path;
97
10- use crate :: bash:: parse_shell_lc_plain_commands;
11- use crate :: bash:: parse_shell_lc_single_command_prefix;
128use crate :: config:: Permissions ;
139use crate :: path_utils:: normalize_for_path_comparison;
1410use crate :: skills:: SkillLoadOutcome ;
15- use crate :: skills:: SkillMetadata ;
1611
12+ /// Resolves the sandbox policy extension contributed by the first matching
13+ /// skill for a command invocation.
14+ ///
15+ /// Assumptions:
16+ /// 1. `command_cwd` reflects the effective command target location.
17+ /// 2. If `command_cwd` is contained by multiple skill directories, the first
18+ /// enabled skill in `skills_outcome.skills` wins.
19+ /// 3. Command tokens are not used for matching.
20+ ///
21+ /// Returns `None` when no enabled skill with permissions matches
22+ /// `command_cwd`.
1723pub ( crate ) fn resolve_skill_sandbox_extension_for_command (
1824 skills_outcome : & SkillLoadOutcome ,
19- command : & [ String ] ,
2025 command_cwd : & Path ,
2126) -> Option < SandboxPolicy > {
22- let segments = command_segments_for_matching ( command) ;
23- let mut best_match: Option < ( usize , SandboxPolicy ) > = None ;
24-
25- for segment in segments {
26- for candidate in candidate_paths_for_segment ( & segment, command_cwd) {
27- let Some ( ( depth, _, permissions) ) =
28- match_skill_for_candidate ( skills_outcome, candidate. as_path ( ) )
29- else {
30- continue ;
31- } ;
32-
33- let should_replace = match & best_match {
34- Some ( ( best_depth, _) ) => depth > * best_depth,
35- None => true ,
36- } ;
37- if should_replace {
38- best_match = Some ( ( depth, permissions. sandbox_policy . get ( ) . clone ( ) ) ) ;
39- }
40- }
41- }
42-
43- best_match. map ( |( _, sandbox_policy) | sandbox_policy)
44- }
45-
46- fn command_segments_for_matching ( command : & [ String ] ) -> Vec < Vec < String > > {
47- if let Some ( commands) = parse_shell_lc_plain_commands ( command)
48- && !commands. is_empty ( )
49- {
50- return commands;
51- }
52-
53- if let Some ( command) = parse_shell_lc_single_command_prefix ( command) {
54- return vec ! [ command] ;
55- }
56-
57- vec ! [ command. to_vec( ) ]
58- }
59-
60- fn candidate_paths_for_segment ( segment : & [ String ] , command_cwd : & Path ) -> Vec < PathBuf > {
61- let mut candidates = Vec :: new ( ) ;
62- let mut seen = HashSet :: new ( ) ;
63-
64- if let Some ( path) = normalize_candidate_path ( command_cwd)
65- && seen. insert ( path. clone ( ) )
66- {
67- candidates. push ( path) ;
68- }
69-
70- for token in segment {
71- let Some ( path) = candidate_path_from_token ( token, command_cwd) else {
72- continue ;
73- } ;
74- if seen. insert ( path. clone ( ) ) {
75- candidates. push ( path) ;
76- }
77- }
78-
79- candidates
80- }
81-
82- fn candidate_path_from_token ( token : & str , command_cwd : & Path ) -> Option < PathBuf > {
83- if token. is_empty ( ) || token. contains ( "://" ) || token. starts_with ( '-' ) {
84- return None ;
85- }
86-
87- let is_path_like = token == "~"
88- || token. starts_with ( "~/" )
89- || token. starts_with ( "./" )
90- || token. starts_with ( "../" )
91- || token. contains ( '/' )
92- || token. contains ( '\\' )
93- || Path :: new ( token) . is_absolute ( )
94- || command_cwd. join ( token) . exists ( ) ;
95- if !is_path_like {
96- return None ;
97- }
98-
99- let expanded = expand_home ( token) ;
100- let path = PathBuf :: from ( expanded) ;
101- let absolute = if path. is_absolute ( ) {
102- path
103- } else {
104- command_cwd. join ( path)
105- } ;
106- normalize_candidate_path ( & absolute)
27+ let candidate = normalize_candidate_path ( command_cwd) ?;
28+ let permissions = match_skill_for_candidate ( skills_outcome, candidate. as_path ( ) ) ?;
29+ Some ( permissions. sandbox_policy . get ( ) . clone ( ) )
10730}
10831
10932fn normalize_candidate_path ( path : & Path ) -> Option < PathBuf > {
@@ -121,9 +44,7 @@ fn normalize_candidate_path(path: &Path) -> Option<PathBuf> {
12144fn match_skill_for_candidate < ' a > (
12245 skills_outcome : & ' a SkillLoadOutcome ,
12346 candidate : & Path ,
124- ) -> Option < ( usize , & ' a SkillMetadata , & ' a Permissions ) > {
125- let mut best_match: Option < ( usize , & SkillMetadata , & Permissions ) > = None ;
126-
47+ ) -> Option < & ' a Permissions > {
12748 for skill in & skills_outcome. skills {
12849 if skills_outcome. disabled_paths . contains ( & skill. path ) {
12950 continue ;
@@ -140,33 +61,10 @@ fn match_skill_for_candidate<'a>(
14061 if !candidate. starts_with ( & skill_dir) {
14162 continue ;
14263 }
143-
144- let depth = skill_dir. components ( ) . count ( ) ;
145- let should_replace = match & best_match {
146- Some ( ( best_depth, _, _) ) => depth > * best_depth,
147- None => true ,
148- } ;
149- if should_replace {
150- best_match = Some ( ( depth, skill, permissions) ) ;
151- }
64+ return Some ( permissions) ;
15265 }
15366
154- best_match
155- }
156-
157- fn expand_home ( path : & str ) -> String {
158- if path == "~" {
159- if let Some ( home) = home_dir ( ) {
160- return home. to_string_lossy ( ) . to_string ( ) ;
161- }
162- return path. to_string ( ) ;
163- }
164- if let Some ( rest) = path. strip_prefix ( "~/" )
165- && let Some ( home) = home_dir ( )
166- {
167- return home. join ( rest) . to_string_lossy ( ) . to_string ( ) ;
168- }
169- path. to_string ( )
67+ None
17068}
17169
17270fn normalize_lexically ( path : & Path ) -> PathBuf {
@@ -237,13 +135,11 @@ mod tests {
237135 }
238136
239137 #[ test]
240- fn resolves_skill_policy_for_executable_inside_skill_directory ( ) {
138+ fn resolves_skill_policy_when_cwd_is_inside_skill_directory ( ) {
241139 let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
242140 let skill_dir = tempdir. path ( ) . join ( "skills/demo" ) ;
243141 let scripts_dir = skill_dir. join ( "scripts" ) ;
244142 std:: fs:: create_dir_all ( & scripts_dir) . expect ( "create scripts" ) ;
245- let executable = scripts_dir. join ( "run.sh" ) ;
246- std:: fs:: write ( & executable, "#!/bin/sh\n echo ok\n " ) . expect ( "write script" ) ;
247143 let skill_path = skill_dir. join ( "SKILL.md" ) ;
248144 std:: fs:: write ( & skill_path, "skill" ) . expect ( "write SKILL.md" ) ;
249145
@@ -260,19 +156,17 @@ mod tests {
260156 skill_policy. clone( ) ,
261157 ) ] ) ;
262158
263- let resolved = resolve_skill_sandbox_extension_for_command (
264- & outcome,
265- & [ canonical ( & executable) . to_string_lossy ( ) . to_string ( ) ] ,
266- tempdir. path ( ) ,
267- ) ;
159+ let resolved = resolve_skill_sandbox_extension_for_command ( & outcome, & scripts_dir) ;
268160
269161 assert_eq ! ( resolved, Some ( skill_policy) ) ;
270162 }
271163
272164 #[ test]
273- fn resolves_skill_policy_for_shell_wrapped_relative_script_command ( ) {
165+ fn does_not_resolve_policy_when_only_command_path_matches ( ) {
274166 let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
275167 let skill_dir = tempdir. path ( ) . join ( "skills/demo" ) ;
168+ let outside_dir = tempdir. path ( ) . join ( "outside" ) ;
169+ std:: fs:: create_dir_all ( & outside_dir) . expect ( "create outside" ) ;
276170 let scripts_dir = skill_dir. join ( "scripts" ) ;
277171 std:: fs:: create_dir_all ( & scripts_dir) . expect ( "create scripts" ) ;
278172 std:: fs:: write ( scripts_dir. join ( "run.sh" ) , "#!/bin/sh\n echo ok\n " ) . expect ( "write script" ) ;
@@ -292,26 +186,17 @@ mod tests {
292186 skill_policy. clone( ) ,
293187 ) ] ) ;
294188
295- let resolved = resolve_skill_sandbox_extension_for_command (
296- & outcome,
297- & [
298- "bash" . to_string ( ) ,
299- "-lc" . to_string ( ) ,
300- "./scripts/run.sh --flag" . to_string ( ) ,
301- ] ,
302- & skill_dir,
303- ) ;
189+ let resolved = resolve_skill_sandbox_extension_for_command ( & outcome, & outside_dir) ;
304190
305- assert_eq ! ( resolved, Some ( skill_policy ) ) ;
191+ assert_eq ! ( resolved, None ) ;
306192 }
307193
308194 #[ test]
309195 fn ignores_disabled_skill_when_resolving_command_policy ( ) {
310196 let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
311197 let skill_dir = tempdir. path ( ) . join ( "skills/demo" ) ;
312198 std:: fs:: create_dir_all ( & skill_dir) . expect ( "create skill dir" ) ;
313- let executable = skill_dir. join ( "tool.sh" ) ;
314- std:: fs:: write ( & executable, "#!/bin/sh\n echo ok\n " ) . expect ( "write script" ) ;
199+ std:: fs:: write ( skill_dir. join ( "tool.sh" ) , "#!/bin/sh\n echo ok\n " ) . expect ( "write script" ) ;
315200 let skill_path = skill_dir. join ( "SKILL.md" ) ;
316201 std:: fs:: write ( & skill_path, "skill" ) . expect ( "write SKILL.md" ) ;
317202 let skill_path = canonical ( & skill_path) ;
@@ -322,24 +207,23 @@ mod tests {
322207 ) ] ) ;
323208 outcome. disabled_paths . insert ( skill_path) ;
324209
325- let resolved = resolve_skill_sandbox_extension_for_command (
326- & outcome,
327- & [ canonical ( & executable) . to_string_lossy ( ) . to_string ( ) ] ,
328- tempdir. path ( ) ,
329- ) ;
210+ let resolved = resolve_skill_sandbox_extension_for_command ( & outcome, & skill_dir) ;
330211
331212 assert_eq ! ( resolved, None ) ;
332213 }
333214
334215 #[ test]
335- fn prefers_most_specific_skill_directory_for_nested_match ( ) {
216+ fn resolves_first_matching_skill_directory_for_nested_match ( ) {
336217 let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
337218 let parent_skill_dir = tempdir. path ( ) . join ( "skills/parent" ) ;
338219 let nested_skill_dir = parent_skill_dir. join ( "nested" ) ;
339220 std:: fs:: create_dir_all ( nested_skill_dir. join ( "scripts" ) ) . expect ( "create scripts" ) ;
340221
341- let executable = nested_skill_dir. join ( "scripts/run.sh" ) ;
342- std:: fs:: write ( & executable, "#!/bin/sh\n echo ok\n " ) . expect ( "write script" ) ;
222+ std:: fs:: write (
223+ nested_skill_dir. join ( "scripts/run.sh" ) ,
224+ "#!/bin/sh\n echo ok\n " ,
225+ )
226+ . expect ( "write script" ) ;
343227
344228 let parent_skill_path = parent_skill_dir. join ( "SKILL.md" ) ;
345229 let nested_skill_path = nested_skill_dir. join ( "SKILL.md" ) ;
@@ -364,16 +248,12 @@ mod tests {
364248 exclude_slash_tmp : false ,
365249 } ;
366250 let outcome = outcome_with_skills ( vec ! [
367- skill_with_policy( canonical( & parent_skill_path) , parent_policy) ,
251+ skill_with_policy( canonical( & parent_skill_path) , parent_policy. clone ( ) ) ,
368252 skill_with_policy( canonical( & nested_skill_path) , nested_policy. clone( ) ) ,
369253 ] ) ;
370254
371- let resolved = resolve_skill_sandbox_extension_for_command (
372- & outcome,
373- & [ canonical ( & executable) . to_string_lossy ( ) . to_string ( ) ] ,
374- tempdir. path ( ) ,
375- ) ;
255+ let resolved = resolve_skill_sandbox_extension_for_command ( & outcome, & nested_skill_dir) ;
376256
377- assert_eq ! ( resolved, Some ( nested_policy ) ) ;
257+ assert_eq ! ( resolved, Some ( parent_policy ) ) ;
378258 }
379259}
0 commit comments