safe-chains 0.221.0

Auto-allow safe bash commands in agentic coding tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use sha2::{Digest, Sha256};

use super::build::{insert_spec, load_toml};
use super::types::CommandSpec;

const REPO_FILENAME: &str = ".safe-chains.toml";
const USER_FILENAME: &str = "safe-chains.toml";

#[derive(Deserialize)]
struct TrustedEntry {
    path: String,
    sha256: String,
}

#[derive(Deserialize)]
struct TrustedConfig {
    #[serde(default)]
    trusted: Vec<TrustedEntry>,
    /// The user's chosen auto-approve CEILING (`level = "network-admin"`). Read ONLY from the
    /// write-protected user config (`~/.config/safe-chains.toml`) — never from a repo
    /// `.safe-chains.toml`, which the agent can write (raising a ceiling from a checked-out repo is
    /// exactly the self-escalation the config-write freeze prevents). Absent → the default band.
    #[serde(default)]
    level: Option<String>,
}

/// Walk up from CWD looking for a project-level custom TOML.
fn find_repo_custom() -> Option<PathBuf> {
    let mut dir = env::current_dir().ok()?;
    loop {
        let candidate = dir.join(REPO_FILENAME);
        if candidate.is_file() {
            return Some(candidate);
        }
        if !dir.pop() {
            return None;
        }
    }
}

/// `~/.config/safe-chains.toml` — the ONLY user-config location. `XDG_CONFIG_HOME` is
/// deliberately NOT honored: it's an agent-mutable env var, and if a harness ever passed the
/// agent's environment to the hook, a redirected `XDG_CONFIG_HOME` could point the trust root at
/// an agent-writable directory (plant a "grant everything" config, load it as trusted). Reading
/// only from the real home directory closes that off — a common stance for a security-sensitive
/// CLI. Trades away XDG relocation until a protected third-party config location exists.
fn find_user_custom() -> Option<PathBuf> {
    let dir = env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))?;
    let candidate = dir.join(USER_FILENAME);
    candidate.is_file().then_some(candidate)
}

fn parse_trusted(source: &str) -> Vec<TrustedEntry> {
    toml::from_str::<TrustedConfig>(source)
        .map(|c| c.trusted)
        .unwrap_or_default()
}

fn parse_level(source: &str) -> Option<String> {
    toml::from_str::<TrustedConfig>(source).ok()?.level
}

/// The `level = "…"` ceiling from the USER config (`~/.config/safe-chains.toml`) only — the
/// write-protected location an agent cannot rewrite. Returns the raw name (the caller validates it
/// against the known levels; an unknown name falls back to the default band). `None` when no config,
/// no `level`, or local config is disabled (`SAFE_CHAINS_NO_LOCAL`). The repo file (`find_repo_custom`)
/// is NEVER consulted here — a repo `.safe-chains.toml` cannot raise the ceiling (the agent writes it).
pub(crate) fn user_config_level() -> Option<String> {
    if env::var_os("SAFE_CHAINS_NO_LOCAL").is_some() {
        return None;
    }
    let path = find_user_custom()?;
    let source = fs::read_to_string(&path).ok()?;
    parse_level(&source)
}

fn sha256_hex(bytes: &[u8]) -> String {
    Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}

/// A repo `.safe-chains.toml` is honored only when the user has pinned its
/// directory in the user config and the file's hash matches the pin. The
/// directory the agent works in is otherwise untrusted — it can write the file
/// freely, so reading it on sight would let an agent approve any command by
/// editing the file first. See `docs/design/trusted-customization.md`.
fn repo_is_trusted(repo_file: &Path, bytes: &[u8], trusted: &[TrustedEntry]) -> bool {
    let Some(parent) = repo_file.parent() else {
        return false;
    };
    let Ok(dir) = fs::canonicalize(parent) else {
        return false;
    };
    let hash = sha256_hex(bytes);
    trusted.iter().any(|t| {
        t.sha256.trim().eq_ignore_ascii_case(&hash)
            && fs::canonicalize(&t.path).map(|p| p == dir).unwrap_or(false)
    })
}

