Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions shell/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ iii -c ./config.yaml

| flag | default | purpose |
|------|---------|---------|
| `--config <path>` | `./config.yaml` | Optional seed config: the YAML is passed as `initial_value` when registering the schema with the `configuration` worker on first boot. It is **not** the live source of truth — the live value is fetched over RPC after registration. |
| `--config <path>` | `./config.yaml` | Optional seed config: the YAML is passed as `initial_value` when registering the schema with the `configuration` worker on first boot. It is **not** the live source of truth — the live value is fetched over RPC after registration. When the file is absent and nothing is stored yet, the worker seeds a built-in zero-config default (`ShellConfig::seed_default()`, jailed to `/tmp`) instead. |
| `--url <ws-url>` | `ws://127.0.0.1:49134` | iii engine WebSocket |

## Configuration

The shell worker integrates with the central `configuration` worker rather than reading a static file at runtime:

1. On boot it registers a schema with id `shell`; the YAML at `--config <path>` (default `./config.yaml`) is sent as the `initial_value` (populates the first-boot default). If the file is missing or unreadable the worker warns and continues without a seed.
1. On boot it registers a schema with id `shell`; the YAML at `--config <path>` (default `./config.yaml`) is sent as the `initial_value` (populates the first-boot default). If the file is missing or unreadable the worker warns and, when no value is stored yet, seeds the built-in zero-config default (`ShellConfig::seed_default()`) so it still boots.
2. It immediately fetches the live value over RPC and activates the security policy and fs backend from that response.
3. It then registers the `configuration:updated` trigger and runs a **fail-closed** boot reconcile before exposing any public function. The reconcile re-fetches the authoritative value (closing the race where an update lands between the initial fetch and trigger registration, leaving no listener). If that re-fetch fails the worker aborts startup — it exits rather than serve a possibly stale security policy, and no `shell::*` / `shell::fs::*` function is ever exposed.
4. It subscribes to `configuration:updated` events. When the config for schema id `shell` changes, the worker hot-reloads the security policy and fs backend atomically.
Expand Down
4 changes: 4 additions & 0 deletions shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ sandbox:
enabled: true # false -> every target: sandbox call returns S210
```

### Zero-config default

With no `--config` file and no value stored in the `configuration` worker, the worker seeds a built-in default on first registration — so it boots with nothing configured (database-style). That built-in default is the shipped [`config.yaml`](config.yaml): jailed to `/tmp`, env forwarded, open exec with a catastrophic-only denylist (kept in sync by a unit test). If the stored value is later nulled, the worker does not silently fall back to this seed: boot fails closed and a hot-reload keeps the last-good config. A config that is *present* but leaves `fs.host_root` unset (without `fs.allow_unjailed: true`) also fails closed.

Host `shell::exec` is not a security boundary: any allowlisted interpreter (`sh`, `node`, `python3`) can construct a denylisted token at runtime and bypass the regex. Run untrusted input with `target: { kind: "sandbox", sandbox_id }`, which forwards through the `iii-sandbox` microVM. The allowlist and denylist still apply on top of either backend.

### Per-call `cwd`, `env`, and `stdin` (host target)
Expand Down
49 changes: 49 additions & 0 deletions shell/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,40 @@ impl Default for ShellConfig {
}
}

impl ShellConfig {
/// The bootable, zero-config default: seeded as `initial_value` on first
/// registration and used as the runtime fallback when the stored value is
/// null, so the worker boots with no config file at all (database-style
/// zero-config). This is deliberately NOT `Default::default()` — that is
/// unjailed (`host_root: None`) so an operator config that omits the jail
/// fails closed. This seed is the shipped permissive dev default: jailed to
/// `/tmp`, env forwarded, open exec with a catastrophic-only denylist. It is
/// kept in sync with `config.yaml` by a unit test.
pub fn seed_default() -> Self {
Self {
max_timeout_ms: 120_000,
inherit_env: true,
denylist_patterns: vec![
r"rm\s+-rf\s+/".into(),
r":\(\)\s*\{\s*:\|".into(),
"mkfs".into(),
r"dd\s+if=".into(),
"shutdown".into(),
"reboot".into(),
"/etc/shadow".into(),
],
fs: FsConfig {
host_root: Some(PathBuf::from("/tmp")),
max_read_bytes: 16_777_216,
max_write_bytes: 16_777_216,
denylist_paths: vec![PathBuf::from("/etc/passwd"), PathBuf::from("/etc/shadow")],
..FsConfig::default()
},
..Self::default()
}
}
}

impl ShellConfig {
pub fn compile_denylist(&mut self) -> Result<()> {
self.compiled_denylist = self
Expand Down Expand Up @@ -459,6 +493,21 @@ mod tests {
assert!(err.contains("denylist"), "got: {err}");
}

/// `seed_default()` is the in-code twin of the shipped `config.yaml` — the
/// file the registry publishes and that `cargo run` loads. If they drift, a
/// zero-config boot and a `config.yaml` boot would diverge silently.
#[test]
fn seed_default_matches_shipped_config_yaml() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml");
let content = std::fs::read_to_string(path).expect("read config.yaml");
let from_file: ShellConfig = serde_yaml::from_str(&content).expect("config.yaml parses");
assert_eq!(
from_file.to_json(),
ShellConfig::seed_default().to_json(),
"config.yaml and ShellConfig::seed_default() must stay in sync"
);
}

#[test]
fn exec_command_path_inside_jail_is_rejected() {
// An agent can plant `<host_root>/ls` (0755) via shell::fs::write; the
Expand Down
63 changes: 47 additions & 16 deletions shell/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,42 +129,66 @@ pub fn build_runtime(cfg: &ShellConfig, iii: &III) -> Result<ShellRuntime, Strin
}

/// Register the `shell` configuration schema with the configuration worker.
///
/// `initial_value` (used only on first registration; preserved afterwards) is
/// taken from an explicit `--config` seed when given, otherwise from the
/// built-in zero-config default when no value is stored yet — so the worker
/// boots with no config file at all (database parity). Unlike database we
/// cannot seed `ShellConfig::default()`: it is intentionally unjailed/invalid,
/// so the built-in seed is `ShellConfig::seed_default()`, a bootable permissive
/// dev default.
pub async fn register_config(iii: &III, seed: Option<&ShellConfig>) -> Result<(), String> {
let mut payload = json!({
"id": CONFIG_ID,
"name": "Shell",
"description": "Command allowlist/denylist, timeout & output caps, and the fs jail.",
"schema": ShellConfig::json_schema(),
});
// Only an explicit --config seed is written as initial_value. We never seed
// the built-in ShellConfig::default(): it is intentionally invalid (unjailed;
// rejected by prepare_config), so writing it would leave the stored `shell`
// config stuck in a self-invalid state. With no seed and no stored value the
// worker fails closed at build_runtime with a clear fs-jail error instead.
if let Some(seed) = seed {
// Validate the seed with the SAME checks build_runtime uses (denylist
// regex compile, fs-jail rule, host_root/denylist reachability) BEFORE
// persisting it. Persisting an invalid seed would store a value the
// worker then rejects; because configuration::register preserves the
// value after first registration, a one-line typo would become a
// persistent outage. If the seed is invalid, register the schema only
// and let the worker fail closed with a clear error.
match build_runtime(seed, iii) {
Ok(_) => payload["initial_value"] = seed.to_json(),
let candidate: Option<ShellConfig> = match seed {
Some(s) => Some(s.clone()),
None if should_seed_default_value(iii).await? => Some(ShellConfig::seed_default()),
None => None,
};
if let Some(cfg) = &candidate {
// Validate with the SAME checks build_runtime uses (denylist regex
// compile, fs-jail rule, host_root/denylist reachability) BEFORE
// persisting. configuration::register preserves the value after first
// registration, so a one-line typo in --config — or an unbootable
// built-in seed — would become a persistent outage. If invalid, register
// the schema only and let the worker fail closed with a clear error.
match build_runtime(cfg, iii) {
Ok(_) => payload["initial_value"] = cfg.to_json(),
Err(e) => tracing::error!(
error = %e,
"ignoring invalid --config seed; not registering it as initial_value"
"ignoring invalid config seed; not registering it as initial_value"
),
}
}
trigger_with_retry(iii, "configuration::register", payload).await?;
Ok(())
}

/// Seed the built-in default only when nothing is stored yet — never overwrite
/// an operator's persisted value.
async fn should_seed_default_value(iii: &III) -> Result<bool, String> {
match try_get_config_value(iii).await? {
None => Ok(true),
Some(value) if value.is_null() => Ok(true),
Some(_) => Ok(false),
}
}

/// Read the live `shell` configuration (env-expanded by the configuration worker).
pub async fn fetch_config(iii: &III) -> Result<ShellConfig, String> {
let value = get_config_value(iii).await?;
if value.is_null() {
// Null means register_config did not seed (its seed_default failed
// validation) or the stored value was nulled at runtime. Fall back to
// the unjailed Default::default(), which build_runtime rejects: boot
// fails closed, and a steady-state hot-reload keeps last-good rather
// than silently widening the live jail to the /tmp seed default. Boot
// zero-config comes from register_config seeding seed_default(), not
// from this fallback.
tracing::warn!(
"configuration value is null; falling back to the built-in default, which is \
intentionally invalid (unjailed) and is rejected by build_runtime — boot \
Expand Down Expand Up @@ -391,6 +415,13 @@ mod tests {
assert!(err.contains("fs jail"));
}

#[test]
fn prepare_config_accepts_seed_default() {
// The zero-config seed must boot (jailed to /tmp) — that is the whole
// point of seeding it instead of the unjailed Default::default().
prepare_config(&ShellConfig::seed_default()).expect("seed_default boots");
}

#[test]
fn prepare_config_accepts_pinned_host_root() {
let mut c = ShellConfig::default();
Expand Down
2 changes: 1 addition & 1 deletion shell/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ async fn main() -> Result<()> {
Some(cfg)
}
Err(e) => {
tracing::warn!(path = %cli.config, error = %e, "failed to load seed config; relying on existing configuration entry");
tracing::warn!(path = %cli.config, error = %e, "could not load --config seed; using the stored configuration value if present, else the built-in zero-config default");
None
}
};
Expand Down
Loading