Skip to content

Commit 11f5561

Browse files
authored
Merge pull request #21 from A3S-Lab/codex/srt-core-6.2.0
feat(sandbox): ship SRT process isolation in v6.2.0
2 parents 3ff18db + 0b297fd commit 11f5561

25 files changed

Lines changed: 2503 additions & 121 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [6.2.0] - 2026-07-22
11+
12+
### Added
13+
14+
- Added a fail-closed SRT-backed `BashSandbox` with bounded execution,
15+
separated output streams, protected workspace control metadata, credential
16+
read boundaries, and verified exact-path npm and Node runtime constructors.
17+
1018
### Fixed
1119

1220
- Forwarded delegated child confirmation-required, confirmation-received, and

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,53 @@ directories, `AgentDir`, inline host input, or live registration. The
302302
model-visible `program` tool executes JavaScript in QuickJS, not arbitrary
303303
Python or a shell-script catalog.
304304

305+
### Process sandbox contract
306+
307+
Hosts can attach a `BashSandbox` through `SessionOptions::with_sandbox_handle`.
308+
The extended execution request preserves the command, guest workspace, timeout,
309+
stream observer, and explicit command environment; existing implementations
310+
remain compatible through `exec_command`.
311+
312+
`SrtBashSandbox` is the fail-closed local adapter. It denies command network
313+
egress, local binding, and Unix sockets; limits writes to the active workspace
314+
and a private per-run scratch directory; protects repository and agent-control
315+
metadata; blocks common credential reads; and scrubs ambient secrets and
316+
language/bootstrap injection variables. It keeps stdout and stderr distinct
317+
under one global capture limit. Its deadline covers output draining and the
318+
child's complete lifetime, including a process that closes both streams before
319+
it exits; timeout or cancellation terminates the Unix process group. It never
320+
searches `PATH` for a sandbox runtime and never falls back to the host runner
321+
when its explicitly provisioned runtime is missing or fails. The embedding host
322+
remains responsible for choosing whether an unavailable sandbox causes an
323+
interactive escalation or a deterministic denial.
324+
325+
Shell isolation does not automatically govern in-process workspace tools.
326+
Interactive hosts should construct `LocalWorkspaceBackend` or
327+
`ManifestWorkspaceBackend` with
328+
`LocalWorkspaceAccessPolicy::CredentialBoundary`. That opt-in applies the
329+
credential boundary to direct reads, range reads, writes, and both indexed and
330+
fallback grep. Directory grep omits denied candidates, explicit sensitive
331+
targets fail closed, source-tree multi-link files are rejected, and ordinary
332+
package-store hardlinks remain usable unless they alias a discovered
333+
credential inode. Guarded local Git diff enumerates changed paths with Git's
334+
NUL-delimited format and regenerates output only for allowed paths; option-like
335+
revision input cannot become a Git flag, and displayed remote URLs omit
336+
embedded HTTP credentials and query tokens.
337+
338+
Hosts use `from_verified_npm_with_node` when a lifecycle owner supplies exact
339+
SRT and Node paths. The verified constructor requires the expected npm package
340+
identity and a tested SRT version; `new` likewise requires an explicit path for
341+
an intentionally supplied custom adapter. The A3S CLI uses the verified
342+
exact-path form after validating and, when policy allows, preparing its
343+
user-wide managed installation; Core does not install host software.
344+
345+
This adapter is a low-latency local enforcement provider, not the stack-wide
346+
workload contract. Code owns agent permission and escalation policy, A3S
347+
Runtime owns provider-neutral Task and Service lifecycle and placement, and A3S
348+
Box owns OCI and stronger-isolation workloads. Replacing the CLI's transitional
349+
npm bootstrap with an A3S-signed sandbox artifact does not change the
350+
`BashSandbox` contract.
351+
305352
Long-running hosts can call `AgentSession::add_skill`, `remove_skill`, and
306353
`skill_names` without rebuilding the session. The model-visible Skill catalog
307354
reads the same live registry, so a successful mutation is visible on the next

core/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "6.1.0"
3+
version = "6.2.0"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"
@@ -122,6 +122,9 @@ aws-smithy-runtime-api = { version = "1", optional = true }
122122
[target.'cfg(unix)'.dependencies]
123123
libc = "0.2"
124124

125+
[target.'cfg(windows)'.dependencies]
126+
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
127+
125128
[features]
126129
default = []
127130
# Enable OpenTelemetry OTLP export for traces and metrics.

core/src/sandbox.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,63 @@
1111
//! user installs `a3s-box`). This crate defines only the trait contract.
1212
1313
use 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.
1572
pub 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

Comments
 (0)