1111//! user installs `a3s-box`). This crate defines only the trait contract.
1212
1313use async_trait:: async_trait;
14+ use std:: collections:: HashMap ;
15+ use std:: sync:: Arc ;
16+
17+ use crate :: workspace:: CommandOutputObserver ;
18+
19+ pub mod srt;
20+
21+ /// Workspace-relative directories whose contents can change the agent,
22+ /// repository, editor, or tool control plane.
23+ ///
24+ /// Ordinary sandboxed commands and quiet workspace file mutations must not
25+ /// write these paths. An interactive host may expose an explicit, auditable
26+ /// escalation path instead.
27+ pub const PROTECTED_WORKSPACE_DIRECTORIES : & [ & str ] = & [
28+ ".git" , ".a3s" , ".agents" , ".codex" , ".claude" , ".vscode" , ".idea" ,
29+ ] ;
30+
31+ /// Workspace-relative files that can change command discovery or repository
32+ /// behavior even though they are not contained in a protected directory.
33+ pub const PROTECTED_WORKSPACE_FILES : & [ & str ] = & [
34+ ".gitmodules" ,
35+ ".mcp.json" ,
36+ ".ripgreprc" ,
37+ ".bashrc" ,
38+ ".bash_profile" ,
39+ ".zshrc" ,
40+ ".zprofile" ,
41+ ".profile" ,
42+ ] ;
43+
44+ /// Return whether a workspace-relative path targets protected control
45+ /// metadata.
46+ ///
47+ /// Both separators are recognized so policy decisions are stable before a
48+ /// platform-specific workspace resolver consumes the path. Boundary traversal
49+ /// is handled separately by the workspace guardrail and is never treated as a
50+ /// protected-path approval request.
51+ pub fn is_protected_workspace_path ( path : & str ) -> bool {
52+ let normalized = path. replace ( '\\' , "/" ) ;
53+ let mut components = normalized
54+ . split ( '/' )
55+ . filter ( |component| !component. is_empty ( ) && * component != "." ) ;
56+ let Some ( first) = components. next ( ) else {
57+ return false ;
58+ } ;
59+ if first == ".." || components. clone ( ) . any ( |component| component == ".." ) {
60+ return false ;
61+ }
62+
63+ PROTECTED_WORKSPACE_DIRECTORIES
64+ . iter ( )
65+ . any ( |protected| first. eq_ignore_ascii_case ( protected) )
66+ || PROTECTED_WORKSPACE_FILES
67+ . iter ( )
68+ . any ( |protected| first. eq_ignore_ascii_case ( protected) )
69+ }
70+
1471/// Output from running a command inside a sandbox.
1572pub struct SandboxOutput {
1673 /// Standard output bytes decoded as UTF-8.
@@ -21,6 +78,53 @@ pub struct SandboxOutput {
2178 pub exit_code : i32 ,
2279}
2380
81+ /// Complete request passed to sandbox implementations that support the
82+ /// execution controls used by the built-in `bash` tool.
83+ ///
84+ /// The legacy [`BashSandbox::exec_command`] method remains the minimum
85+ /// compatibility contract. New implementations should override
86+ /// [`BashSandbox::exec`] so command timeouts, streaming output, and explicit
87+ /// host-provided environment values are preserved inside the sandbox.
88+ #[ derive( Clone ) ]
89+ pub struct SandboxCommandRequest {
90+ pub command : String ,
91+ pub guest_workspace : String ,
92+ pub timeout_ms : u64 ,
93+ pub output_observer : Option < Arc < dyn CommandOutputObserver > > ,
94+ pub env : Option < Arc < HashMap < String , String > > > ,
95+ }
96+
97+ impl std:: fmt:: Debug for SandboxCommandRequest {
98+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
99+ f. debug_struct ( "SandboxCommandRequest" )
100+ . field ( "command" , & self . command )
101+ . field ( "guest_workspace" , & self . guest_workspace )
102+ . field ( "timeout_ms" , & self . timeout_ms )
103+ . field ( "output_observer" , & self . output_observer . is_some ( ) )
104+ . field ( "env" , & self . env . as_ref ( ) . map ( |env| env. len ( ) ) )
105+ . finish ( )
106+ }
107+ }
108+
109+ /// Output from the extended sandbox execution contract.
110+ pub struct SandboxExecutionOutput {
111+ pub stdout : String ,
112+ pub stderr : String ,
113+ pub exit_code : i32 ,
114+ pub timed_out : bool ,
115+ }
116+
117+ impl From < SandboxOutput > for SandboxExecutionOutput {
118+ fn from ( output : SandboxOutput ) -> Self {
119+ Self {
120+ stdout : output. stdout ,
121+ stderr : output. stderr ,
122+ exit_code : output. exit_code ,
123+ timed_out : false ,
124+ }
125+ }
126+ }
127+
24128// ============================================================================
25129// BashSandbox trait
26130// ============================================================================
@@ -43,6 +147,18 @@ pub trait BashSandbox: Send + Sync {
43147 guest_workspace : & str ,
44148 ) -> anyhow:: Result < SandboxOutput > ;
45149
150+ /// Execute a command with the complete host tool contract.
151+ ///
152+ /// Existing implementations inherit a compatibility adapter that delegates
153+ /// to [`Self::exec_command`]. Sandboxes that spawn a real process should
154+ /// override this method so timeout and output-stream semantics are not
155+ /// silently lost.
156+ async fn exec ( & self , request : SandboxCommandRequest ) -> anyhow:: Result < SandboxExecutionOutput > {
157+ self . exec_command ( & request. command , & request. guest_workspace )
158+ . await
159+ . map ( Into :: into)
160+ }
161+
46162 /// Shut down the sandbox (best-effort, infallible from caller's perspective).
47163 async fn shutdown ( & self ) ;
48164}
@@ -115,4 +231,35 @@ mod tests {
115231 let result = sandbox. exec_command ( "true" , "/workspace" ) . await . unwrap ( ) ;
116232 assert_eq ! ( result. exit_code, 0 ) ;
117233 }
234+
235+ #[ test]
236+ fn protected_workspace_paths_cover_control_metadata_cross_platform ( ) {
237+ for path in [
238+ ".git/config" ,
239+ "./.a3s/permissions.acl" ,
240+ ".AGENTS/worker.acl" ,
241+ ".codex\\ config" ,
242+ ".Claude/settings.json" ,
243+ ".vscode/tasks.json" ,
244+ ".idea/workspace.xml" ,
245+ ".gitmodules" ,
246+ ".MCP.JSON" ,
247+ ] {
248+ assert ! (
249+ is_protected_workspace_path( path) ,
250+ "{path} should require explicit host authorization"
251+ ) ;
252+ }
253+ for path in [
254+ "src/lib.rs" ,
255+ "nested/.git/config" ,
256+ "AGENTS.md" ,
257+ "../.git/config" ,
258+ ] {
259+ assert ! (
260+ !is_protected_workspace_path( path) ,
261+ "{path} should be handled by another boundary or remain ordinary"
262+ ) ;
263+ }
264+ }
118265}
0 commit comments