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
//! Effect-tagging coverage (docs/AGENTIC_FIRST_DESIGN.md §5.3, §12).
//!
//! `safety::effect_of` decides whether a builtin is gated by policy, approval and
//! the audit log. Its fall-through is `Effect::Pure` — the *least* restrictive
//! class — so a builtin nobody tagged is silently treated as side-effect-free.
//! §12 flagged tagging 1,100+ builtins as unfinished labour and proposed "a lint
//! that fails CI on untagged builtins". This is that lint.
//!
//! It does not demand every builtin be tagged; most genuinely are pure. It demands
//! that no builtin whose *name* advertises a side effect falls through to `Pure`,
//! because that is the failure mode that matters: an ungated destructive call that
//! looks safe to every consumer of `effect_of`, including the agent-facing
//! ontology's `x-effect` annotation.
use aethershell::builtins::BUILTIN_LOOKUP;
use aethershell::safety::{effect_of, Effect};
/// Name fragments that assert a side effect. Deliberately narrow: each one is a
/// verb an implementation has to *act* on, not a noun that merely mentions state.
const DESTRUCTIVE: &[&str] = &["delete", "destroy", "purge", "wipe", "truncate", "drop_"];
const EXECUTING: &[&str] = &["_exec", "exec_", "spawn", "_shell", "shell_", "sudo"];
const KILLING: &[&str] = &["kill", "terminate", "sigkill"];
/// Egress: sends data somewhere it cannot be recalled from. Under-tagging these
/// is the exfiltration blind spot — a `Pure` tag means no `Network` governor
/// accounting and no audit entry.
const NETWORKING: &[&str] = &["upload", "download", "publish", "_post", "post_", "webhook"];
/// Writes that persist outside the current value space.
const WRITING: &[&str] = &["_write", "write_", "_save", "save_", "install", "_mount"];
/// Changing permissions, ownership, or the run-state of a service. These act on
/// the machine rather than on data, so an untagged one is a privilege operation
/// nobody is metering.
const CONTROLLING: &[&str] = &[
"chmod", "chown", "restart", "deploy", "_service", "service_",
];
/// Names that match a fragment but are genuinely pure, with the reason. Every
/// entry here is a claim someone can check — which is the point of listing them
/// rather than loosening the patterns.
fn is_known_pure(name: &str) -> bool {
const ALLOWED: &[&str] = &[
// Verified by reading the implementations: a `which("sudo")` lookup and
// an env-var read respectively. They report on the platform's shell
// without invoking one.
"platform_has_sudo",
"platform_shell_type",
// Three names that assert execution and perform none. All three were
// tagged `Exec` on the first pass of this lint — from the name alone,
// which is precisely the mistake the lint exists to catch, made while
// fixing it. Caught by reading each body before wiring a guard to it,
// which is the only step that would have caught them.
// sudo_exec: returns "use sudo directly in terminal", runs nothing
// watchexec_run: returns a suggested `watchexec --` invocation
// env_shell: reads $SHELL / %COMSPEC%
// remote_exec: a stub — "Simulate remote execution (in real impl
// would use SSH/RPC)". It used to report
// `status: "executed"`, a separate honesty problem
// from the effect tag; now reports `simulated`.
"sudo_exec",
"watchexec_run",
"env_shell",
"remote_exec",
"exec_remote",
// Transaction bookkeeping: `tx_savepoint` matches "_save" but only names
// a point in the journal. It writes no user data.
"tx_savepoint",
// A stub with no cloud client and no subprocess: it records a deployment
// intent locally. It used to report `status: "deployed"` — corrected to
// `simulated` at the same time this entry was added.
"cloud_deploy",
// Predicates and formatters that only *describe* an effect.
"can_delete",
"is_executable",
"shell_quote",
"shell_escape",
"shell_split",
"exec_plan",
"explain_exec",
];
ALLOWED.contains(&name)
}
fn matches_any(name: &str, fragments: &[&str]) -> bool {
let lower = name.to_lowercase();
fragments.iter().any(|f| lower.contains(f))
}
/// Report the shape of the tagging so the number is visible rather than assumed.
#[test]
fn effect_tagging_coverage_is_reported() {
let mut counts = std::collections::BTreeMap::new();
for name in BUILTIN_LOOKUP.keys() {
*counts
.entry(format!("{:?}", effect_of(name)))
.or_insert(0usize) += 1;
}
let total: usize = counts.values().sum();
let pure = *counts.get("Pure").unwrap_or(&0);
println!(
"effect coverage: {} builtins, {} classified, {} fall through to Pure ({:.0}%)",
total,
total - pure,
pure,
pure as f64 / total as f64 * 100.0
);
for (effect, n) in &counts {
println!(" {effect}: {n}");
}
assert!(total > 1000, "expected the full builtin table, saw {total}");
}
/// The lint. A name that advertises a side effect must not classify as `Pure`.
#[test]
fn no_builtin_that_names_a_side_effect_is_classified_pure() {
let mut offenders: Vec<(&str, &'static str)> = Vec::new();
for name in BUILTIN_LOOKUP.keys() {
if is_known_pure(name) || effect_of(name) != Effect::Pure {
continue;
}
let kind = if matches_any(name, DESTRUCTIVE) {
"destructive"
} else if matches_any(name, EXECUTING) {
"executing"
} else if matches_any(name, KILLING) {
"killing"
} else if matches_any(name, NETWORKING) {
"networking"
} else if matches_any(name, WRITING) {
"writing"
} else if matches_any(name, CONTROLLING) {
"controlling"
} else {
continue;
};
offenders.push((name, kind));
}
offenders.sort_unstable();
assert!(
offenders.is_empty(),
"{} builtin(s) name a side effect but classify as Pure, so policy, \
approval and the audit log do not apply to them:\n{}",
offenders.len(),
offenders
.iter()
.map(|(n, k)| format!(" {n} ({k})"))
.collect::<Vec<_>>()
.join("\n")
);
}