Skip to main content

leviath_cli/config/
security.rs

1//! `[security]`: what an agent's own code and commands are allowed to reach.
2//!
3//! The section that decides whether a blueprint may loosen a policy, which
4//! environment variables a shell or script can see, and where `[read_paths]`
5//! grants apply. Its defaults are the shipped posture, so a change here is a
6//! change to what a fresh install permits.
7
8use serde::{Deserialize, Serialize};
9
10use super::default_true;
11
12/// One agent's entry in `[agent_read_paths.<name>]`.
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
14pub struct ReadPathGrants {
15    /// Granted entries, same forms as a blueprint's `[read_paths] allow`.
16    #[serde(default)]
17    pub allow: Vec<String>,
18}
19
20/// `[security]` in `~/.leviath/config.toml`.
21///
22/// Distinct from a *blueprint's* `[security]` block, which configures taint
23/// tracking for one agent - this one holds machine-wide switches.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct SecurityConfig {
26    /// Directories a run's workdir may sit under without being confirmed.
27    ///
28    /// **Empty by default**, which means "ask once about anything alarming"
29    /// rather than "ask about everything": a workdir is only questioned when it
30    /// is a home directory or a filesystem root, which is where an agent's file
31    /// writes do the most damage and the least good. Listing a path here says
32    /// "yes, I work there" and silences the prompt for it and everything under
33    /// it.
34    ///
35    /// This exists because the workdir defaults to wherever `lev run` was
36    /// invoked, and running from `~` is an easy thing to do by accident - issue
37    /// #252 is a machine that lost 115 GB to an agent writing under a profile
38    /// root. Confirming is cheap; noticing afterwards is not.
39    #[serde(default)]
40    pub allowed_workdirs: Vec<String>,
41
42    /// Whether a blueprint's `seed = { command = "..." }` regions may run.
43    ///
44    /// **On by default.** A command seed executes at spawn - before the first
45    /// inference, and therefore before any tool-approval prompt - so it is the
46    /// one place a manifest can run something without the user being asked.
47    /// It is still confined to the run's workdir, routed through the entry
48    /// stage's sandbox when the agent declares one, and capped by
49    /// `[limits] script_shell_timeout_secs`. Set this to `false` to refuse them
50    /// machine-wide, or pass `--no-seed-commands` for a single run. Inspect an
51    /// agent's command seeds before installing it with `lev validate <path>`.
52    #[serde(default = "default_true")]
53    pub allow_seed_commands: bool,
54
55    /// Whether agent-driven fetches may reach loopback, private, and link-local
56    /// addresses.
57    ///
58    /// **Off by default.** An agent's `web_fetch` URL is chosen by the model out
59    /// of context an attacker can influence - a search result, a page fetched a
60    /// moment ago, an issue body - so an unrestricted fetch makes the agent a
61    /// confused deputy *inside* the user's network. The concrete targets are
62    /// `http://169.254.169.254/…` (cloud metadata, which returns instance
63    /// credentials), `http://127.0.0.1:3000/api/…` (the user's own `lev serve`),
64    /// and anything on the LAN.
65    ///
66    /// Turn this on when the agent is genuinely meant to talk to something local -
67    /// a self-hosted model, a dev server under test. It applies to the script
68    /// host's `http_get`/`http_post` and to redirect following; see
69    /// [`leviath_net`].
70    #[serde(default)]
71    pub allow_local_network: bool,
72
73    /// Credential-shaped environment variables that agent scripts may read.
74    ///
75    /// A Rhai script tool or script provider calling `env_var("NAME")` gets any
76    /// ordinary variable - `PATH`, `TZ`, an app's own config. A name that *looks
77    /// like a credential* (see [`leviath_core::secrets::is_sensitive_env_name`])
78    /// is refused unless it appears here, because a two-line script tool reading
79    /// `ANTHROPIC_API_KEY` and POSTing it elsewhere was otherwise a working
80    /// exfiltration path with no prompt anywhere in it.
81    ///
82    /// List the exact names a script legitimately needs - typically the key for
83    /// a custom provider script:
84    ///
85    /// ```toml
86    /// [security]
87    /// allow_env_vars = ["MY_PROVIDER_KEY"]
88    /// ```
89    ///
90    /// Matching is case-insensitive and exact. There is no wildcard: `"*"` is
91    /// read as a variable literally named `*`, not as "allow everything".
92    #[serde(default)]
93    pub allow_env_vars: Vec<String>,
94
95    /// How much of the daemon's environment a `shell` tool call, a Rhai
96    /// `shell()` host call, and a region's command seed inherit.
97    ///
98    /// The daemon holds provider keys, `LEVIATH_API_TOKEN`, and whatever
99    /// credentials the person who started it had exported. Handing all of that
100    /// to every shell command means a single `env` in tool output leaks the lot.
101    ///
102    /// `filtered` (the default) withholds credential-shaped names but keeps
103    /// `SSH_AUTH_SOCK`, so `git push` over agent keys still works. `strict`
104    /// drops the carve-out and also takes `AWS_PROFILE`, `KUBECONFIG` and
105    /// friends. `custom` ignores the shape heuristic and withholds exactly what
106    /// [`Self::shell_env_withhold`] names. `inherit` is the old behaviour.
107    ///
108    /// Toolchain variables - `PATH`, `HOME`, `CARGO_HOME`, `JAVA_HOME`,
109    /// `VIRTUAL_ENV`, `NVM_DIR`, `GOPATH`, `DOCKER_HOST` - pass through under
110    /// every mode. [`Self::allow_env_vars`] hands a specific name over under
111    /// every mode too.
112    #[serde(default)]
113    pub shell_env: leviath_core::ShellEnvMode,
114
115    /// The names `shell_env = "custom"` withholds. Ignored under every other
116    /// mode, where the name-shape heuristic decides instead.
117    #[serde(default)]
118    pub shell_env_withhold: Vec<String>,
119
120    /// Whether a blueprint's `[read_paths]` declarations are honored as-is.
121    ///
122    /// **Off by default.** A `[read_paths]` block travels inside the
123    /// `agent.leviath` you installed, and a manifest may only *tighten* what
124    /// your config allows, never widen it - otherwise any agent package could
125    /// read `~/.ssh`, this very config file (your API keys), or a password
126    /// store by shipping one TOML line. With this off, an agent's declared
127    /// read paths are inert until you grant them via [`Self::read_paths`] or
128    /// `[agent_read_paths.<name>]`. Turning it on says "any blueprint I run
129    /// may read every path it declares" - reads only, each access still
130    /// resolves symlinks and must land inside a declared entry, but prefer
131    /// the per-agent grant for anything you did not author yourself.
132    #[serde(default)]
133    pub allow_blueprint_read_paths: bool,
134
135    /// Honour every blueprint's own `[safe_commands]` block.
136    ///
137    /// **Off by default**, and for the same reason as
138    /// [`Self::allow_blueprint_read_paths`]: a `[safe_commands]` block travels
139    /// inside an `agent.leviath` you installed, so letting it count by itself
140    /// would let any agent package pre-approve its own shell with one TOML line.
141    /// With this off, a blueprint's list is inert until you opt in, either here
142    /// for every agent or per agent via
143    /// `[agent_safe_commands.<name>] allow_blueprint = true`. Prefer the
144    /// per-agent grant for anything you did not author yourself.
145    #[serde(default)]
146    pub allow_blueprint_safe_commands: bool,
147
148    /// Honour a blueprint's `[tool_permissions]` even where it is *more*
149    /// permissive than the built-in default for a tool you have not configured.
150    ///
151    /// Off by default, for the same reason as the two switches around it:
152    /// declaring is not granting. Saying nothing about `shell` is the normal
153    /// state, so without this a downloaded manifest could give itself
154    /// `shell = "allow"` on a stock machine. With it off, a blueprint may still
155    /// pre-approve the read-only web tools that are some agents' whole point,
156    /// and anything beyond that is clamped to the built-in default.
157    ///
158    /// The per-agent grant needs no switch of its own: naming the tool under
159    /// `[agent_tool_permissions.<name>]` makes it a ceiling for that agent, and
160    /// a blueprint may go up to a ceiling. Prefer that for anything you did not
161    /// author yourself - it says which agent and which tool, where this says
162    /// "every agent, every tool".
163    #[serde(default)]
164    pub allow_blueprint_permissions: bool,
165
166    /// Machine-wide read grants for agents that declare `[read_paths]`.
167    ///
168    /// Entries use the same three forms as a blueprint's `[read_paths] allow`:
169    /// an exact path (grants its subtree), `glob:` and `regex:` patterns
170    /// (matched against the symlink-resolved real path, written with `/` on
171    /// every OS, regex auto-anchored). `~/` expands to your home; a relative
172    /// entry resolves against the run's workdir.
173    ///
174    /// ```toml
175    /// [security]
176    /// read_paths = ["~/.leviath/runs", "glob:~/design-docs/**"]
177    /// ```
178    ///
179    /// A grant only takes effect for a path the running blueprint *also*
180    /// declares - by itself it grants nothing, so listing a directory here
181    /// does not open it to agents that never asked.
182    #[serde(default)]
183    pub read_paths: Vec<String>,
184
185    /// Where provider API keys and MCP OAuth tokens are kept.
186    ///
187    /// **`file` by default** - `~/.leviath/config.toml` and
188    /// `~/.leviath/mcp-auth.json`, both created `0600` so they are never even
189    /// briefly world-readable. This is what Claude Code and Codex do, and it is
190    /// the only backend that works headless, in a container, over SSH, and on a
191    /// CI runner.
192    ///
193    /// Set it to `keychain` to move secrets into the OS credential store (macOS
194    /// Keychain, Windows Credential Manager, Secret Service elsewhere), so a
195    /// stolen `~/.leviath` directory yields nothing:
196    ///
197    /// ```toml
198    /// [security]
199    /// credential_store = "keychain"
200    /// ```
201    ///
202    /// Then run `lev auth migrate` to move the secrets you already have. It is
203    /// opt-in rather than the default because an unavailable keychain is not a
204    /// degraded experience but a broken one - every inference fails at once -
205    /// and the environments Leviath is most useful in are the least likely to
206    /// have a working credential store. `lev auth status` reports whether this
207    /// machine actually has one.
208    #[serde(default)]
209    pub credential_store: leviath_core::CredentialStoreKind,
210}
211
212impl Default for SecurityConfig {
213    fn default() -> Self {
214        Self {
215            allowed_workdirs: Vec::new(),
216            allow_seed_commands: true,
217            allow_local_network: false,
218            allow_env_vars: Vec::new(),
219            shell_env: leviath_core::ShellEnvMode::default(),
220            shell_env_withhold: Vec::new(),
221            allow_blueprint_read_paths: false,
222            allow_blueprint_safe_commands: false,
223            allow_blueprint_permissions: false,
224            read_paths: Vec::new(),
225            credential_store: leviath_core::CredentialStoreKind::File,
226        }
227    }
228}