termaxa 0.18.4

A cooperative gate for the shell commands AI coding agents run — command previews, automatic backups, allow/ask/deny policy, and audit logging.
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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! `termaxa wrap -- <agent>` — every shell command the agent runs goes
//! through the gate, hook or no hook.
//!
//! v0.16 groundwork. The mechanism is deliberately boring: create a shim
//! directory, put `sh`/`bash`/`zsh` in it that forward to `termaxa run`,
//! prepend it to `PATH`, point `SHELL` at it, and launch the agent. A command
//! the agent runs through a shell then arrives at the existing runner —
//! "gate one command, insure, execute, record" — which already exists and is
//! already tested.
//!
//! WHAT THIS DOES NOT DO, stated the way the grades table states it. This is
//! not process interception. There is no `ptrace`, no `seccomp`, no
//! `LD_PRELOAD`. Commands reach the gate because they resolve a shell **by
//! name** through `PATH` or read `$SHELL`; a caller that execs `/bin/sh`
//! **by absolute path**, or that `execve`s a binary directly without a shell
//! at all, does not pass through anything. That residue is real and belongs
//! in the same table that sells the rung.
//!
//! NO HARNESS IS CLAIMED AS COVERED (#20, #45). Whether a given agent's shell
//! tool resolves `sh` through `PATH` or hardcodes `/bin/sh` is an empirical
//! question about that agent, and nobody here has watched one do it. `doctor`
//! reports what is wired; it does not promise what an unobserved harness will
//! do. Measured, then written — not the reverse.
//!
//! WHY THE SHIM DIR IS OPERATOR-OWNED FROM THE START (#51). A directory on
//! `PATH` whose contents get executed is an execution primitive: anything that
//! can write there chooses what `sh` means. In basic mode the agent's UID
//! could write to a shim dir it owns, which would make the wrapper a way to
//! run code rather than a way to gate it. So the ownership story is written
//! now — the dir lives under the operator's Termaxa home, not in the project,
//! and supervised mode inherits it rather than retrofitting it.

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

/// Shells the shim answers to. Only names a caller would resolve through
/// `PATH`; adding one is a claim that agents invoke it, so the list stays
/// short and observed rather than aspirational.
const SHIMMED_SHELLS: [&str; 3] = ["sh", "bash", "zsh"];

/// Where the shims live: under the Termaxa home, never in the project.
///
/// Under the project it would be agent-writable in every mode, which is the
/// execution primitive above. Under the Termaxa home it is operator-owned in
/// supervised mode by the same `chmod` that protects the rest of the state
/// directory.
pub fn shim_dir(termaxa_home: &Path) -> PathBuf {
    termaxa_home.join("shims")
}

/// Write the shim scripts, returning the directory to prepend to `PATH`.
///
/// The shim is one line of `exec`, so it adds no shell of its own between the
/// agent and the gate: `exec termaxa run -- "$@"` replaces the shim process
/// rather than nesting under it.
#[cfg(unix)]
pub fn install_shims(termaxa_home: &Path, termaxa_bin: &Path) -> Result<PathBuf> {
    use std::os::unix::fs::PermissionsExt;

    let dir = shim_dir(termaxa_home);
    std::fs::create_dir_all(&dir)
        .with_context(|| format!("cannot create shim directory {}", dir.display()))?;

    // 0755: the agent user must traverse and execute, and must not write.
    let mut perm = std::fs::metadata(&dir)?.permissions();
    perm.set_mode(0o755);
    std::fs::set_permissions(&dir, perm)?;

    for shell in SHIMMED_SHELLS {
        // Only a shell that exists gets a shim. A shim for an absent shell
        // makes the harness believe it has one: Claude Code preferred `zsh`
        // in a container with no zsh installed, because the shim directory
        // offered it (Sep 10, 2026).
        let Some(real) = real_shell(shell, &dir) else {
            // A shim left behind by an earlier install still advertises
            // the shell; take it down with the same reasoning.
            let _ = std::fs::remove_file(dir.join(shell));
            continue;
        };
        let path = dir.join(shell);
        // A `-c` string is how a shell is asked to run one command, and it is
        // the form we forward. It may sit in a cluster - `bash -lc` is how
        // Codex spells it - or after other options (`sh -e -c`), so the shim
        // scans the options the way `split_segments_deep` does and forwards
        // the whole argument list, options intact, so `-l` and `-e` still
        // reach the shell that finally runs it (#69). An interactive shell
        // (no `-c`) or a script file is a human or a file at a terminal and
        // is passed through untouched, because gating a person's own login
        // shell is not what this is for.
        let script = format!(
            r#"#!/bin/sh
# termaxa shim - generated, do not edit.
# A `-c` string, alone or in a cluster such as `-lc` or `-ec`, is routed
# through the gate with the shell's other options intact; anything else
# (a script file, an interactive shell) is handed to the real shell unchanged.
expect_string=""
skip_next=""
for a in "$@"; do
  if [ -n "$skip_next" ]; then
    skip_next=""
    continue
  fi
  if [ -n "$expect_string" ]; then
    # Options may follow -c (`zsh -c -l "..."` is Claude Code's spelling);
    # the string is the first operand after them.
    case "$a" in
      --) break ;;
      -o) skip_next=1 ;;
      -*) ;;
      "") break ;;
      *) exec {bin} run -- {shell} "$@" ;;
    esac
    continue
  fi
  case "$a" in
    --) break ;;
    -) break ;;
    --*) ;;
    -o) skip_next=1 ;;
    -*c*) expect_string=1 ;;
    -*) ;;
    *) break ;;
  esac
