Skip to content
Closed
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
118 changes: 117 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1 +1,117 @@
@AGENTS.md
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

@AGENTS.md

## Build & Development Commands

All tasks run through `mise`. Run `mise tasks` to list all available tasks.

### Essential commands

| Command | Purpose |
|---------|---------|
| `mise run pre-commit` | Lint, format, license headers, tests — run before every commit |
| `mise run test` | All unit tests (Rust + Python) |
| `mise run ci` | Full local CI: lint + type checks + tests — run before PRs |
| `mise run cluster` | Bootstrap or incremental deploy of the K3s cluster |
| `mise run sandbox` | Create or reconnect to the dev sandbox |
| `mise run e2e` | End-to-end tests (requires a running cluster) |

### Rust-specific commands

```bash
cargo test -p openshell-sandbox # tests for a single crate
cargo test -p openshell-sandbox test_name # single test function
cargo test --workspace --exclude openshell-vm # all tests (vm crate excluded by default)
cargo check --workspace # fast compile check
cargo clippy --workspace --all-targets # lint
cargo fmt --all # format
```

### Python-specific commands

```bash
uv run pytest python/ # Python unit tests
uv run ruff check python/ tasks/scripts/*.py deploy/sbom/*.py # lint
uv run ruff format python/ tasks/scripts/*.py deploy/sbom/*.py # format
uv run ty check python/ tasks/scripts/*.py deploy/sbom/*.py # type check
mise run python:proto # regenerate protobuf stubs
```

### Fast iteration

```bash
mise run cluster:deploy:supervisor # docker cp supervisor binary into cluster (skip rebuild)
mise run cluster:deploy:all # push-mode deploy via local registry
mise run term:dev # TUI with hot-reload on file changes
```

## Toolchain & Environment

- **Rust**: edition 2024, MSRV 1.88, managed by mise
- **Python**: 3.13, managed by mise, always use `uv` (not pip)
- **Node**: 24 (for Fern docs tooling)
- **sccache**: enabled via `RUSTC_WRAPPER` in mise.toml — disk cache at `.cache/sccache`
- **Z3**: required by `openshell-prover`. Install via system package (`brew install z3` / `apt install libz3-dev`) or build with `cargo build -p openshell-prover --features bundled-z3`

## Lint Configuration

Clippy runs with `pedantic` + `nursery` lints enabled. Many noisy lints are allowed at the workspace level (see `Cargo.toml` `[workspace.lints.clippy]`). Notable allows: `module_name_repetitions`, `must_use_candidate`, `missing_errors_doc`, `too_many_lines`, `needless_pass_by_value`. Don't fight these — if clippy passes, you're fine.

## DCO Sign-off

All commits require a `Signed-off-by` line per the Developer Certificate of Origin:

```bash
git commit -s -m "feat(scope): description"
```

## Running the CLI Locally

`scripts/bin/openshell` is a shortcut script that auto-builds `openshell-cli` if needed and runs the debug binary. Because mise adds `scripts/bin` to `PATH`, you can run `openshell` directly:

```bash
openshell --help
openshell sandbox create -- claude
```

`scripts/bin/` also contains `kubectl` and `k9s` wrappers that run inside the active gateway's K3s container — works for both local and remote gateways.

## Architecture at a Glance

All components run inside a single Docker container hosting a K3s Kubernetes cluster:

- **CLI/TUI/SDK** → gRPC over mTLS (port 30051 NodePort) → **Gateway** (openshell-server, port 8080)
- **Gateway** manages sandbox lifecycle via K8s CRDs, persists state in SQLite, tunnels SSH via HTTP CONNECT upgrade at `/connect/ssh`
- **Sandbox pods** (one per sandbox): privileged supervisor runs SSH server + HTTP CONNECT proxy + OPA engine + inference router; agent process runs under Landlock + seccomp + network namespace isolation
- All agent outbound traffic is forced through the proxy (10.200.0.1:3128) via veth pair — OPA evaluates every connection, TLS is auto-terminated for credential injection and optional L7 inspection
- **Inference routing** happens inside the sandbox (not through the gateway) — gateway provides route config and credentials via gRPC, sandbox executes HTTP requests directly to backends

See `architecture/system-architecture.md` for the full diagram and component communication flows.

## Protobuf / gRPC

Proto definitions live in `proto/`. The Rust codegen runs via `tonic-build` at compile time. Python stubs are generated by `mise run python:proto` and live in `python/openshell/_proto/`. If you modify a `.proto` file, regenerate the Python stubs and fix the import rewrites (handled automatically by the `python:proto` task).

