Skip to content

Commit f24d533

Browse files
committed
Mode B: symbi run spawns a governed Claude Code subprocess (executor=claude_code path via CliExecutor with the SYMBIONT_* env handshake, stdio symbi mcp back-channel, max-turns primary bound + graceful SIGTERM/SIGKILL timeout); add code_reviewer agent, cli-executor on by default, and Mode B docs
1 parent aa8d1d1 commit f24d533

12 files changed

Lines changed: 669 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ E2B is a separate hosted-cloud backend, **not** a peer of Tier 1/2/3. Code runs
132132
|---------|-----------|---------------|-----------|
133133
| E2B (hosted) | `with { sandbox = "e2b" }` (DSL only — no `--sandbox` flag) | `E2B_API_KEY` env var | Quick-start demos, evaluation without setting up a sandbox host. **Not for production workloads with privacy or compliance requirements.** |
134134

135+
## Managed CLI agents (Mode B)
136+
137+
An agent whose metadata declares `executor = "claude_code"` is run by spawning a
138+
governed Claude Code subprocess via `crates/runtime/src/cli_executor` (the
139+
`cli-executor` feature, on by default) instead of the ORGA reasoning loop. The
140+
reference agent is `agents/code_reviewer.symbi`; the path lives in
141+
`src/commands/managed_cli.rs`.
142+
143+
`symbi run code_reviewer --target <dir>`:
144+
- passes the spawn through the policy Gate (fail-closed; allow via Cedar or
145+
`SYMBI_INSECURE_ALLOW_ALL=1`);
146+
- injects the env handshake `SYMBIONT_MANAGED=true`, `SYMBIONT_SESSION_ID`,
147+
`SYMBIONT_BUDGET_TOKENS`, `SYMBIONT_BUDGET_TIMEOUT`, `CLAUDE_PROJECT_DIR` (the
148+
symbi-claude-code plugin defers its hooks to the outer Gate on `SYMBIONT_MANAGED`);
149+
- loads the plugin via `--plugin-dir` (resolve order: `--plugin-dir` flag,
150+
`SYMBIONT_CLAUDE_PLUGIN_DIR`, then sibling-repo autodetect) and wires the stdio
151+
`symbi mcp` back-channel via `--mcp-config --strict-mcp-config`;
152+
- bounds the run with `--max-turns` (primary, cooperative) and `--budget-timeout`
153+
(hard wall-clock backstop; `CliExecutor` kills with graceful SIGTERM → SIGKILL).
154+
155+
Do **not** pass `--bare` to the spawned `claude` — it skips reading `~/.claude`
156+
(credentials included) and breaks subscription auth.
157+
135158
## ToolClad Tools
136159

137160
Tools live in `tools/<name>.clad.toml` and are auto-discovered at startup by `symbi up`, the HTTP Input server, and `symbi tools`. The watcher (`crates/runtime/src/toolclad/watcher.rs`) hot-reloads on file changes — no restart needed.

CLAUDE.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ E2B is a separate hosted-cloud backend, **not** a peer of Tier 1/2/3. Code runs
132132
|---------|-----------|---------------|-----------|
133133
| E2B (hosted) | `with { sandbox = "e2b" }` (DSL only — no `--sandbox` flag) | `E2B_API_KEY` env var | Quick-start demos, evaluation without setting up a sandbox host. **Not for production workloads with privacy or compliance requirements.** |
134134

135+
## Managed CLI agents (Mode B)
136+
137+
An agent whose metadata declares `executor = "claude_code"` is run by spawning a
138+
governed Claude Code subprocess via `crates/runtime/src/cli_executor` (the
139+
`cli-executor` feature, on by default) instead of the ORGA reasoning loop. The
140+
reference agent is `agents/code_reviewer.symbi`; the path lives in
141+
`src/commands/managed_cli.rs`.
142+
143+
`symbi run code_reviewer --target <dir>`:
144+
- passes the spawn through the policy Gate (fail-closed; allow via Cedar or
145+
`SYMBI_INSECURE_ALLOW_ALL=1`);
146+
- injects the env handshake `SYMBIONT_MANAGED=true`, `SYMBIONT_SESSION_ID`,
147+
`SYMBIONT_BUDGET_TOKENS`, `SYMBIONT_BUDGET_TIMEOUT`, `CLAUDE_PROJECT_DIR` (the
148+
symbi-claude-code plugin defers its hooks to the outer Gate on `SYMBIONT_MANAGED`);
149+
- loads the plugin via `--plugin-dir` (resolve order: `--plugin-dir` flag,
150+
`SYMBIONT_CLAUDE_PLUGIN_DIR`, then sibling-repo autodetect) and wires the stdio
151+
`symbi mcp` back-channel via `--mcp-config --strict-mcp-config`;
152+
- bounds the run with `--max-turns` (primary, cooperative) and `--budget-timeout`
153+
(hard wall-clock backstop; `CliExecutor` kills with graceful SIGTERM → SIGKILL).
154+
155+
Do **not** pass `--bare` to the spawned `claude` — it skips reading `~/.claude`
156+
(credentials included) and breaks subscription auth.
157+
135158
## ToolClad Tools
136159

