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}