## Key Crate Interactions

- `openshell-core` — shared types, config, error handling. Every other crate depends on this.
- `openshell-server` — the gateway. Depends on `openshell-core`, `openshell-policy`, `openshell-ocsf`. Talks to K8s API and SQLite.
- `openshell-sandbox` — the sandbox supervisor. Depends on `openshell-core`, `openshell-policy`, `openshell-ocsf`, `openshell-router`. Talks to gateway via gRPC (`grpc_client.rs`).
- `openshell-policy` — policy types and validation. Used by both server and sandbox.
- `openshell-router` — privacy-aware LLM routing. Embedded in sandbox.
- `openshell-ocsf` — OCSF v1.7.0 structured logging. Used by sandbox and server for security-relevant events.
- `openshell-prover` — Z3-based policy formal verification (separate from runtime enforcement).
- `openshell-cli` — user-facing CLI binary. Depends on `openshell-core`. Talks to gateway via gRPC.
- `openshell-tui` — ratatui terminal dashboard. Polls gateway via gRPC every 2s.
- `openshell-vm` — experimental MicroVM runtime (libkrun). Excluded from default test runs.

## Testing Notes

- E2E tests (`mise run e2e`) require a running cluster — `mise run cluster` first.
- Rust e2e tests live in `e2e/rust/` with their own `Cargo.toml`. Run with `cargo test --manifest-path e2e/rust/Cargo.toml --features e2e`.
- Python e2e tests live in `e2e/python/` and use `pytest-xdist` for parallelism (default 5 workers).
- `gateway_resume_scenarios` e2e tests run in a dedicated CI job — skip locally with `--skip gateway_resume_scenarios`.
- `openshell-vm` is excluded from `cargo test --workspace` — test it separately if modifying.
16 changes: 16 additions & 0 deletions architecture/gateway-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,22 @@ openshell policy set --global --policy policy.yaml --yes

The `--wait` flag is rejected for global policy updates with: `"--wait is not supported for global policies; global policies are effective immediately"`. See `crates/openshell-cli/src/main.rs`.

### `policy set --dry-run`

Validate a policy update without applying it. Runs all validation checks (safety, static field immutability, global policy lock) and returns a diff of network rule changes, but does not persist the revision or notify the sandbox.

```bash
openshell policy set demo --policy policy.yaml --dry-run
openshell policy set --global --policy policy.yaml --dry-run
```

The response shows:
- The version number and hash the policy would receive
- Added network rules (rules present in the new policy but not the current one)
- Removed network rules (rules present in the current policy but not the new one)

The `--wait` flag is incompatible with `--dry-run` (dry-run does not apply the policy, so there is nothing to wait for).

### `policy delete --global [--yes]`

Delete the gateway-global policy, restoring sandbox-level policy control. Removes the `policy` key from the `gateway_settings` blob and supersedes all `__global__` revisions.
Expand Down
25 changes: 19 additions & 6 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,11 @@ enum PolicyCommands {
/// Timeout for --wait in seconds.
#[arg(long, default_value_t = 60)]
timeout: u64,

/// Validate the policy without applying it. Shows validation results
/// and a diff of network rules that would change.
#[arg(long)]
dry_run: bool,
},

