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
use crate::audit::{now, AuditEntry, AuditLog};
use crate::context;
use crate::policy::{Action, Policy};
use anyhow::{bail, Result};
use std::io::{self, Write};
use std::process::Command;
/// `termaxa run -- <cmd...>`: gatekept execution from the CLI.
pub fn run(paths: &crate::paths::Paths, argv: &[String]) -> Result<i32> {
if argv.is_empty() {
bail!("nothing to run — usage: termaxa run -- <command...>");
}
let command = shell_join(argv);
let policy = Policy::load(&paths.policy_file())?;
let ctx =
crate::resolve::EvalContext::from_paths(std::env::current_dir().unwrap_or_default(), paths);
let base = policy.evaluate_command(&command, &ctx);
let signals = context::gather(&command);
let (decision, escalated) = context::apply(base, &signals);
println!("┌ termaxa");
println!("│ command : {}", command);
println!("│ decision: {}", decision.action);
println!("│ reason : {}", decision.reason);
for s in &signals {
println!(
"│ context : {}{}",
s.label,
if s.escalate { " ⚠" } else { "" }
);
}
println!("└");
crate::notify::maybe_send(
&policy,
&decision.action.to_string(),
&command,
&decision.reason,
"run",
);
// The runner executes the command itself, so ITS process cwd is the
// correct resolution base — unlike the hook, whose process cwd is the
// harness's, not the command's. Threaded explicitly so that distinction
// is visible rather than relying on an ambient default inside resolve.
let run_cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut backup_id: Option<String> = None;
// Returns whether the command may go on to run. A failed backup proceeds
// with a warning by default; a policy that sets `backup_failure: deny`
// refuses instead, because nobody is reading warnings on an unattended run.
let insure = |backup_id: &mut Option<String>| -> bool {
match crate::backup::take(&paths.state_dir, &command, &run_cwd) {
Ok(Some(rec)) => {
println!("🛟 backup {} — {}", rec.id, rec.note);
*backup_id = Some(rec.id);
true
}
Ok(None) => true, // nothing to insure
Err(e) if policy.backup_failure == crate::policy::BackupFailure::Deny => {
eprintln!(
"termaxa: backup failed ({}); refused — the policy sets \
`backup_failure: deny`, so an uninsured command does not run",
e
);
false
}
Err(e) => {
eprintln!(
"termaxa: backup failed ({}); proceeding — command was approved",
e
);
true
}
}
};
let root = paths.project_dir.parent();
let preview_summary =
crate::preview::generate(&command, root, &run_cwd, true).map(|p| p.summary);
let (approved, exit_code) = match decision.action {
Action::Deny => {
eprintln!("termaxa: blocked by policy.");
(Some(false), None)
}
Action::Ask => {
if let Some(pv) = crate::preview::generate(&command, root, &run_cwd, true) {
println!("┌ {}", pv.title);
for l in &pv.lines {
println!("│{}", l);
}
println!("└");
}
// An answer has to come from a person at a terminal. Under
// `wrap`, the agent's Bash tool hands us a pipe that never
// closes: the prompt blocked for 120 s until the harness gave
// up (Claude Code, Sep 10, 2026) - and the agent itself noted
// it could have piped `y` into the same stdin. Both end here:
// no terminal, no ask, refused with the reason. A pipe with a
// `y` in it is not a human.
if !stdin_is_terminal() {
eprintln!(
"termaxa: this needs a human at a terminal and stdin is not one \
(a pipe, a harness, a script) — refused rather than run unasked. \
Add an allow rule, or run it yourself."
);
(Some(false), None)
} else {
print!("Proceed? [y/N] ");
io::stdout().flush()?;
let mut line = String::new();
let read = io::stdin().read_line(&mut line)?;
// NO ONE TO ASK is not the same as a refusal, and saying
// "declined" when nobody declined is a lie the user cannot debug.
// `read_line` returns Ok(0) on a closed or non-interactive stdin -
// which is exactly what a `wrap` shim hands us, since the agent
// is not a person at a terminal.
//
// The verdict is unchanged: an `ask` nobody can answer must not
// run. Falling through to the y/N match would already have
// declined, by accident of an empty string failing to equal "y";
// this makes it a decision with a message that fits (#48 - a gate
// whose refusals are unexplainable gets uninstalled).
if read == 0 {
eprintln!(
"termaxa: this needs a human and stdin is not interactive — \
refused rather than run unasked."
);
(Some(false), None)
} else if matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
if insure(&mut backup_id) {
let code = execute(argv)?;
(Some(true), Some(code))
} else {
(Some(false), None)
}
} else {
eprintln!("termaxa: declined.");
(Some(false), None)
}
}
}
Action::Allow => {
if insure(&mut backup_id) {
let code = execute(argv)?;
(None, Some(code))
} else {
(Some(false), None)
}
}
};
let intent_label = crate::intent::classify_command(&command).map(|i| i.label().to_string());
let log = AuditLog::new(&paths.state_dir)?;
let (ts_ms, ts) = now();
log.append(&AuditEntry {
ts_ms,
ts,
source: "run".into(),
// `run` is the human's own surface: no agent harness produced it, so
// naming one would be false provenance.
actor: None,
decided_by: Some(decision.source.as_str().to_string()),
command,
decision: decision.action.to_string(),
matched_rule: decision.matched_rule,
reason: decision.reason,
signals: signals.iter().map(|s| s.label.clone()).collect(),
escalated,
session: None,
backup: backup_id,
preview: preview_summary,
intent: intent_label,
approved,
exit_code,
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default(),
// Filled by `append`, which links each entry to the one before it.
prev: None,
hash: None,
})?;
Ok(exit_code.unwrap_or(1))
}
/// Whether stdin is a terminal - the only place an answer to an ask can come
/// from. A pipe from a harness never closes, and a pipe from an agent can
/// carry a `y`; neither is a person.
#[cfg(unix)]
fn stdin_is_terminal() -> bool {
// SAFETY: isatty on a constant fd is a pure query with no side effects.
unsafe { libc::isatty(libc::STDIN_FILENO) == 1 }
}
#[cfg(not(unix))]
fn stdin_is_terminal() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}
/// Rebuild a display/analysis string from argv WITHOUT losing token
/// boundaries: any argument containing whitespace or quotes is re-quoted so
/// downstream tokenizers (previews, backups) see the original structure.
/// A naive `join(" ")` flattens `-c "TRUNCATE users"` into three words —
/// which is how v0.6 initially failed to insure a truncate.
fn shell_join(argv: &[String]) -> String {
argv.iter()
.map(|a| {
if a.is_empty()
|| a.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '\'')
{
format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn execute(argv: &[String]) -> Result<i32> {
// Outside the wrapper's shims (#65): under `termaxa wrap`, `sh` by name
// is the shim, and the shim is what brought us here.
let inherited = std::env::var_os("PATH");
let (program, path) = match crate::paths::home_base() {
Ok(home) => crate::wrap::outside_shims(&argv[0], inherited.as_deref(), &home),
Err(_) => (argv[0].clone().into(), inherited.unwrap_or_default()),
};
let status = Command::new(&program)
.args(&argv[1..])
.env("PATH", &path)
.status()?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempTree;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|p| p.to_string()).collect()
}
/// A project whose policy gives every command the same verdict.
fn project_with_default(tmp: &TempTree, default: &str) -> crate::paths::Paths {
let project_dir = tmp.dir("proj/.termaxa");
std::fs::write(
project_dir.join("policy.yaml"),
format!("version: 1\ndefault: {}\nrules: []\n", default),
)
.expect("policy must be writable");
crate::paths::Paths {
project_dir,
state_dir: tmp.dir("state"),
}
}
#[test]
fn shell_join_leaves_ordinary_arguments_alone() {
// Nothing here carries structure, so nothing should gain quotes —
// the reconstruction is what rules are matched against.
assert_eq!(shell_join(&argv(&["git", "status"])), "git status");
}
#[test]
fn shell_join_keeps_a_quoted_argument_in_one_piece() {
// The v0.6 failure this function exists for: `-c "TRUNCATE users"`
// flattened into three words, so the backup layer never saw a
// truncate worth insuring.
assert_eq!(
shell_join(&argv(&["psql", "-c", "TRUNCATE users"])),
"psql -c \"TRUNCATE users\""
);
}
#[test]
fn shell_join_quotes_an_empty_argument() {
// An empty argument is a token too; unquoted it vanishes from the
// string entirely and `sh -c ""` reads as a bare `sh -c`.
assert_eq!(shell_join(&argv(&["sh", "-c", ""])), "sh -c \"\"");
}
#[test]
fn shell_join_quotes_on_a_quote_character_without_whitespace() {
assert_eq!(shell_join(&argv(&["say", "it's"])), "say \"it's\"");
assert_eq!(shell_join(&argv(&["say", "a\"b"])), "say \"a\\\"b\"");
}
#[test]
fn shell_join_escapes_backslashes_before_quoting() {
// Backslashes go first, or the escape added for `"` gets escaped in
// turn and the quoting closes early.
assert_eq!(
shell_join(&argv(&["cat", "C:\\tmp dir\\x"])),
"cat \"C:\\\\tmp dir\\\\x\""
);
}
#[cfg(unix)]
#[test]
fn execute_returns_the_child_exit_code() {
// 7 on purpose: none of 0, 1 or -1, so a body that reports a fixed
// code instead of the child's cannot pass.
assert_eq!(execute(&argv(&["sh", "-c", "exit 7"])).unwrap(), 7);
}
#[cfg(windows)]
#[test]
fn execute_returns_the_child_exit_code() {
assert_eq!(execute(&argv(&["cmd", "/C", "exit 7"])).unwrap(), 7);
}
#[test]
fn execute_reports_an_unlaunchable_command_as_an_error() {
// Spawn failure must surface, not read as a successful exit 0.
assert!(execute(&argv(&["termaxa-no-such-binary-xyzzy"])).is_err());
}
#[test]
fn run_refuses_an_empty_command_line() {
let tmp = TempTree::new("runner-empty");
let paths = project_with_default(&tmp, "allow");
let err = run(&paths, &[]).expect_err("there is nothing to gate or execute");
assert!(
err.to_string().contains("nothing to run"),
"the error should say what was missing, got: {}",
err
);
}
#[test]
fn run_blocks_a_denied_command_and_records_the_decision() {
let tmp = TempTree::new("runner-deny");
let paths = project_with_default(&tmp, "deny");
let code = run(&paths, &argv(&["echo", "hello"])).expect("a block is not an error");
assert_eq!(code, 1, "a blocked command must not report success");
let log = std::fs::read_to_string(paths.log_file())
.expect("every decision is logged, including the ones that ran nothing");
assert!(log.contains("\"decision\":\"deny\""), "{}", log);
assert!(log.contains("\"command\":\"echo hello\""), "{}", log);
// Nothing was launched, so there is no child exit code to report.
assert!(log.contains("\"exit_code\":null"), "{}", log);
assert!(log.contains("\"approved\":false"), "{}", log);
}
}