done
exec {real} "$@"
"#,
            bin = termaxa_bin.display(),
            shell = shell,
            real = real.display(),
        );
        std::fs::write(&path, script)
            .with_context(|| format!("cannot write shim {}", path.display()))?;
        let mut p = std::fs::metadata(&path)?.permissions();
        p.set_mode(0o755);
        std::fs::set_permissions(&path, p)?;
    }
    Ok(dir)
}

/// The real binary a shim stands in front of, found on PATH outside the shim
/// directory, or `None` when the shell is not installed at all.
#[cfg(unix)]
fn real_shell(shell: &str, shim_dir: &std::path::Path) -> Option<std::path::PathBuf> {
    let path = std::env::var_os("PATH")?;
    for d in std::env::split_paths(&path) {
        if d == shim_dir {
            continue;
        }
        let candidate = d.join(shell);
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}

/// Windows has no `$SHELL` convention and its shim story is different enough
/// that guessing at it would be worse than saying so.
///
/// v0.16 proves the model on one platform (the scope doc's words). Windows
/// gets its own residue analysis in v0.17 or an explicit "never" — either
/// way stated rather than left to a user to discover.
#[cfg(not(unix))]
pub fn install_shims(termaxa_home: &Path, _termaxa_bin: &Path) -> Result<PathBuf> {
    // Named rather than hand-waved: the message says where shims WOULD go and
    // which shells they would cover, so the refusal describes the missing
    // work instead of just declining.
    anyhow::bail!(
        "termaxa wrap is Unix-only in v0.16. Windows has no $SHELL convention, so \
         shims for {shells} under {dir} would not be consulted the way they are on \
         Unix, and guessing at an equivalent is worse than saying so. Use hook mode, \
         which is fully supported on Windows.",
        shells = SHIMMED_SHELLS.join("/"),
        dir = shim_dir(termaxa_home).display(),
    )
}

/// The program and `PATH` an approved command runs with: the shim directory
/// taken out of `PATH`, and a bare program name resolved through what is
/// left, so it is the real shell and not the shim again.
///
/// #65. The shim forwards `sh -c "<cmd>"` to `termaxa run -- sh "$@"`.
/// The runner then executed `sh` by name, through the same `PATH` the
/// wrapper had set up, and got the shim: an allowed command recursed
/// without end (`wrap -- sh -c 'echo hi'` hung), an asked one asked twice
/// and then found no stdin. Nothing ever reached `/bin/sh`. Measured on
/// 2026-09-03; the residue test had pinned a deny and a bypass, never an
/// execution.
///
/// The command's own children run with the same stripped `PATH`, which is
/// the intent: what was approved was the command and what it spawns. A
/// program given with a path separator is left alone; a bare name that
/// resolves nowhere is left bare, for the OS to report as before.
pub fn outside_shims(
    program: &str,
    path: Option<&std::ffi::OsStr>,
    termaxa_home: &Path,
) -> (std::ffi::OsString, std::ffi::OsString) {
    let shims = shim_dir(termaxa_home);
    let same_dir = |entry: &str| -> bool {
        let e = Path::new(entry);
        e == shims
            || match (e.canonicalize(), shims.canonicalize()) {
                (Ok(a), Ok(b)) => a == b,
                _ => false,
            }
    };
    let kept: Vec<String> = path
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_default()
        .split(path_separator())
        .filter(|entry| !entry.is_empty() && !same_dir(entry))
        .map(str::to_string)
        .collect();
    let stripped: std::ffi::OsString = kept.join(path_separator()).into();
    let bare = !program.contains('/') && !program.contains('\\');
    let resolved = if bare {
        kept.iter()
            .map(|dir| Path::new(dir).join(program))
            .find(|candidate| is_executable_file(candidate))
            .map(|p| p.into_os_string())
            .unwrap_or_else(|| program.into())
    } else {
        program.into()
    };
    (resolved, stripped)
}

#[cfg(unix)]
fn is_executable_file(p: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(p)
        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable_file(p: &Path) -> bool {
    std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false)
}

/// Launch `argv` with the shims in front of it.
pub fn run(argv: &[String], termaxa_home: &Path) -> Result<i32> {
    if argv.is_empty() {
        anyhow::bail!("nothing to wrap: termaxa wrap -- <command>");
    }
    let bin = std::env::current_exe().context("cannot locate the termaxa binary")?;
    let dir = install_shims(termaxa_home, &bin)?;

    let existing = std::env::var("PATH").unwrap_or_default();
    let path = format!("{}{}{}", dir.display(), path_separator(), existing);

    let mut cmd = std::process::Command::new(&argv[0]);
    cmd.args(&argv[1..])
        .env("PATH", path)
        .env("SHELL", dir.join("sh"))
        // A marker the gate can see, so a shimmed command is distinguishable
        // in the record from one that arrived by hook. Not a security
        // control - anything in the child can unset it - which is why it is
        // provenance rather than policy input.
        .env("TERMAXA_WRAPPED", "1");

    // THE ENDPOINT, and only the endpoint.
    //
    // The wrapped process runs as the agent, whose $HOME is deliberately not
    // the operator's - so it cannot discover the socket the way the operator
    // does, and must be TOLD. The first proving run found this the hard way:
    // an agent's hook looked in its own home, found nothing, and decided
    // locally while the supervisor sat idle.
    //
    // What travels is the socket path. NOT TERMAXA_HOME: pointing the agent's
    // state directory at the operator's would reverse the ownership model and
    // establish a convention where an environment variable hands an agent a
    // path to privileged state. The agent needs to ask; it does not need to
    // know where the answers are kept.
    if let Some(sock) = crate::supervise::endpoint() {
        cmd.env(crate::supervise::SOCKET_ENV, &sock);
    }

    let status = cmd
        .status()
        .with_context(|| format!("cannot launch {}", argv[0]))?;
    Ok(status.code().unwrap_or(1))
}

fn path_separator() -> &'static str {
    if cfg!(windows) {
        ";"
    } else {
        ":"
    }
}

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

    #[cfg(unix)]
    #[test]
    fn shims_are_written_executable_and_not_writable_by_others() {
        use std::os::unix::fs::PermissionsExt;
        let t = TempTree::new("wrap-shims");
        let home = t.path();
        let dir = install_shims(home, Path::new("/usr/bin/termaxa")).unwrap();

        for shell in SHIMMED_SHELLS {
            let p = dir.join(shell);
            if real_shell(shell, &dir).is_none() {
                assert!(
                    !p.exists(),
                    "no shim for a shell that is not installed: {shell}"
                );
                continue;
            }
            assert!(p.exists(), "{shell} shim exists");
            let mode = std::fs::metadata(&p).unwrap().permissions().mode();
            assert_eq!(mode & 0o111, 0o111, "{shell} is executable");
            assert_eq!(
                mode & 0o022,
                0,
                "{shell} must not be group- or world-writable: a writable file on \
                 PATH is a way to run code, not a way to gate it (#51)"
            );
        }
    }

    /// The shim forwards `-c` to the gate and everything else to the real
    /// shell. An interactive shell is a person at a terminal; gating that is
    /// not what this is for, and a shim that swallowed it would break login.
    #[cfg(unix)]
    #[test]
    fn the_shim_routes_dash_c_and_passes_everything_else_through() {
        let t = TempTree::new("wrap-script");
        let dir = install_shims(t.path(), Path::new("/usr/bin/termaxa")).unwrap();
        let script = std::fs::read_to_string(dir.join("sh")).unwrap();

        assert!(
            script.contains("/usr/bin/termaxa run --"),
            "a -c command goes through the runner: {script}"
        );
        let real = real_shell("sh", &dir).expect("sh exists on any unix test machine");
        assert!(
            script.contains(&format!("exec {} \"$@\"", real.display())),
            "anything else reaches the real shell by its resolved path: {script}"
        );
        // Options after -c are stepped over; the first operand routes.
        assert!(
            script.contains("-o) skip_next=1 ;;")
                && script.contains("exec /usr/bin/termaxa run --"),
            "{script}"
        );
        assert!(
            script.contains("exec "),
            "exec rather than a nested shell, so the shim adds no process: {script}"
        );
    }

    /// THE RESIDUE, pinned so it is never quietly assumed away.
    ///
    /// A shim on PATH catches a shell resolved BY NAME. It does not catch
    /// `/bin/sh` by absolute path, and it cannot — nothing consults PATH for
    /// an absolute path. Measured inside a real wrapper:
    ///
    ///     wrap -- sh -c "rm -rf victim"        blocked by policy
    ///     wrap -- /bin/sh -c "rm -rf victim"   ran ungated
    ///
    /// This is the grades table's "escape via tools that execute without
    /// spawning through the wrapper", made concrete. A test that only proved
    /// the happy path would let someone read the wrapper as interception.
    /// #65: the runner's own `sh` must be the real one. The shim directory
    /// leaves `PATH`, a bare name resolves through what remains, a path
    /// is left alone, and a name that resolves nowhere stays bare.
    #[cfg(unix)]
    #[test]
    fn an_approved_command_runs_outside_the_shims() {
        use std::os::unix::fs::PermissionsExt;
        let t = TempTree::new("wrap-outside");
        let dir = install_shims(t.path(), Path::new("/usr/bin/termaxa")).unwrap();
        let real = t.dir("realbin");
        std::fs::write(real.join("sh"), "#!/bin/sh\nexit 0\n").unwrap();
        let mut p = std::fs::metadata(real.join("sh")).unwrap().permissions();
        p.set_mode(0o755);
        std::fs::set_permissions(real.join("sh"), p).unwrap();

        let path = format!("{}:{}:/nonexistent", dir.display(), real.display());
        let (program, stripped) = outside_shims("sh", Some(std::ffi::OsStr::new(&path)), t.path());
        assert_eq!(
            program,
            real.join("sh").into_os_string(),
            "the bare name resolves past the shim to the real shell"
        );
        let stripped = stripped.to_string_lossy().into_owned();
        assert!(
            !stripped.contains(&dir.display().to_string()),
            "the shim directory is out of the child's PATH: {stripped}"
        );
        assert!(
            stripped.starts_with(&real.display().to_string()),
            "{stripped}"
        );

        // A trailing slash is the same directory.
        let path = format!("{}/:{}", dir.display(), real.display());
        let (_, stripped) = outside_shims("sh", Some(std::ffi::OsStr::new(&path)), t.path());
        assert!(
            !stripped.to_string_lossy().contains("shims"),
            "{stripped:?}"
        );

        // A program given as a path is left alone; a name that resolves
        // nowhere stays a name.
        let (program, _) = outside_shims("/bin/sh", Some(std::ffi::OsStr::new(&path)), t.path());
        assert_eq!(program, std::ffi::OsString::from("/bin/sh"));
        let (program, _) = outside_shims(
            "no-such-program-tmx",
            Some(std::ffi::OsStr::new(&path)),
            t.path(),
        );
        assert_eq!(program, std::ffi::OsString::from("no-such-program-tmx"));

        // No shims on PATH at all: nothing changes but the resolution.
        let (_, same) = outside_shims("sh", Some(std::ffi::OsStr::new("/usr/bin:/bin")), t.path());
        assert_eq!(same, std::ffi::OsString::from("/usr/bin:/bin"));
    }

    #[cfg(unix)]
    #[test]
    fn an_absolute_path_shell_is_outside_what_a_path_shim_can_reach() {
        let t = TempTree::new("wrap-residue");
        let dir = install_shims(t.path(), Path::new("/usr/bin/termaxa")).unwrap();

        // The shim answers to the NAME. That is the whole mechanism.
        assert!(dir.join("sh").exists());

        // And nothing here, or anywhere, puts a file at /bin/sh - so a caller
        // naming that path reaches the system shell directly. Asserted as a
        // property of the design rather than by touching /bin.
        assert!(
            !dir.join("bin").exists(),
            "the shim dir shadows names on PATH, not absolute paths"
        );
    }

    /// The shim directory lives under the Termaxa home, never in the project.
    /// In the project it would be agent-writable in every mode, which turns a
    /// gate into an execution primitive (#51).
    #[test]
    fn the_shim_directory_is_outside_the_project() {
        let t = TempTree::new("wrap-location");
        let home = t.path();
        let dir = shim_dir(home);
        assert!(dir.starts_with(home), "{}", dir.display());
        assert!(
            !dir.to_string_lossy().contains(".termaxa/policy"),
            "not beside the policy the agent may read"
        );
    }
}