/// Show current active policy for a sandbox or the global policy.
Expand Down Expand Up @@ -1965,6 +1970,7 @@ async fn main() -> Result<()> {
yes,
wait,
timeout,
dry_run,
} => {
if global {
if wait {
Expand All @@ -1973,19 +1979,26 @@ async fn main() -> Result<()> {
global policies are effective immediately"
));
}
run::sandbox_policy_set_global(
run::sandbox_policy_set_global(&ctx.endpoint, &policy, yes, dry_run, &tls)
.await?;
} else {
if dry_run && wait {
return Err(miette::miette!(
"--wait is not supported with --dry-run; \
dry-run does not apply the policy"
));
}
let name = resolve_sandbox_name(name, &ctx.name)?;
run::sandbox_policy_set(
&ctx.endpoint,
&name,
&policy,
yes,
wait,
timeout,
dry_run,
&tls,
)
.await?;
} else {
let name = resolve_sandbox_name(name, &ctx.name)?;
run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls)
.await?;
}
}
PolicyCommands::Get {
Expand Down
91 changes: 78 additions & 13 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4148,18 +4148,13 @@ pub async fn sandbox_policy_set_global(
server: &str,
policy_path: &str,
yes: bool,
wait: bool,
_timeout_secs: u64,
dry_run: bool,
tls: &TlsOptions,
) -> Result<()> {
if wait {
return Err(miette::miette!(
"--wait is only supported for sandbox-scoped policy updates"
));
if !dry_run {
confirm_global_setting_takeover("policy", yes)?;
}

confirm_global_setting_takeover("policy", yes)?;

let policy = load_sandbox_policy(Some(policy_path))?
.ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?;

Expand All @@ -4172,19 +4167,47 @@ pub async fn sandbox_policy_set_global(
setting_value: None,
delete_setting: false,
global: true,
dry_run,
})
.await
.into_diagnostic()?
.into_inner();

let hash_display = if response.policy_hash.len() >= 12 {
&response.policy_hash[..12]
} else {
&response.policy_hash
};

if response.dry_run {
eprintln!(
"{} Dry-run: global policy would be configured (hash: {}, settings revision: {})",
"✓".green().bold(),
hash_display,
response.settings_revision,
);
if !response.added_network_rules.is_empty() {
eprintln!(" Network rules to add:");
for rule in &response.added_network_rules {
eprintln!(" + {rule}");
}
}
if !response.removed_network_rules.is_empty() {
eprintln!(" Network rules to remove:");
for rule in &response.removed_network_rules {
eprintln!(" - {rule}");
}
}
if response.added_network_rules.is_empty() && response.removed_network_rules.is_empty() {
eprintln!(" No network rule changes");
}
return Ok(());
}

eprintln!(
"{} Global policy configured (hash: {}, settings revision: {})",
"✓".green().bold(),
if response.policy_hash.len() >= 12 {
&response.policy_hash[..12]
} else {
&response.policy_hash
},
hash_display,
response.settings_revision,
);
Ok(())
Expand Down Expand Up @@ -4371,6 +4394,7 @@ pub async fn gateway_setting_set(
setting_value: Some(setting_value),
delete_setting: false,
global: true,
dry_run: false,
})
.await
.into_diagnostic()?
Expand Down Expand Up @@ -4404,6 +4428,7 @@ pub async fn sandbox_setting_set(
setting_value: Some(setting_value),
delete_setting: false,
global: false,
dry_run: false,
})
.await
.into_diagnostic()?
Expand Down Expand Up @@ -4437,6 +4462,7 @@ pub async fn gateway_setting_delete(
setting_value: None,
delete_setting: true,
global: true,
dry_run: false,
})
.await
.into_diagnostic()?
Expand Down Expand Up @@ -4470,6 +4496,7 @@ pub async fn sandbox_setting_delete(
setting_value: None,
delete_setting: true,
global: false,
dry_run: false,
})
.await
.into_diagnostic()?
Expand Down Expand Up @@ -4500,6 +4527,7 @@ pub async fn sandbox_policy_set(
policy_path: &str,
wait: bool,
timeout_secs: u64,
dry_run: bool,
tls: &TlsOptions,
) -> Result<()> {
let policy = load_sandbox_policy(Some(policy_path))?
Expand Down Expand Up @@ -4527,12 +4555,49 @@ pub async fn sandbox_policy_set(
setting_value: None,
delete_setting: false,
global: false,
dry_run,
})
.await
.into_diagnostic()?;

let resp = response.into_inner();

if resp.dry_run {
if resp.version == current_version {
eprintln!(
"{} Dry-run: policy unchanged (version {}, hash: {})",
"·".dimmed(),
resp.version,
&resp.policy_hash[..12]
);
return Ok(());
}

eprintln!(
"{} Dry-run: policy would become version {} (hash: {})",
"✓".green().bold(),
resp.version,
&resp.policy_hash[..12]
);

if !resp.added_network_rules.is_empty() {
eprintln!(" Network rules to add:");
for rule in &resp.added_network_rules {
eprintln!(" + {rule}");
}
}
if !resp.removed_network_rules.is_empty() {
eprintln!(" Network rules to remove:");
for rule in &resp.removed_network_rules {
eprintln!(" - {rule}");
}
}
if resp.added_network_rules.is_empty() && resp.removed_network_rules.is_empty() {
eprintln!(" No network rule changes");
}
return Ok(());
}

if resp.version == current_version {
eprintln!(
"{} Policy unchanged (version {}, hash: {})",
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod image;
pub mod inference;
pub mod net;
pub mod paths;
pub mod policy_diff;
pub mod proto;
pub mod settings;

Expand Down
Loading
Loading