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
//! FR-3 (#998): the grant-independent absolute deny-list — a fixed veto that NO
//! capability, mode, or persona grant can unlock, spanning built-in AND remote
//! tools. Checked at the top of [`super::tools::execute_tool_with_offload`],
//! ABOVE the `mcp.handles` early-return (so one site covers built-in AND remote
//! dispatch) and BEFORE alias-rewrite / `run_command`→builtin routing (so it
//! sees the pre-rewrite name + args).
//!
//! ## Why structural, not a substring scan
//! The coaching workload is full of `ssh` / `rm -rf` / `systemctl restart`
//! inside runbook and alert TEXT. A regex over serialized args would veto the
//! coach's own questions ("should I run `systemctl restart nginx`?"). So we
//! extract the real axis TARGET of each call — the exec leading token for a
//! shell command — and match THAT. `ssh` as the command a model wants to
//! execute is denied; `ssh` quoted in a `request_user_input` question, or
//! written into a note's `content`, is not.
//!
//! Fail-closed within the axis it checks: a shell command whose leader hits the
//! list is refused regardless of caveats; anything we cannot parse to a target
//! simply is not an exec surface and falls through to the normal leashes.
//! File and directory deletion uses those normal exec/filesystem grants; it is
//! not an absolute prohibition, including recursive and multi-target cleanup.
use serde_json::Value;
/// The verdict for one tool call against the absolute deny-list. `reason` is
/// surfaced to the model verbatim as the tool result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Denied {
pub reason: String,
}
/// Tool names that drive a free-form shell command line: the `run_command`
/// built-in PLUS the shell aliases [`super::tools::resolve_tool_alias`] rewrites
/// to it. Listed here so the deny-list sees the exec surface under ANY spelling
/// and BEFORE the rewrite. Keep in sync with the shell-alias arm of
/// `resolve_tool_alias`.
const EXEC_TOOL_NAMES: &[&str] = &[
"run_command",
"execute",
"exec",
"bash",
"shell",
"sh",
"zsh",
"terminal",
"run_shell_command",
"shell_command",
"system",
];
/// Exec leading tokens NO session may run — lateral movement off this host and
/// raw destructive / disk / power operations. Matched against the RESOLVED
/// leading token of a shell command (after `sudo` / `env VAR=val` prefixes,
/// reduced to the basename of a path), never against arbitrary args.
#[rustfmt::skip]
const DENY_EXEC_LEADERS: &[&str] = &[
// lateral movement off this host
"ssh",
"scp",
"sftp",
"rsh",
"rlogin",
"telnet",
// destructive / raw-disk
"shred",
"dd",
"mkfs",
"fdisk",
"parted",
"wipefs",
// host power state (would take down the machine newt runs on)
"shutdown", "reboot", "halt", "poweroff",
];
/// Service managers whose STATE-CHANGING actions are denied. Read-only actions
/// (status / show / list / is-*) fall through — the deny is on disruption, not
/// inspection. Matched position-independently over the remaining tokens, since
/// `systemctl restart X` and `service X restart` order their action differently.
const SERVICE_MANAGERS: &[&str] = &["systemctl", "service"];
const SERVICE_STATE_ACTIONS: &[&str] = &[
"restart",
"stop",
"start",
"reload",
"try-restart",
"force-reload",
"disable",
"enable",
"kill",
"mask",
"unmask",
"isolate",
"reboot",
"poweroff",
"halt",
];
/// The single entry point: does this tool call hit the absolute deny-list?
/// `None` = allowed to proceed to the persona allow-list / routing / dispatch.
pub fn deny_check(name: &str, args: &Value) -> Option<Denied> {
if EXEC_TOOL_NAMES.contains(&name) {
let command = args.get("command").and_then(Value::as_str).unwrap_or("");
return deny_exec(command);
}
// fs / net axes: structural hooks intentionally deferred — the RFC (#998)
// acceptance is exec-focused, and fs writes / net reach are already leashed
// by the fs_write / net caveats. When added, extract `args["path"]` /
// `args["url"]` host here in the SAME structural style (never a content scan).
None
}
/// Classify a shell command line by its EXEC TARGET (the leading token), after
/// stripping `sudo` / `doas` / `env VAR=val` wrappers and any leading directory.
fn deny_exec(command: &str) -> Option<Denied> {
let mut tokens = command.split_ascii_whitespace().peekable();
// Strip privilege / env prefixes so `sudo rm` and `env X=1 ssh` still match.
loop {
match tokens.peek().copied() {
Some("sudo") | Some("doas") => {
tokens.next();
}
Some("env") => {
tokens.next();
// Consume `VAR=val` assignments that follow `env`.
while tokens
.peek()
.is_some_and(|t| t.contains('=') && !t.starts_with('='))
{
tokens.next();
}
}
_ => break,
}
}
let Some(raw_leader) = tokens.next() else {
return None; // empty command — nothing to deny
};
// `/usr/bin/ssh` → `ssh`; `./rm` → `rm`.
let leader = raw_leader.rsplit(['/', '\\']).next().unwrap_or(raw_leader);
if DENY_EXEC_LEADERS.contains(&leader) {
return Some(Denied {
reason: deny_message(leader, "an absolutely forbidden command"),
});
}
if SERVICE_MANAGERS.contains(&leader) && tokens.any(|t| SERVICE_STATE_ACTIONS.contains(&t)) {
return Some(Denied {
reason: deny_message(leader, "a service-state change"),
});
}
None
}
fn deny_message(target: &str, what: &str) -> String {
format!(
"Refused: `{target}` is {what} under newt's absolute deny-list — a fixed \
safety floor that no capability, mode, or persona grant can unlock. This \
is not a permission you can request; if the operation is genuinely \
required, a human must perform it outside the agent."
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cmd(c: &str) -> Option<Denied> {
deny_check("run_command", &json!({ "command": c }))
}
#[test]
fn denies_lateral_movement_destruction_and_service_changes() {
assert!(cmd("ssh host 'uptime'").is_some(), "ssh denied");
assert!(
cmd("/usr/bin/scp a b:").is_some(),
"path-qualified scp denied"
);
assert!(
cmd("env FOO=1 ssh host").is_some(),
"env-prefixed ssh denied"
);
assert!(
cmd("systemctl restart nginx").is_some(),
"systemctl restart denied"
);
assert!(
cmd("service nginx restart").is_some(),
"service restart denied"
);
assert!(cmd("shutdown -h now").is_some(), "shutdown denied");
assert!(cmd("dd if=/dev/zero of=/dev/sda").is_some(), "dd denied");
}
#[test]
fn native_cleanup_reaches_the_existing_authority_boundary() {
for command in [
"rm -rf build/",
"rm -r build/",
"rm one two",
"rm --recursive --force -- 'directory with spaces'",
"rmdir empty/",
"unlink file",
"/bin/rm -rf /outside/workspace",
"env FLAG=1 rm -rf build/",
] {
for alias in EXEC_TOOL_NAMES {
assert!(
deny_check(alias, &json!({ "command": command })).is_none(),
"{alias} must leave {command:?} to exec/filesystem authority"
);
}
}
}
#[test]
fn allows_simple_one_file_delete_for_governed_routing() {
assert!(
cmd("rm src/cockpit.rs").is_none(),
"plain one-file rm should reach routing/delete_file"
);
assert!(
cmd("rm -f src/cockpit.rs").is_none(),
"force one-file rm should reach routing/delete_file"
);
assert!(
cmd("unlink src/cockpit.rs").is_none(),
"unlink one-file delete should reach routing/delete_file"
);
}
/// The absolute floor ignores the tool NAME's spelling: a shell alias that
/// rewrites to `run_command` is caught under its raw name, pre-rewrite.
#[test]
fn denies_exec_under_shell_aliases_too() {
for alias in ["bash", "exec", "shell", "sh", "system"] {
let hit = deny_check(alias, &json!({ "command": "ssh host" }));
assert!(hit.is_some(), "{alias} → ssh must be denied");
}
}
#[test]
fn allows_inspection_and_ordinary_commands() {
assert!(
cmd("systemctl status nginx").is_none(),
"status is read-only"
);
assert!(
cmd("service nginx status").is_none(),
"service status read-only"
);
assert!(cmd("cargo test").is_none());
assert!(cmd("git log --oneline").is_none());
assert!(cmd("ls -la").is_none());
assert!(cmd("").is_none(), "empty command is a no-op, not a hit");
assert!(
cmd("ripgrep ssh src/").is_none(),
"ssh as an ARG is not the target"
);
}
/// The crux of #998: `ssh` / `rm -rf` / `systemctl restart` appearing as
/// DATA in another tool's args (a coach's question, a runbook excerpt) must
/// NOT be vetoed — only the same tokens in EXEC-TARGET position are.
#[test]
fn does_not_veto_forbidden_words_as_data_in_other_tools() {
let question = json!({
"prompt": "The alert says `ssh web01 && systemctl restart nginx`, \
then `rm -rf /var/cache/*`. Should I?",
});
assert!(deny_check("request_user_input", &question).is_none());
let note = json!({
"path": "notes/incident.md",
"content": "Runbook: ssh in, systemctl restart, rm -rf the cache.",
});
assert!(deny_check("write_file", ¬e).is_none());
}
}