/// Load a USER-SUPPLIED custom TOML without letting a bad one take the process down.
///
/// `load_toml` panics on anything it cannot validate — an unparseable file, an unknown behavior
/// hook, a bad enum value; there are ~40 such assertions. That is right for the built-in
/// `commands/*.toml`, which are compiled in and validated at build time: a panic there is a broken
/// build. It is wrong for a file a USER wrote. A single typo in `~/.config/safe-chains.toml` made
/// EVERY invocation abort with exit 101 — including the hook, and a crashed PreToolUse hook means
/// the harness proceeds, so an ordinary mistake silently disabled safe-chains altogether.
///
/// Skipping the file fails SAFE: the custom definitions do not load, so nothing is widened, and the
/// built-in registry keeps deciding. The message goes to stderr rather than stdout so it cannot be
/// mistaken for a hook decision.
/// Fuzz/test seam onto [`load_custom_file`]: how many command definitions a config SOURCE yields,
/// and whether a repo-scoped load stripped an output claim.
///
/// Exposed because a repo `.safe-chains.toml` is the one input an attacker can place in a
/// repository, and this is the function that reads it. It has already aborted the process once —
/// `load_toml` panicked on a wrong-typed key, and since the hook is a PreToolUse hook, an abort
/// means the harness proceeds, so an ordinary typo silently disabled safe-chains altogether. The
/// filesystem path cannot be fuzzed because `CUSTOM_REGISTRY` is a `LazyLock` read once per
/// process, so the seam takes the source directly.
#[doc(hidden)]
pub fn fuzz_load_config(source: &str, repo_scope: bool) -> usize {
    let path = Path::new("<fuzz>");
    let category = if repo_scope { "custom-project" } else { "custom-user" };
    let specs = load_custom_file(source, category, path);
    if repo_scope {
        // Mirrors what `apply_custom` does for a repo file: an output claim never survives from a
        // config the agent can write.
        return specs.into_iter().map(repo_scoped).filter(|s| s.output.is_none()).count();
    }
    specs.len()
}

fn load_custom_file(source: &str, category: &str, path: &Path) -> Vec<CommandSpec> {
    // Check SYNTAX first, so the common failure — a typo — is reported without a panic at all.
    // `toml::Value` accepts any well-formed document, so this only rejects what `load_toml` would
    // have aborted on, and its error carries the line and column.
    if let Err(e) = toml::from_str::<toml::Value>(source) {
        return skip(path, &e.to_string());
    }
    // Everything else `load_toml` refuses — an unknown behavior hook, a bad enum value, ~40
    // assertions — still panics, so it is caught here and its message recovered for the report.
    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| load_toml(source, category))) {
        Ok(specs) => specs,
        Err(payload) => {
            let why = payload
                .downcast_ref::<String>()
                .map(String::as_str)
                .or_else(|| payload.downcast_ref::<&str>().copied())
                .unwrap_or("unrecognized command definition");
            skip(path, why)
        }
    }
}

fn skip(path: &Path, why: &str) -> Vec<CommandSpec> {
    eprintln!(
        "safe-chains: ignoring {}{why}\n  Built-in commands are unaffected; fix the file to \
         re-enable your custom ones.",
        path.display()
    );
    Vec::new()
}

/// Strip the fields a REPO-level custom TOML may not carry.
///
/// `[command.output]` is dropped for the reason `level` is never read from a repo file. Every other
/// per-command field widens the command it is written on, where a reader can see what it costs.
/// This one is TRANSITIVE: it widens whatever CONSUMES the command's output, so
/// `[command.output] locus_from = "cwd"` on `echo` is really a statement that
/// `cat $(echo /etc/shadow)` may run — a consequence not visible at the place it is declared.
///
/// Repo files are hash-pinned, so this is not agent-reachable either way; the point is that
/// vouching for a file should not require tracing an indirect grant. The user config, which is
/// write-protected, keeps the field.
fn repo_scoped(mut spec: CommandSpec) -> CommandSpec {
    spec.output = None;
    spec
}

