Security
OpenKoi runs locally with full filesystem access -- the agent operates with the same permissions as your user account. Security boundaries exist between the agent core, plugins (WASM sandboxed), external tool servers (MCP subprocess), and user scripts (Rhai). The guiding principle: the agent has the user's permissions, plugins do not.
This page covers every layer of the security model: trust levels, credential storage, sandboxing, and the safety guardrails that prevent accidental damage.
Trust Levels
OpenKoi uses a nested trust model. Each layer has strictly less access than the one above it.
+---------------------------------------------------------------+
| User trust level |
| +----------------------------------------------------------+ |
| | Agent core (full filesystem, network, shell) | |
| | +-----------------------------------------------------+ | |
| | | MCP servers (subprocess, inherit env selectively) | | |
| | +-----------------------------------------------------+ | |
| | | Rhai scripts (no I/O unless exposed by host) | | |
| | +-----------------------------------------------------+ | |
| | | WASM plugins (sandboxed, explicit capabilities) | | |
| | +-----------------------------------------------------+ | |
| +----------------------------------------------------------+ |
+---------------------------------------------------------------+| Layer | Trust Level | Filesystem | Network | Shell | Notes |
|---|---|---|---|---|---|
| Agent core | Full | Yes | Yes | Yes | Runs as $USER. Same permissions as your terminal session. |
| MCP servers | High (inherited) | Inherited | Inherited | N/A | Separate child process. Env vars passed selectively via config. |
| Rhai scripts | Medium | No (unless exposed) | No (unless exposed) | No | Pure computation plus host-exposed functions only. |
| WASM plugins | Low (explicit caps) | Explicit grants | Explicit grants | No | wasmtime sandbox. Must declare capabilities in a manifest. |
The agent core has no restrictions beyond what the operating system enforces on your user account. Every layer below the core is progressively more restricted, with WASM plugins being the most constrained.
Credential Storage
All credentials are stored under ~/.openkoi/credentials/ using plain files protected by filesystem permissions. This is the same proven approach used by SSH keys, GPG keyrings, and most CLI tools.
Directory Layout
~/.openkoi/
credentials/ # chmod 700 (rwx------)
providers.json # API keys for model providers (chmod 600)
integrations.json # OAuth tokens for Slack, Notion, etc. (chmod 600)
anthropic.key # Individual key files (chmod 600)
openai.key # Individual key files (chmod 600)
...Permission Model
| Path | Permission | Meaning |
|---|---|---|
~/.openkoi/credentials/ | chmod 700 (rwx------) | Only the owner can list or enter the directory. |
*.key files | chmod 600 (rw-------) | Only the owner can read or write. |
providers.json | chmod 600 (rw-------) | Only the owner can read or write. |
integrations.json | chmod 600 (rw-------) | Only the owner can read or write. |
Design Decisions
- No encryption at rest. API keys are stored as plaintext, not base64-encoded or obfuscated. Obfuscation without real encryption is security theater. The protection boundary is filesystem permissions, the same as SSH keys in
~/.ssh/. - Permissions set on first write. When OpenKoi saves a credential for the first time, it immediately sets
chmod 600on the file andchmod 700on the directory. - Warn on read if permissions are wrong. If OpenKoi detects that a credential file has overly permissive access (e.g., world-readable), it logs a warning and offers to fix it.
Implementation
use std::os::unix::fs::PermissionsExt;
const CRED_FILE_MODE: u32 = 0o600; // rw-------
const CRED_DIR_MODE: u32 = 0o700; // rwx------
pub async fn save_credential(provider: &str, key: &str) -> Result<()> {
let creds_dir = config_dir().join("credentials");
fs::create_dir_all(&creds_dir).await?;
fs::set_permissions(&creds_dir, Permissions::from_mode(CRED_DIR_MODE)).await?;
let key_path = creds_dir.join(format!("{provider}.key"));
fs::write(&key_path, key).await?;
fs::set_permissions(&key_path, Permissions::from_mode(CRED_FILE_MODE)).await?;
Ok(())
}Permission Auditing
The openkoi doctor command includes a dedicated permissions check that audits all credential files, the config directory, and the data directory.
What openkoi doctor Checks
$ openkoi doctor
Config: ~/.openkoi/config.toml (loaded)
Database: ~/.local/share/openkoi/openkoi.db (12MB, 1,247 entries)
Credentials: ~/.openkoi/credentials/ (permissions ok)
Providers: anthropic (ok), ollama (ok), openai (key expired)
MCP: github (ok, 12 tools), filesystem (ok, 5 tools)
Skills: 34 active, 2 proposed
Integrations: slack (ok), notion (token expired)
Disk: 47MB total
Permissions: all ok
Issues:
! OpenAI API key expired. Run: openkoi init
! Notion token expired. Run: openkoi connect notionWhen a permission issue is detected, the doctor reports it with a clear fix suggestion:
Issues:
! ~/.openkoi/credentials/providers.json has mode 644 (should be 600)
Fix: chmod 600 ~/.openkoi/credentials/providers.json
! ~/.openkoi/credentials/ has mode 755 (should be 700)
Fix: chmod 700 ~/.openkoi/credentials/Automated Permission Fixing
OpenKoi provides two functions for programmatic permission repair:
| Function | Scope | Behavior |
|---|---|---|
fix_permissions(path) | Single file or directory | Sets the correct mode for the given path. |
fix_all_permissions() | Entire credentials directory | Walks ~/.openkoi/credentials/ and fixes every file and the directory itself. |
Both functions log what they changed so the user has a clear audit trail.
WASM Sandbox
WASM plugins are the most restricted execution environment. They run inside the wasmtime sandbox and have zero capabilities by default. Every capability must be explicitly declared in the plugin's manifest and approved by the user on first install.
Capability Declaration
Each WASM plugin ships with a plugin.toml manifest:
[plugin]
name = "my-custom-plugin"
version = "0.1.0"
[capabilities]
filesystem = ["read:~/.config/my-app/*"] # Path glob with access mode
network = ["https://api.example.com/*"] # URL pattern scoped
environment = ["MY_APP_TOKEN"] # Specific env var names onlyCapability Types
| Capability | Declaration | What It Grants |
|---|---|---|
| Filesystem | Path globs with read:, write:, or readwrite: prefix | Access to matching paths only. All other paths are invisible to the plugin. |
| Network | URL patterns (scheme + host + path glob) | HTTP requests to matching URLs only. All other destinations are blocked. |
| Environment | Explicit variable names | Access to specific env vars. All others return empty. |
Approval Flow
On first install of a WASM plugin, OpenKoi shows the requested capabilities and asks for confirmation:
$ openkoi plugin install my-custom-plugin.wasm
Plugin: my-custom-plugin v0.1.0
Requested capabilities:
Filesystem: read ~/.config/my-app/*
Network: https://api.example.com/*
Env vars: MY_APP_TOKEN
[a]pprove [d]eny [v]iew source
> a
Installed and approved.Once approved, the capabilities are stored. The plugin runs with those grants on every subsequent invocation. If the plugin updates its manifest to request additional capabilities, the user is prompted again.
Sandbox Enforcement
impl WasmSandbox {
pub fn instantiate(
wasm_bytes: &[u8],
caps: &WasmCapabilities,
) -> Result<WasmInstance> {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let mut linker = Linker::new(&engine);
// Only link host functions that match declared capabilities
if !caps.filesystem.is_empty() {
link_fs_functions(&mut linker, &caps.filesystem)?;
}
if !caps.network.is_empty() {
link_network_functions(&mut linker, &caps.network)?;
}
// Env vars: only expose declared ones
let filtered_env: HashMap<String, String> = caps.environment.iter()
.filter_map(|k| std::env::var(k).ok().map(|v| (k.clone(), v)))
.collect();
let store = Store::new(&engine, SandboxState { env: filtered_env });
let instance = linker.instantiate(&mut store, &component)?;
Ok(WasmInstance { store, instance })
}
}MCP Server Isolation
MCP (Model Context Protocol) servers run as separate child processes that communicate exclusively over stdin/stdout using JSON-RPC. This provides natural process-level isolation.
Isolation Properties
| Property | Detail |
|---|---|
| Process boundary | Each MCP server is a separate OS process. No shared memory with the agent. |
| Communication channel | stdin/stdout only (JSON-RPC over stdio). No filesystem sharing. |
| Crash isolation | If a server crashes, only that tool becomes unavailable. The agent continues normally. |
| Timeout | Default 30-second timeout per tool call. Configurable per server. |
| Environment | Env vars are passed selectively, not inherited wholesale. |
Configuration
# ~/.openkoi/config.toml
[[mcp.servers]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
[[mcp.servers]]
name = "github"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "${GITHUB_TOKEN}" } # Only pass this specific env var
[[mcp.servers]]
name = "postgres"
command = "mcp-server-postgres"
env = { DATABASE_URL = "postgres://..." }
timeout_seconds = 60 # Override default 30s timeoutThe env field controls exactly which environment variables a given MCP server receives. If omitted, the server inherits the agent's full environment. Specifying env restricts the server to only the listed variables.
Lifecycle
- Startup: All configured servers are spawned on session start. Each server goes through an
initializehandshake that exchanges capabilities and lists available tools. - Tool calls: The agent routes tool calls to the correct server by namespace prefix (e.g.,
github__create_issue). - Shutdown: On session end, OpenKoi sends a shutdown notification to each server, waits briefly, then terminates the process.
Rhai Script Safety
Rhai is an embedded scripting language designed for safe hosting. By default, Rhai scripts in OpenKoi have no I/O capabilities -- no filesystem access, no network access, no shell execution, and no environment variable access.
Default Restrictions
| Capability | Available by Default | How to Enable |
|---|---|---|
| Logging | No | Set allow_log = true in script config |
| HTTP requests | No | Set allow_http = true in script config |
| Filesystem | No | Not available (use WASM or MCP for I/O) |
| Shell execution | No | Not available |
| Environment vars | No | Not available |
| Memory search | No | Host must expose search_memory function |
| Send messages | No | Host must expose send_message function |
Host-Exposed Functions
The OpenKoi host selectively registers functions into the Rhai engine based on configuration:
pub fn create_rhai_engine(exposed: &RhaiExposedFunctions) -> Engine {
let mut engine = Engine::new();
// Only expose what the user has configured
if exposed.allow_log {
engine.register_fn("log", |msg: &str| {
info!(target: "rhai", "{}", msg);
});
}
if exposed.allow_http {
engine.register_fn("http_get", |url: &str| -> Result<String, Box<EvalAltResult>> {
// Runs through a URL-pattern filter (same as WASM plugins)
Ok(blocking_http_get(url)?)
});
}
engine
}URL-Pattern Filtering
When allow_http is enabled, HTTP requests from Rhai scripts are still filtered through a URL-pattern allowlist. This prevents scripts from making requests to arbitrary destinations. The allowlist uses the same pattern format as WASM plugin network capabilities.
Safety Guardrails
OpenKoi includes multiple layers of circuit breakers and safety limits to prevent runaway execution, excessive cost, and accidental damage.
SafetyConfig
The safety configuration controls hard limits on iteration, cost, and time:
pub struct SafetyConfig {
pub max_iterations: u8, // Default: 3
pub max_tokens: u32, // Default: 200_000
pub max_duration: Duration, // Default: 5 minutes
pub max_cost_usd: f64, // Default: $2.00
pub abort_on_regression: bool, // Default: true
pub regression_threshold: f32, // Default: 0.2
}| Limit | Default | What Happens When Exceeded |
|---|---|---|
max_iterations | 3 | Iteration loop stops. Returns best result so far. |
max_tokens | 200,000 | BudgetExceeded error. Task aborts with partial result. |
max_duration | 5 minutes | AbortTimeout decision. Returns best result so far. |
max_cost_usd | $2.00 | Hard stop. No bypass without explicit --budget increase. |
abort_on_regression | true | If score drops by more than regression_threshold, abort immediately. |
regression_threshold | 0.2 | A 0.2 drop (e.g., 0.85 to 0.65) triggers ScoreRegression abort. |
Tool Loop Detection
The tool loop detector prevents the agent from calling the same tool repeatedly in a pattern that indicates it is stuck. Three thresholds escalate the response:
| Threshold | Count | Action |
|---|---|---|
| Warning | 10 calls | Log a warning. Agent is informed but continues. |
| Critical | 20 calls | Log a critical warning. Agent is strongly urged to stop. |
| Circuit breaker | 30 calls | Hard stop. ToolLoop error raised. Task aborts. |
# config.toml
[safety.tool_loop]
warning = 10
critical = 20
circuit_breaker = 30These thresholds are configurable. For tasks that legitimately require many tool calls (e.g., batch file processing), you can raise the limits.
Destructive Operation Guardrails
The agent has full filesystem and shell access, but built-in guardrails add a confirmation step before executing operations that could cause irreversible damage.
Guardrail Rules
| Operation Pattern | Guardrail |
|---|---|
rm -rf, rm -r, destructive file deletion | Pause and confirm with user before executing. |
DROP TABLE, DROP DATABASE, destructive SQL | Pause and confirm with user before executing. |
git push --force, git reset --hard | Pause and confirm with user before executing. |
Cost exceeds session budget (max_cost_usd) | Hard stop. No bypass without --budget increase. |
Unknown binary execution (not in $PATH or project) | Warn before executing. |
| Credential detected in output | Redact before displaying. Warn user. |
How Confirmation Works
When the agent encounters a destructive operation, it pauses execution and presents the command to the user:
The agent wants to execute a potentially destructive command:
git push --force origin main
[a]llow [d]eny [v]iew context
> d
Denied. The agent will attempt an alternative approach.Credential Redaction
If the agent's output contains what appears to be an API key or secret (detected via pattern matching for common key formats), OpenKoi redacts it before displaying:
Output contains a potential credential:
sk-ant-api03-****...****
The value has been redacted from the display.Security Summary
OpenKoi's security model is defense-in-depth. The primary boundary is that the agent runs as you -- it can do anything you can do from a terminal. Everything below that is layered protection:
- WASM plugins are fully sandboxed with explicit capability grants.
- MCP servers are process-isolated with selective env var passing.
- Rhai scripts have zero I/O by default with opt-in function exposure.
- Safety guardrails prevent runaway cost, time, and iteration.
- Destructive operation detection adds human confirmation for dangerous commands.
- Credential storage uses filesystem permissions (the same model as SSH keys).
- Permission auditing via
openkoi doctorcatches misconfigurations.
These are safeguards against accidents, not security against a hostile agent. The design assumes the agent is acting in good faith on behalf of the user, and the guardrails exist to catch the inevitable mistakes that any automated system will make.
