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
//! An agent may not approve its own denied call.
//!
//! §5.3's policy table grades `Destructive` and `Exec` as **approve** in agent
//! mode, and §7.2 documented the loop as `E_NEEDS_APPROVAL` → `approve(token)` →
//! re-run succeeds. That loop is right for a human at a REPL and wrong for an
//! agent, because the agent holds the token it was just refused with.
//!
//! It was demonstrated before it was fixed: the denied call succeeded on retry,
//! because `approve` is classified `Pure`, `Pure` is not centrally enforced, and
//! `bi_approve` called `grant_approval` unconditionally. Against an agent that
//! *errs* the second step is a real speed bump; against one that is *adversarial
//! or prompt-injected* the `approve` cell was simply `allow`.
//!
//! The fix refuses the in-band path **in agent mode only**:
//!
//! * human mode is untouched — the documented REPL loop still works;
//! * the out-of-band channel the design already provides (`AETHER_APPROVE`,
//! `AETHER_APPROVE_ALL`, set by whoever launched the agent) still works, so no
//! capability was removed that was not already reachable by someone outside
//! the agent's control.
//!
//! The assumption behind that choice, stated so it can be overridden: **the
//! adversarial threat model is in scope.** For a shell built to be driven by
//! language models, prompt injection is the defining threat, and hardening in
//! that direction is the conservative error.
use aethershell::builtins;
use aethershell::env::Env;
use aethershell::safety::{self, effect_of, Effect};
use aethershell::value::Value;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
fn lock() -> std::sync::MutexGuard<'static, ()> {
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn call(name: &str, args: Vec<Value>) -> anyhow::Result<Value> {
let mut env = Env::new();
builtins::call(name, args, &mut env)
}
#[test]
fn an_agent_cannot_spend_the_token_it_was_just_handed() {
let _g = lock();
std::env::set_var("AETHER_MODE", "agent");
// 1. A Destructive call is refused, and the refusal carries the token.
let denied = safety::guard_dispatch("git_clean", &[]);
let err = denied.expect_err("git_clean must be refused in agent mode");
assert_eq!(err.code, safety::ErrorCode::NeedsApproval);
let token = err
.approval
.as_ref()
.map(|a| a.token.clone())
.expect("the refusal hands the caller a token");
// 2. Spending it in-band is now refused. This is the fix.
let self_grant = call("approve", vec![Value::Str(token.clone())]);
// 3. And the call is still refused afterwards.
let after = safety::guard_dispatch("git_clean", &[]);
safety::revoke_approval(&token);
std::env::remove_var("AETHER_MODE");
let e = self_grant.expect_err("an agent must not approve its own call");
let text = format!("{e:#}").to_ascii_lowercase();
assert!(
text.contains("own call") || text.contains("self-service"),
"the refusal should say why, and point at the out-of-band path: {text}"
);
assert!(
text.contains("aether_approve"),
"the refusal must name the channel that does work, or it is a dead end: {text}"
);
assert!(
after.is_err(),
"the denied call must stay denied after a refused self-approval"
);
}
#[test]
fn a_human_can_still_approve_at_the_repl() {
// The check that keeps the fix from being a removal. §7.2's loop is correct
// for a person: they are not the party being constrained.
let _g = lock();
std::env::remove_var("AETHER_MODE");
let r = call("approve", vec![Value::Str("apv_human_test".into())]);
safety::revoke_approval("apv_human_test");
assert!(
r.is_ok(),
"human mode must be untouched — the documented REPL loop still works: {r:?}"
);
}
#[test]
fn the_out_of_band_channel_still_works_in_agent_mode() {
// The fix would be a wall rather than a redirection if this did not hold.
// `AETHER_APPROVE` is set by whoever launches the agent, which is the whole
// point: the approver is not the approved.
let _g = lock();
std::env::set_var("AETHER_MODE", "agent");
let token = safety::guard_dispatch("git_clean", &[])
.expect_err("denied")
.approval
.map(|a| a.token)
.expect("token");
std::env::set_var("AETHER_APPROVE", &token);
let after = safety::guard_dispatch("git_clean", &[]);
std::env::remove_var("AETHER_APPROVE");
std::env::remove_var("AETHER_MODE");
assert!(
after.is_ok(),
"an out-of-band approval must still let the call through: {after:?}"
);
}
#[test]
fn approve_is_still_pure_and_that_is_now_fine() {
// `approve` remains unclassified, so `guard_dispatch` still waves it through.
// That is no longer the whole story: the refusal moved into the body, which
// is where the mode is known. Asserted so a future reclassification is a
// deliberate act rather than a surprise.
assert_eq!(effect_of("approve"), Effect::Pure);
}
#[test]
fn the_binding_property_that_does_hold_still_holds() {
// What §7.2 actually guarantees, and it survives the fix: a token is bound to
// one action descriptor, so it cannot be replayed against a different call.
//
// The control has to be a builtin the *central* gate judges. The first draft
// used `proc_kill`, which is in `SELF_GUARDED` — `guard_dispatch` returns
// `Ok` for it before any token is consulted, so the test failed while the
// property was fine. A bad control produces a false alarm as readily as a
// real finding.
let _g = lock();
std::env::set_var("AETHER_MODE", "agent");
let a = safety::guard_dispatch("git_clean", &[])
.expect_err("denied")
.approval
.map(|x| x.token)
.expect("token");
safety::grant_approval(&a);
let other = safety::guard_dispatch("git_reset", &[]);
safety::revoke_approval(&a);
std::env::remove_var("AETHER_MODE");
assert!(
other.is_err(),
"a token bound to git_clean must not approve git_reset — this is the \
content-binding property §7.2 claims, and it holds"
);
}