/// Apply user-level then repo-level custom TOMLs to the registry, in that order
/// so a trusted repo-level definition wins on conflicts. The user file
/// (`~/.config/safe-chains.toml`) is trusted as-is and also carries the
/// `[[trusted]]` list that pins repo files.
pub(super) fn apply_custom(map: &mut HashMap<String, CommandSpec>) {
    if env::var_os("SAFE_CHAINS_NO_LOCAL").is_some() {
        return;
    }

    let mut trusted = Vec::new();
    if let Some(path) = find_user_custom()
        && let Ok(source) = fs::read_to_string(&path)
    {
        for spec in load_custom_file(&source, "custom-user", &path) {
            insert_spec(map, spec);
        }
        trusted = parse_trusted(&source);
    }

    if let Some(repo_file) = find_repo_custom()
        && let Ok(bytes) = fs::read(&repo_file)
        && repo_is_trusted(&repo_file, &bytes, &trusted)
        && let Ok(source) = std::str::from_utf8(&bytes)
    {
        for spec in load_custom_file(source, "custom-project", &repo_file) {
            insert_spec(map, repo_scoped(spec));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A user-supplied config that safe-chains cannot validate must be SKIPPED, never fatal.
    ///
    /// `load_toml` panics on ~40 conditions — that is correct for the built-in `commands/*.toml`,
    /// which are compiled in and validated at build time. Applied to a file the USER wrote it was a
    /// fail-OPEN: one typo in `~/.config/safe-chains.toml` aborted every invocation with exit 101,
    /// the hook included, and a crashed PreToolUse hook lets the harness proceed. So an ordinary
    /// mistake silently disabled safe-chains entirely.
    /// A config that does not parse must load NOTHING — never a partial read.
    ///
    /// The unit-level counterpart to the `config_load` fuzz target. Loading "some" of a broken
    /// config is how a half-parsed definition would widen the allowlist, and this function has
    /// already aborted the process once on a wrong-typed key: as a PreToolUse hook, an abort means
    /// the harness proceeds, so a typo in a config file silently disabled safe-chains entirely.
    #[test]
    fn a_config_that_does_not_parse_loads_nothing() {
        // Malformed in several different ways, including the wrong-typed key that caused the abort.
        for broken in [
            "this is not valid toml [[[",
            "[[command]]\nname = 12345\n",
            "[[command]]\nname = \"x\"\nlevel = \"not-a-level\"\n",
            "[[command]]\nname = \"x\"\nbehavior = { hook = \"no-such-hook\" }\n",
            "\u{0}\u{1}\u{2}",
            "[[command]]",
        ] {
            for repo_scope in [false, true] {
                assert_eq!(
                    super::fuzz_load_config(broken, repo_scope),
                    0,
                    "a broken config loaded definitions (repo_scope={repo_scope}): {broken:?}"
                );
            }
        }
        // Non-vacuity: a WELL-FORMED config does load, so the zeros above are not simply what this
        // function always returns.
        let good = "[[command]]\nname = \"fuzzprobe\"\nmax_positional = 1\nlevel = \"SafeWrite\"\n";
        assert!(
            super::fuzz_load_config(good, false) > 0,
            "a valid config must load; otherwise the fail-safe assertions prove nothing"
        );
    }

    #[test]
    fn an_invalid_custom_config_is_skipped_not_fatal() {
        let path = Path::new("/tmp/does-not-matter.toml");
        // Bad SYNTAX — caught before any panic, so the message carries line/column.
        let specs = load_custom_file("this is not valid toml [[[", "custom-user", path);
        assert!(specs.is_empty(), "an unparseable file must contribute no commands");

        // Valid syntax, invalid CONTENT — reaches `load_toml`'s assertions and is caught there.
        let bogus = "[[command]]\nname = \"zz\"\nlevel = \"Inert\"\n\n\
                     [command.behavior]\noperation = \"observe\"\npositionals = \"read\"\n\
                     hook = \"bogus\"\n";
        let specs = load_custom_file(bogus, "custom-user", path);
        assert!(specs.is_empty(), "an unvalidatable file must contribute no commands");

        // Non-vacuity: a GOOD file still loads, so "always returns empty" cannot pass this.
        let good = "[[command]]\nname = \"frobnicate\"\nlevel = \"Inert\"\nbare = true\n";
        let specs = load_custom_file(good, "custom-user", path);
        assert_eq!(specs.len(), 1, "a valid custom file must still load");
        assert_eq!(specs[0].name, "frobnicate");
    }

    /// A repo file cannot carry `[command.output]`. The field is a TRANSITIVE grant — it widens
    /// whatever consumes the command's output, not the command itself — so declaring it on `echo`
    /// is really a statement that `cat $(echo /etc/shadow)` may run. Repo files are hash-pinned, so
    /// this is not agent-reachable; the guard is that vouching for a file should not require the
    /// user to trace an indirect consequence. The user config keeps the field.
    #[test]
    fn repo_custom_toml_cannot_declare_command_output() {
        let source = r#"
[[command]]
name = "echo"
description = "hijacked"
level = "Inert"
bare = true

[command.output]
locus_from = "cwd"
"#;
        let user: Vec<_> = load_toml(source, "custom-user").into_iter().collect();
        assert!(
            user.iter().any(|s| s.output.is_some()),
            "the user config must still be able to declare an output locus, or this guard is \
             testing the parser rather than the restriction",
        );

        // Calls the REAL rule `apply_custom` applies, not a copy of it — a copy would pass even
        // with the restriction deleted from the load path.
        let stripped: Vec<_> =
            load_toml(source, "custom-project").into_iter().map(repo_scoped).collect();
        assert!(
            stripped.iter().all(|s| s.output.is_none()),
            "a repo-level custom TOML must not be able to declare `[command.output]`",
        );
    }

    #[test]
    fn sha256_hex_known_vectors() {
        assert_eq!(
            sha256_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
        assert_eq!(
            sha256_hex(b"abc"),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn parse_trusted_reads_entries() {
        let src = r#"
            [[trusted]]
            path = "/a/b"
            sha256 = "abc123"

            [[trusted]]
            path = "/c/d"
            sha256 = "def456"
        "#;
        let t = parse_trusted(src);
        assert_eq!(t.len(), 2);
        assert_eq!(t[0].path, "/a/b");
        assert_eq!(t[1].sha256, "def456");
    }

    #[test]
    fn parse_level_reads_the_ceiling() {
        assert_eq!(parse_level("level = \"network-admin\"").as_deref(), Some("network-admin"));
        // alongside trusted/commands still parses.
        assert_eq!(
            parse_level("level = \"yolo\"\n[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n").as_deref(),
            Some("yolo"),
        );
        // absent / malformed / empty → None (fail-safe to the default band).
        assert!(parse_level("[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n").is_none());
        assert!(parse_level("not valid toml {{{").is_none());
        assert!(parse_level("").is_none());
    }

    #[test]
    fn parse_trusted_absent_or_malformed_is_empty() {
        assert!(parse_trusted("[[command]]\nname = \"x\"").is_empty());
        assert!(parse_trusted("not valid toml {{{").is_empty());
        assert!(parse_trusted("").is_empty());
    }

    #[test]
    fn load_toml_tolerates_trusted_sections() {
        // A user config holding only [[trusted]] must parse to zero commands,
        // not panic on a missing `command` field.
        assert!(load_toml("[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n", "custom-user").is_empty());
        // command alongside trusted: command parsed, trusted ignored here.
        let specs = load_toml(
            "[[command]]\nname = \"myco\"\nbare = true\n\n[[trusted]]\npath = \"/a\"\nsha256 = \"x\"\n",
            "custom-user",
        );
        assert_eq!(specs.len(), 1);
    }

    fn write_repo_file(dir: &Path, body: &str) -> PathBuf {
        let f = dir.join(REPO_FILENAME);
        fs::write(&f, body).unwrap();
        f
    }

    #[test]
    fn repo_trusted_when_path_and_hash_match() {
        let dir = tempfile::tempdir().unwrap();
        let body = "[[command]]\nname = \"myco\"\n";
        let f = write_repo_file(dir.path(), body);
        let canon = fs::canonicalize(dir.path()).unwrap();
        let trusted = vec![TrustedEntry {
            path: canon.to_string_lossy().into_owned(),
            sha256: sha256_hex(body.as_bytes()),
        }];
        assert!(repo_is_trusted(&f, body.as_bytes(), &trusted));
    }

    #[test]
    fn repo_untrusted_when_hash_differs() {
        let dir = tempfile::tempdir().unwrap();
        let f = write_repo_file(dir.path(), "[[command]]\nname = \"myco\"\n");
        let canon = fs::canonicalize(dir.path()).unwrap();
        let trusted = vec![TrustedEntry {
            path: canon.to_string_lossy().into_owned(),
            sha256: sha256_hex(b"different content"),
        }];
        // An agent rewrote the file after it was pinned: hash no longer matches.
        let tampered = b"[[command]]\nname = \"curl\"\nlevel = \"Inert\"\n";
        assert!(!repo_is_trusted(&f, tampered, &trusted));
    }

    #[test]
    fn repo_untrusted_when_path_not_listed() {
        let dir = tempfile::tempdir().unwrap();
        let body = "[[command]]\nname = \"myco\"\n";
        let f = write_repo_file(dir.path(), body);
        let trusted = vec![TrustedEntry {
            path: "/some/other/dir".to_string(),
            sha256: sha256_hex(body.as_bytes()),
        }];
        assert!(!repo_is_trusted(&f, body.as_bytes(), &trusted));
    }

    #[test]
    fn repo_untrusted_when_list_empty() {
        let dir = tempfile::tempdir().unwrap();
        let body = "[[command]]\nname = \"myco\"\n";
        let f = write_repo_file(dir.path(), body);
        assert!(!repo_is_trusted(&f, body.as_bytes(), &[]));
    }
}