137160
Tools live in `tools/<name>.clad.toml` and are auto-discovered at startup by `symbi up`, the HTTP Input server, and `symbi tools`. The watcher (`crates/runtime/src/toolclad/watcher.rs`) hot-reloads on file changes — no restart needed.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ name = "native-execution-example"
7272
required-features = ["native-sandbox"]
7373

7474
[features]
75-
default = ["cron", "interactive"]
75+
default = ["cron", "interactive", "cli-executor"]
7676
interactive = ["dep:dialoguer"]
7777
cron = ["symbi-runtime/cron", "dep:cron", "dep:chrono", "dep:chrono-tz"]
7878
cedar = ["symbi-runtime/cedar", "dep:cedar-policy"]

agents/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ This directory contains ten reusable agent examples that demonstrate core Symbio
2020
| [SchemaPin Researcher](#schemapin-researcher) | Supply-chain secure research | Only executes cryptographically verified tools and sources |
2121
| [HITL DevOps](#hitl-devops) | Human-in-the-loop infra | Read-only with cryptographic approval for privilege escalation |
2222
| [Code Review Pipeline](#code-review-pipeline) | Multi-agent trust boundary | Untrusted developer + AgentPin-authenticated reviewer |
23+
| [Code Reviewer (Mode B)](#code-reviewer-mode-b) | Governed Claude Code subprocess | `executor = "claude_code"`; spawns Claude Code under CliExecutor with the `SYMBIONT_*` handshake |
24+
25+
### Mode B: Governed Claude Code
26+
27+
**Code Reviewer** (`code_reviewer.symbi`) is the reference *managed-CLI* agent. Its
28+
metadata declares `executor = "claude_code"`, so `symbi run code_reviewer` spawns
29+
a governed Claude Code subprocess via the runtime's `CliExecutor` instead of the
30+
LLM reasoning loop. The spawn passes the policy Gate, sets the `SYMBIONT_MANAGED`
31+
handshake (the symbi-claude-code plugin then defers its hooks to the outer Gate),
32+
and wires the stdio `symbi mcp` back-channel.
33+
34+
```bash
35+
# Allow the spawn at the Gate via a Cedar policy, or for local dev:
36+
SYMBI_INSECURE_ALLOW_ALL=1 symbi run code_reviewer --target /path/to/repo \
37+
--max-turns 12 --budget-timeout 15m
38+
```
39+
40+
`--max-turns` is the primary cooperative bound; `--budget-timeout` is a hard
41+
wall-clock backstop. See the "Mode B" section of `docs/getting-started.md` for the
42+
full env handshake and flag reference.
2343

2444
### v1.8.0 Governance Examples
2545

agents/code_reviewer.symbi

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
metadata {
2+
version = "0.1.0"
3+
author = "Symbiont Community"
4+
description = "Governed code reviewer that runs Claude Code as a Mode B managed subprocess"
5+
tags = ["code-review", "claude-code", "mode-b", "managed", "cli-executor"]
6+
executor = "claude_code"
7+
model = "claude-sonnet-4-5"
8+
allowed_tools = "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(git status:*)"
9+
system_prompt = "You are a senior code reviewer running under Symbiont governance. Review the working tree (prefer the staged and unstaged diff) for correctness bugs, security issues, and clarity problems. Do NOT modify files. Produce a concise, severity-tagged list of findings with file:line references and a one-line summary verdict."
10+
}
11+
12+
# Mode B (ORGA-managed) agent.
13+
#
14+
# `symbi run code_reviewer` reads `executor = "claude_code"` from the metadata
15+
# above and takes the deterministic CliExecutor path instead of the ORGA
16+
# reasoning loop. It spawns one governed Claude Code subprocess with:
17+
# - the SYMBIONT_* env handshake (SYMBIONT_MANAGED=true + budgets + session id)
18+
# - the symbi-claude-code plugin loaded via --plugin-dir, so its hooks detect
19+
# managed mode and defer hard enforcement to the outer Gate
20+
# - the stdio `symbi mcp` back-channel via --mcp-config (--strict-mcp-config)
21+
# - --max-turns as the primary (cooperative) bound; the wall-clock budget
22+
# timeout is a hard backstop with graceful SIGTERM -> SIGKILL termination
23+
#
24+
# The agent body below documents the intended governance; the managed path
25+
# enforces it through the policy Gate + the spawned subprocess flags.
26+
27+
agent code_reviewer(target: String) -> Review {
28+
capabilities = ["read", "analyze"]
29+
30+
policy review_only {
31+
allow: ["read", "analyze"] if true
32+
deny: ["write_data", "execute_code", "network_access"]
33+
34+
require: {
35+
input_sanitization: true
36+
}
37+
38+
audit: {
39+
log_level: "info",
40+
include_findings: true
41+
}
42+
}
43+
44+
with
45+
memory = "ephemeral",
46+
security = "high",
47+
sandbox = "Tier1"
48+
{
49+
return review(target);
50+
}
51+
}

crates/runtime/src/cli_executor/adapters/claude_code.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,55 @@ use crate::sandbox::ExecutionResult;
1818
pub struct ClaudeCodeAdapter {
1919
/// Path or name of the Claude Code executable.
2020
pub executable_path: String,
21-
/// Maximum number of agentic turns before stopping.
21+
/// Maximum number of agentic turns before stopping. This is the *primary*
22+
/// cooperative bound for managed runs — Claude Code exits cleanly with its
23+
/// JSON result when the limit is hit (vs. the wall-clock timeout, which is a
24+
/// hard backstop).
2225
pub max_turns: Option<u32>,
2326
/// Model to use (e.g. "claude-sonnet-4-5-20250929").
2427
pub model: Option<String>,
2528
/// Tools explicitly allowed for this invocation.
2629
pub allowed_tools: Vec<String>,
2730
/// Tools explicitly disallowed for this invocation.
2831
pub disallowed_tools: Vec<String>,
32+
33+
// ── Mode B (governed subprocess) wiring ───────────────────────────────
34+
/// Local plugin directories to load (`--plugin-dir`, repeatable). For Mode B
35+
/// this points at the symbi-claude-code plugin so its hooks fire and defer.
36+
#[serde(default)]
37+
pub plugin_dirs: Vec<String>,
38+
/// MCP config passed via `--mcp-config` (a file path or inline JSON).
39+
#[serde(default)]
40+
pub mcp_config: Option<String>,
41+
/// Emit `--strict-mcp-config` so only `mcp_config` is used.
42+
#[serde(default)]
43+
pub strict_mcp_config: bool,
44+
/// Emit `--bare` to skip auto-discovery (plugins/MCP load only via flags).
45+
#[serde(default)]
46+
pub bare: bool,
47+
/// Permission mode for unattended runs (`--permission-mode`, e.g. "dontAsk").
48+
#[serde(default)]
49+
pub permission_mode: Option<String>,
50+
/// Extra system prompt appended via `--append-system-prompt`.
51+
#[serde(default)]
52+
pub append_system_prompt: Option<String>,
53+
54+
// ── Mode B env handshake (emitted via non_interactive_env) ────────────
55+
/// When true, set `SYMBIONT_MANAGED=true` so the plugin defers enforcement.
56+
#[serde(default)]
57+
pub managed: bool,
58+
/// Correlation id exported as `SYMBIONT_SESSION_ID`.
59+
#[serde(default)]
60+
pub session_id: Option<String>,
61+
/// Token budget exported as `SYMBIONT_BUDGET_TOKENS` (awareness only today).
62+
#[serde(default)]
63+
pub budget_tokens: Option<u64>,
64+
/// Time budget (seconds) exported as `SYMBIONT_BUDGET_TIMEOUT`.
65+
#[serde(default)]
66+
pub budget_timeout_secs: Option<u64>,
67+
/// Project dir exported as `CLAUDE_PROJECT_DIR` so hooks find `.symbiont/`.
68+
#[serde(default)]
69+
pub project_dir: Option<String>,
2970
}
3071

3172
impl Default for ClaudeCodeAdapter {
@@ -36,6 +77,17 @@ impl Default for ClaudeCodeAdapter {
3677
model: None,
3778
allowed_tools: Vec::new(),
3879
disallowed_tools: Vec::new(),
80+
plugin_dirs: Vec::new(),
81+
mcp_config: None,
82+
strict_mcp_config: false,
83+
bare: false,
84+
permission_mode: None,
85+
append_system_prompt: None,
86+
managed: false,
87+
session_id: None,
88+
budget_tokens: None,
89+
budget_timeout_secs: None,
90+
project_dir: None,
3991
}
4092
}
4193
}
@@ -57,6 +109,28 @@ impl AiCliAdapter for ClaudeCodeAdapter {
57109
"json".to_string(),
58110
];
59111

112+
if self.bare {
113+
args.push("--bare".to_string());
114+
}
115+
116+
for dir in &self.plugin_dirs {
117+
args.push("--plugin-dir".to_string());
118+
args.push(dir.clone());
119+
}
120+
121+
if let Some(ref cfg) = self.mcp_config {
122+
args.push("--mcp-config".to_string());
123+
args.push(cfg.clone());
124+
}
125+
if self.strict_mcp_config {
126+
args.push("--strict-mcp-config".to_string());
127+
}
128+
129+
if let Some(ref mode) = self.permission_mode {
130+
args.push("--permission-mode".to_string());
131+
args.push(mode.clone());
132+
}
133+
60134
if let Some(turns) = self.max_turns {
61135
args.push("--max-turns".to_string());
62136
args.push(turns.to_string());
@@ -84,6 +158,11 @@ impl AiCliAdapter for ClaudeCodeAdapter {
84158
args.push(ctx.clone());
85159
}
86160

161+
if let Some(ref extra) = self.append_system_prompt {
162+
args.push("--append-system-prompt".to_string());
163+
args.push(extra.clone());
164+
}
165+
87166
// The prompt is the final positional argument
88167
args.push(request.prompt.clone());
89168

@@ -93,6 +172,27 @@ impl AiCliAdapter for ClaudeCodeAdapter {
93172
fn non_interactive_env(&self) -> HashMap<String, String> {
94173
let mut env = HashMap::new();
95174
env.insert("CI".to_string(), "true".to_string());
175+
176+
// Mode B handshake. These are injected here (the executor merges
177+
// adapter env *unfiltered*, unlike the caller-env ENV_ALLOWLIST) so the
178+
// symbi-claude-code plugin detects managed mode and defers enforcement
179+
// to the outer Gate.
180+
if self.managed {
181+
env.insert("SYMBIONT_MANAGED".to_string(), "true".to_string());
182+
}
183+
if let Some(ref id) = self.session_id {
184+
env.insert("SYMBIONT_SESSION_ID".to_string(), id.clone());
185+
}
186+
if let Some(tokens) = self.budget_tokens {
187+
env.insert("SYMBIONT_BUDGET_TOKENS".to_string(), tokens.to_string());
188+
}
189+
if let Some(secs) = self.budget_timeout_secs {
190+
env.insert("SYMBIONT_BUDGET_TIMEOUT".to_string(), secs.to_string());
191+
}
192+
if let Some(ref dir) = self.project_dir {
193+
env.insert("CLAUDE_PROJECT_DIR".to_string(), dir.clone());
194+
}
195+
96196
env
97197
}
98198

@@ -324,6 +424,66 @@ mod tests {
324424
let adapter = ClaudeCodeAdapter::default();
325425
let env = adapter.non_interactive_env();
326426
assert_eq!(env.get("CI"), Some(&"true".to_string()));
427+
// Unmanaged adapter emits no handshake vars.
428+
assert!(!env.contains_key("SYMBIONT_MANAGED"));
429+
}
430+
431+
#[test]
432+
fn test_build_args_mode_b_flags() {
433+
let adapter = ClaudeCodeAdapter {
434+
bare: true,
435+
plugin_dirs: vec!["/plugins/symbi-claude-code".to_string()],
436+
mcp_config: Some("/tmp/mcp.json".to_string()),
437+
strict_mcp_config: true,
438+
permission_mode: Some("dontAsk".to_string()),
439+
max_turns: Some(12),
440+
append_system_prompt: Some("Review rules.".to_string()),
441+
..Default::default()
442+
};
443+
let args = adapter.build_args(&sample_request());
444+
445+
assert!(args.contains(&"--bare".to_string()));
446+
let pd = args.iter().position(|a| a == "--plugin-dir").unwrap();
447+
assert_eq!(args[pd + 1], "/plugins/symbi-claude-code");
448+
let mc = args.iter().position(|a| a == "--mcp-config").unwrap();
449+
assert_eq!(args[mc + 1], "/tmp/mcp.json");
450+
assert!(args.contains(&"--strict-mcp-config".to_string()));
451+
let pm = args.iter().position(|a| a == "--permission-mode").unwrap();
452+
assert_eq!(args[pm + 1], "dontAsk");
453+
let asp = args
454+
.iter()
455+
.position(|a| a == "--append-system-prompt")
456+
.unwrap();
457+
assert_eq!(args[asp + 1], "Review rules.");
458+
// Prompt stays the final positional arg.
459+
assert_eq!(*args.last().unwrap(), "Fix the bug in main.rs");
460+
}
461+
462+
#[test]
463+
fn test_mode_b_env_handshake() {
464+
let adapter = ClaudeCodeAdapter {
465+
managed: true,
466+
session_id: Some("sess-123".to_string()),
467+
budget_tokens: Some(100_000),
468+
budget_timeout_secs: Some(900),
469+
project_dir: Some("/work/target".to_string()),
470+
..Default::default()
471+
};
472+
let env = adapter.non_interactive_env();
473+
assert_eq!(env.get("SYMBIONT_MANAGED"), Some(&"true".to_string()));
474+
assert_eq!(
475+
env.get("SYMBIONT_SESSION_ID"),
476+
Some(&"sess-123".to_string())
477+
);
478+
assert_eq!(
479+
env.get("SYMBIONT_BUDGET_TOKENS"),
480+
Some(&"100000".to_string())
481+
);
482+
assert_eq!(env.get("SYMBIONT_BUDGET_TIMEOUT"), Some(&"900".to_string()));
483+
assert_eq!(
484+
env.get("CLAUDE_PROJECT_DIR"),
485+
Some(&"/work/target".to_string())
486+
);
327487
}
328488

329489
#[tokio::test]

crates/runtime/src/cli_executor/executor.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ use super::watchdog::OutputWatchdog;
2323
/// sensitive host variables (e.g. credentials, session tokens, internal paths)
2424
/// into spawned AI CLI tool processes. Only variables needed for basic
2525
/// operation and explicitly-required API keys are allowed through.
26+
/// Grace period after SIGTERM before escalating to SIGKILL when terminating a
27+
/// spawned CLI tool. Lets the child (e.g. Claude Code) flush its JSON result
28+
/// and clean up the working tree instead of being hard-killed mid-write.
29+
const KILL_GRACE_PERIOD: Duration = Duration::from_secs(5);
30+
2631
const ENV_ALLOWLIST: &[&str] = &[
2732
"PATH",
2833
"HOME",
@@ -358,10 +363,24 @@ impl CliExecutor {
358363
#[cfg(unix)]
359364
{
360365
if let Some(id) = child.id() {
366+
// Graceful escalation: SIGTERM the process group first so the
367+
// child can flush output and clean up, wait a grace period, then
368+
// SIGKILL only if it is still alive.
361369
// SAFETY: `id` is the PID of a child we spawned (from
362370
// `child.id()`), so the process-group id is valid and owned by
363371
// this process. `killpg` is a plain libc syscall wrapper; a stale
364372
// PID just yields ESRCH, which is harmless here.
373+
unsafe {
374+
libc::killpg(id as i32, libc::SIGTERM);
375+
}
376+
if tokio::time::timeout(KILL_GRACE_PERIOD, child.wait())
377+
.await
378+
.is_ok()
379+
{
380+
return; // exited cleanly after SIGTERM
381+
}
382+
// SAFETY: same invariant as the SIGTERM above; escalate to
383+
// SIGKILL because the child ignored the graceful signal.
365384
unsafe {
366385
libc::killpg(id as i32, libc::SIGKILL);
367386
}

0 commit comments

Comments
 (0)