Skip to main content

candor_classify/
lib.rs

1//! candor-classify — the curated effect classifier (crate+path -> effect), extracted to a STABLE
2//! crate so both the nightly `rustc_private` lint AND a stable backend share ONE source of truth
3//! (no drift). Pure string logic; no rustc internals. The effect vocabulary lives in candor-report.
4
5use candor_report::EFFECTS;
6
7/// The canonical CANDOR_POLICY DSL parser (SPEC §6.2), shared by the nightly gate and candor-query.
8pub mod policy;
9
10/// The SURPRISE heuristic (the cold-repo hook) — SHARED so candor-scan's scan-time note and
11/// candor-query's `tour` verb can't drift. Generic over the effect element type.
12pub mod surface;
13
14/// The transitive least fixed point over a call graph — SHARED so the scanner's gate-side reason-class
15/// accumulator and candor-query's `unverified --class` filter resolve over the SAME reach.
16pub mod propagate;
17
18/// ⟨0.24⟩ The §6.2 GATE over an already-accumulated signature — SHARED so `candor-scan --policy` and
19/// `candor-query gate --report` (SPEC §3.1) are the same gate reached by two routes, not two gates.
20pub mod gate;
21
22/// Project-supplied rules, consulted only when the built-in `classify` returns None.
23pub fn classify_extra(
24    crate_name: &str,
25    path: &str,
26    extra: &[(&'static str, bool, String)],
27) -> Option<&'static str> {
28    for (eff, is_crate, prefix) in extra {
29        let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
30        if hit {
31            return Some(eff);
32        }
33    }
34    None
35}
36
37/// The exact third-party crates `classify` has effect rules for, and the crate-name
38/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
39/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
40/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
41/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
42/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
43pub const CALIBRATED_CRATES: [&str; 79] = [
44    // network (aws_config resolves credentials over the network on `.load()`;
45    // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
46    // pnet is raw L2/L3 packet capture)
47    "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
48    "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
49    // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
50    // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
51    "ignore", "notify",
52    // database (see DB_CRATES in classify)
53    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
54    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
55    // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
56    // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
57    "memmap2", "fs_err", "async_fs", "tempfile", "glob",
58    "rand", "getrandom", "fastrand",
59    // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
60    "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
61    "portable_pty", "async_process", "duct",
62    "dotenvy", "dotenv",
63    "chrono", "time", "tracing", "log", "arboard",
64    // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
65    "rustc_lint", "rustc_errors",
66    // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
67    // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
68    "libc", "nix", "rustix",
69    // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
70    // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
71    // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
72    // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
73    // jiff/backoff clock reads (Clock).
74    "rustls", "native_tls_crate", "tokio_native_tls",
75    "etcetera", "wild", "lscolors",
76    "sqlx_core", "walkdir", "filetime", "clircle",
77    "execute", "ctrlc", "clap", "jiff", "env_logger",
78    "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
79];
80
81pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
82
83/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
84/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
85/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
86/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
87pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
88
89/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
90/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
91/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
92/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
93/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
94/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
95pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
96    "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
97    "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
98    "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
99    "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
100    "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
101    // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
102    // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
103    "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
104    // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
105    // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
106    "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
107    "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
108    "::write_line", "::background_color", "::retry", "::build",
109];
110
111/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
112/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
113pub const DB_CRATES: [&str; 11] = [
114    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
115    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
116];
117
118/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
119/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
120/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
121/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
122/// perform I/O. calling a PURE function effectful is a FABRICATION — the precision failure (candor's cardinal sin is the opposite direction, the silent under-report) — and these collide with the
123/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
124/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
125/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
126/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
127/// fabricated Net/Fs/Ipc.)
128const PURE_FD_TRANSFER: &[&str] = &[
129    "from_raw_fd", "from_raw_socket", "from_raw_handle",
130    "into_raw_fd", "into_raw_socket", "into_raw_handle",
131    "as_raw_fd", "as_raw_socket", "as_raw_handle",
132    "into_std",
133    // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
134    // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
135    // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
136    "from_pathname",
137];
138
139/// Classify a resolved callee by the crate it belongs to and its full path.
140pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
141    // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
142    // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
143    if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
144        return None;
145    }
146    if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
147        // Only request dispatch is network I/O; builder setters/accessors are pure.
148        if path.ends_with("::send") || path.ends_with("::send_with") {
149            return Some("Net");
150        }
151        return None;
152    }
153    // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
154    // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
155    // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
156    // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
157    if crate_name == "aws_config" {
158        if path.ends_with("::load") || path.ends_with("::load_defaults") {
159            return Some("Net");
160        }
161        return None;
162    }
163    // git2 (libgit2 FFI): remote operations contact the network; everything else is local
164    // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
165    // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
166    // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
167    // client reporting it makes no network calls.)
168    if crate_name == "git2" {
169        if path.ends_with("::fetch")
170            || path.ends_with("::push")
171            || path.ends_with("::download")
172            || path.ends_with("::connect")
173            || path.ends_with("::connect_auth")
174            || path.ends_with("::ls")
175            || path.ends_with("::upload")
176        {
177            return Some("Net");
178        }
179        return None;
180    }
181    // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
182    // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
183    // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
184    // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
185    // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
186    // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
187    // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
188    // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
189    // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
190    // gmtime) are also skipped.
191    //
192    // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
193    // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
194    // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
195    // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
196    // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
197    // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
198    // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
199    // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
200    // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
201    // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
202    // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
203    // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
204    // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
205    if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
206        let f = path.rsplit("::").next().unwrap_or(path);
207        // path / directory / metadata syscalls (incl. *64 and *at variants)
208        const FS: &[&str] = &[
209            "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
210            "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
211            "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
212            "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
213            "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
214            "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
215            "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
216            "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
217            "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
218            "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
219            "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
220            "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
221            "realpath",
222        ];
223        // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
224        // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
225        const NET: &[&str] = &[
226            "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
227            "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
228            "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
229        ];
230        // process creation / replacement / reaping
231        const EXEC: &[&str] = &[
232            "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
233            "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
234            "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
235        ];
236        // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
237        const IPC: &[&str] = &[
238            "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
239            "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
240            "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
241        ];
242        const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
243        const CLOCK: &[&str] = &[
244            "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
245            // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
246            // which sets it via `clock_settime`).
247            "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
248        ];
249        const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
250        if FS.contains(&f) {
251            return Some("Fs");
252        }
253        if NET.contains(&f) {
254            return Some("Net");
255        }
256        if EXEC.contains(&f) {
257            return Some("Exec");
258        }
259        if IPC.contains(&f) {
260            return Some("Ipc");
261        }
262        if ENV.contains(&f) {
263            return Some("Env");
264        }
265        if CLOCK.contains(&f) {
266            return Some("Clock");
267        }
268        if RAND.contains(&f) {
269            return Some("Rand");
270        }
271        return None;
272    }
273    // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
274    // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
275    // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
276    // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
277    // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
278    // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
279    // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
280    // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
281    // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
282    {
283        let leaf = path.rsplit("::").next().unwrap_or(path);
284        if let Some(rest) = leaf.strip_prefix("sqlite3_") {
285            let _ = rest;
286            // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
287            const DB: &[&str] = &[
288                "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
289                "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
290                "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
291                "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
292                "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
293                "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
294            ];
295            return DB.contains(&leaf).then_some("Db");
296        }
297        if leaf.starts_with("git_") {
298            // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
299            // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
300            // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
301            const NET: &[&str] = &[
302                "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
303                "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
304                "git_submodule_clone", "git_submodule_update",
305            ];
306            // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
307            const FS: &[&str] = &[
308                "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
309                "git_repository_init", "git_repository_init_ext", "git_repository_discover",
310                "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
311                "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
312                "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
313                "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
314                "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
315                "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
316                "git_commit_create_v", "git_reference_create", "git_reference_set_target",
317                "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
318                "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
319                "git_packbuilder_write",
320            ];
321            if NET.contains(&leaf) {
322                return Some("Net");
323            }
324            if FS.contains(&leaf) {
325                return Some("Fs");
326            }
327            return None;
328        }
329        if leaf.starts_with("curl_") {
330            // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
331            // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
332            // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
333            // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
334            // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
335            // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
336            // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
337            const NET: &[&str] = &[
338                "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
339                "curl_multi_perform", "curl_multi_socket_action",
340            ];
341            return NET.contains(&leaf).then_some("Net");
342        }
343        if let Some(op) = leaf.strip_prefix("SSL_") {
344            // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
345            // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
346            // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
347            // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
348            // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
349            const SSL_NET: &[&str] = &[
350                "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
351                "peek_ex", "shutdown",
352            ];
353            return SSL_NET.contains(&op).then_some("Net");
354        }
355    }
356    // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
357    // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
358    // were silently classified network-free because reqwest wasn't recognized.)
359    if crate_name == "reqwest" || crate_name == "isahc" {
360        // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
361        // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
362        // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
363        // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
364        if path.ends_with("::send")
365            || path.ends_with("::execute")
366            || path == "reqwest::get"
367            || path == "reqwest::blocking::get"
368            || path == "isahc::get"
369        {
370            return Some("Net");
371        }
372        // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
373        // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
374        // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
375        // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
376        // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
377        // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
378        // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
379        // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
380        // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
381        // method is a literal string, and misses it (honest under-report) when the method is an
382        // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
383        if path.ends_with("::get")
384            || path.ends_with("::post")
385            || path.ends_with("::put")
386            || path.ends_with("::delete")
387            || path.ends_with("::patch")
388            || path.ends_with("::head")
389            || path.ends_with("::request")
390        {
391            return Some("Net");
392        }
393        return None;
394    }
395    if crate_name == "ureq" && path.ends_with("::call") {
396        return Some("Net");
397    }
398    // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
399    // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
400    // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
401    // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
402    // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
403    // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
404    if crate_name == "curl"
405        && (path.ends_with("::perform")
406            || path.ends_with("::send")
407            || path.ends_with("::recv")
408            || path.ends_with("::upkeep")
409            || path.ends_with("::action"))
410    {
411        return Some("Net");
412    }
413    // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
414    // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
415    // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
416    // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
417    // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
418    // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
419    match crate_name {
420        // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
421        "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
422        // hyper-util's pooled legacy Client + its TCP connectors.
423        "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
424        // hickory (trust-dns) resolver — issues DNS queries over the network.
425        "hickory_resolver"
426            if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
427                || path.ends_with("::resolve") => return Some("Net"),
428        // HTTP/3 over QUIC.
429        "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
430            || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
431        // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
432        // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
433        // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
434        // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
435        "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
436            || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
437            || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
438            || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
439            || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
440            || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
441            || path.ends_with("::finish") => return Some("Net"),
442        // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
443        "tokio_rustls" | "native_tls"
444            if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
445            return Some("Net"),
446        // AF_VSOCK host<->guest sockets — inter-process / VM comms.
447        "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
448            return Some("Ipc"),
449        // Loads the OS trust store from disk (cert files / keychain).
450        "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
451        // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
452        // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
453        // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
454        // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
455        // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
456        // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
457        "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
458            || path.ends_with("::increase_nofile_limit") => return Some("Env"),
459        // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
460        // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
461        // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
462        // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
463        // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
464        // read_tls), so flagging it would over-report Net on the pure decrypt step.
465        "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
466            || path.ends_with("::complete_io") => return Some("Net"),
467        // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
468        // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
469        "native_tls_crate" | "tokio_native_tls"
470            if path.ends_with("::connect") || path.ends_with("::accept")
471                || path.ends_with("::handshake") => return Some("Net"),
472        _ => {}
473    }
474    // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
475    // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
476    // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
477    // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
478    // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
479    // classified pure — a message-queue client reporting no I/O.)
480    if crate_name == "async_nats" {
481        if path.ends_with("::connect")
482            || path.contains("::publish")
483            || path.ends_with("::subscribe")
484            || path.ends_with("::queue_subscribe")
485            || path.contains("::request")
486            || path.ends_with("::flush")
487        {
488            return Some("Net");
489        }
490        return None;
491    }
492    if crate_name == "lapin" {
493        if path.ends_with("::connect")
494            || path.ends_with("::create_channel")
495            || path.contains("::basic_")
496            || path.contains("::queue_")
497            || path.contains("::exchange_")
498            || path.contains("::tx_")
499            || path.ends_with("::confirm_select")
500            || path.ends_with("::close")
501        {
502            return Some("Net");
503        }
504        return None;
505    }
506    // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
507    // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
508    if crate_name == "lettre" {
509        if path.ends_with("::send") || path.ends_with("::send_raw") {
510            return Some("Net");
511        }
512        return None;
513    }
514    // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
515    // and the socket read/write/send are network; Message constructors are pure. (Found on a
516    // tungstenite consumer: connect + send + read classified pure.)
517    if crate_name == "tungstenite" {
518        if path.ends_with("::connect")
519            || path.ends_with("::read")
520            || path.ends_with("::write")
521            || path.ends_with("::send")
522            || path.ends_with("::close")
523            || path.ends_with("::flush")
524            || path.ends_with("::read_message")
525            || path.ends_with("::write_message")
526        {
527            return Some("Net");
528        }
529        return None;
530    }
531    // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
532    // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
533    if crate_name == "elasticsearch" && path.ends_with("::send") {
534        return Some("Net");
535    }
536    // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
537    // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
538    // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
539    if crate_name == "tonic" {
540        if path.ends_with("::connect")
541            || path.ends_with("::unary")
542            || path.ends_with("::server_streaming")
543            || path.ends_with("::client_streaming")
544            || path.ends_with("::streaming")
545        {
546            return Some("Net");
547        }
548        return None;
549    }
550    // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
551    // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
552    // repro needs librdkafka/cmake, deferred.)
553    if crate_name == "rdkafka" {
554        if path.ends_with("::send")
555            || path.ends_with("::send_result")
556            || path.ends_with("::recv")
557            || path.ends_with("::poll")
558            || path.ends_with("::subscribe")
559            || path.ends_with("::commit")
560            || path.ends_with("::commit_message")
561            || path.ends_with("::commit_consumer_state")
562            || path.ends_with("::store_offset")
563            || path.ends_with("::seek")
564            || path.ends_with("::fetch_metadata")
565            || path.ends_with("::fetch_watermarks")
566            || path.ends_with("::flush")
567        {
568            return Some("Net");
569        }
570        return None;
571    }
572    // cap-std: capability-oriented std. I/O goes *through* a held capability handle
573    // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
574    // cap-std project's real I/O is detected and matches the capability it declared
575    // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
576    if crate_name.starts_with("cap_") {
577        if path.contains("::net::Unix") || path.contains("::os::") {
578            return Some("Ipc");
579        }
580        if path.contains("::net") {
581            return Some("Net");
582        }
583        if path.contains("::time") {
584            return Some("Clock");
585        }
586        if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
587            return Some("Fs");
588        }
589        return None;
590    }
591    // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
592    // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
593    // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
594    // `unix`.
595    if path.starts_with("tokio::net::Unix")
596        || path.starts_with("std::os::unix::net")
597        || path.starts_with("async_std::os::unix::net")
598        || path.starts_with("async_net::unix")
599    {
600        return Some("Ipc");
601    }
602    // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
603    // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
604    // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
605    // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
606    // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
607    // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
608    // scanning bandwhich — a packet sniffer — which reported Net 0.)
609    if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
610        if path.ends_with("::channel") || path.ends_with("::transport_channel") {
611            return Some("Net");
612        }
613        return None;
614    }
615    // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
616    // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
617    // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
618    // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
619    // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
620    // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
621    // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
622    if crate_name == "ignore" {
623        if path == "ignore::WalkBuilder::build"
624            || path == "ignore::WalkBuilder::build_parallel"
625            || path.ends_with("::WalkParallel::run")
626            // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
627            // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
628            // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
629            // surface, so it was silently pure under the covered-crate floor.
630            || path == "ignore::WalkBuilder::add_ignore"
631        {
632            return Some("Fs");
633        }
634        return None;
635    }
636    // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
637    // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
638    // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
639    // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
640    // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
641    // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
642    if crate_name == "notify" {
643        if path.ends_with("Watcher::new")
644            || path.ends_with("::recommended_watcher")
645            || path.ends_with("::watch")
646            || path.ends_with("::unwatch")
647        {
648            return Some("Fs");
649        }
650        return None;
651    }
652    // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
653    // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
654    // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
655    if path.ends_with("::to_socket_addrs")
656        || path == "std::net::lookup_host"
657        || path.ends_with("ToSocketAddrs::to_socket_addrs")
658    {
659        return Some("Net");
660    }
661    // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
662    // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
663    if path.starts_with("std::net::TcpStream")
664        || path.starts_with("std::net::TcpListener")
665        || path.starts_with("std::net::UdpSocket")
666        || path.starts_with("tokio::net::")
667    {
668        // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
669        // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
670        // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
671        // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
672        if path.ends_with("::local_addr")
673            || path.ends_with("::peer_addr")
674            || path.ends_with("::nodelay")
675            || path.ends_with("::ttl")
676            || path.ends_with("::take_error")
677        {
678            return None;
679        }
680        return Some("Net");
681    }
682    // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
683    // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
684    // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
685    // network-free — a network tool confidently reporting 0 Net.)
686    if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
687        return Some("Net");
688    }
689    // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
690    // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
691    // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
692    // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
693    // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
694    // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
695    if path.starts_with("async_std::net::")
696        || path.starts_with("mio::net::")
697        || crate_name == "async_net"
698    {
699        return Some("Net");
700    }
701    // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
702    // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
703    // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
704    // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
705    if DB_CRATES.contains(&crate_name) {
706        // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
707        // execution (round-trips to the server). sqlx is the outlier where bare `query()`
708        // only BUILDS — it keeps the narrow set below. (Found by running on a real
709        // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
710        if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
711            const PG: [&str; 19] = [
712                "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
713                "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
714                "::copy_in", "::copy_out", "::transaction", "::connect",
715                // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
716                // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
717                // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
718                // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
719                // tokio_postgres 0.7.10+.
720                "::query_row", "::query_map", "::query_and_then", "::execute_batch",
721                "::prepare_cached", "::query_typed",
722            ];
723            if PG.iter().any(|v| path.ends_with(v)) {
724                return Some("Db");
725            }
726            // rusqlite only: opening the database IS the connection establishment (`Connection::
727            // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
728            if crate_name == "rusqlite"
729                && (path.ends_with("::open")
730                    || path.ends_with("::open_in_memory")
731                    || path.ends_with("::open_with_flags"))
732            {
733                return Some("Db");
734            }
735            return None;
736        }
737        // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
738        // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
739        // connection establishment. The shared VERBS below only catch the low-level
740        // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
741        // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
742        if crate_name == "redis"
743            && (path.contains("Commands::")
744                || path.contains("::get_connection")
745                || path.contains("::get_async_connection")
746                || path.contains("::get_multiplexed_async_connection")
747                // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
748                // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
749                // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
750                || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
751                    && !path.ends_with("::clone"))
752                || path.ends_with("::query")
753                || path.ends_with("::query_async")
754                || path.ends_with("::req_command")
755                || path.ends_with("::req_packed_command")
756                || path.ends_with("::req_packed_commands"))
757        {
758            return Some("Db");
759        }
760        // mongodb: a document-store API with none of the SQL verbs — the user calls
761        // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
762        // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
763        // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
764        // and option/doc builders don't match these verbs, so they stay pure.
765        if crate_name == "mongodb" {
766            const MONGO: [&str; 27] = [
767                "::with_uri_str", "::connect", "::find", "::find_one", "::insert_one",
768                "::insert_many", "::update_one", "::update_many", "::delete_one",
769                "::delete_many", "::replace_one", "::aggregate", "::count_documents",
770                "::estimated_document_count", "::count", "::distinct", "::run_command",
771                "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
772                "::list_collections", "::list_collection_names", "::list_databases",
773                "::list_database_names", "::create_collection", "::create_index", "::watch",
774            ];
775            if MONGO.iter().any(|v| path.ends_with(v)) {
776                return Some("Db");
777            }
778            return None;
779        }
780        // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
781        // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
782        // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
783        // API (unit-tested; a real-app repro is the remaining confirmation).
784        if matches!(crate_name, "mysql" | "mysql_async") {
785            const MY: [&str; 16] = [
786                "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
787                "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
788                "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
789            ];
790            if MY.iter().any(|v| path.ends_with(v)) {
791                return Some("Db");
792            }
793            return None;
794        }
795        // sea_orm: an ORM whose execution is split from building (like sqlx). The query
796        // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
797        // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
798        // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
799        // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
800        // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
801        if crate_name == "sea_orm" {
802            // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
803            // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
804            // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
805            // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
806            // exclude the whole re-exported namespace first.
807            if path.contains("sea_query") {
808                return None;
809            }
810            if path.ends_with("::all")
811                || path.ends_with("::one")
812                || path.ends_with("::count")
813                || path.ends_with("::stream")
814                || path.ends_with("::exec")
815                || path.ends_with("::exec_with_returning")
816                || path.ends_with("::exec_without_returning")
817                || path.ends_with("::connect")
818                || path.ends_with("::execute")
819                || path.ends_with("::execute_unprepared")
820                || path.ends_with("::query_one")
821                || path.ends_with("::query_all")
822                || path.ends_with("::fetch_page")
823                || path.ends_with("::num_items")
824                || path.contains("ActiveModelTrait::")
825            {
826                return Some("Db");
827            }
828            return None;
829        }
830        // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
831        // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
832        // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
833        const VERBS: [&str; 19] = [
834            "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
835            "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
836            "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
837            "::first", "::get_result", "::get_results",
838        ];
839        if VERBS.iter().any(|v| path.ends_with(v)) {
840            return Some("Db");
841        }
842        return None;
843    }
844    // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
845    // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
846    // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
847    // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
848    // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
849    // fd's residual `Path::symlink_metadata` under-report.)
850    if let Some(m) = path
851        .strip_prefix("std::path::Path::")
852        .or_else(|| path.strip_prefix("std::path::PathBuf::"))
853    {
854        const STAT: &[&str] = &[
855            "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
856            "try_exists", "is_file", "is_dir", "is_symlink",
857        ];
858        return STAT.contains(&m).then_some("Fs");
859    }
860    // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
861    // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
862    if path.starts_with("std::fs::")
863        || path.starts_with("tokio::fs::")
864        || path.starts_with("async_std::fs::")
865        || crate_name == "async_fs"
866        || crate_name == "fs_err"
867    {
868        return Some("Fs");
869    }
870    // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
871    // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
872    // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
873    // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
874    // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
875    // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
876    // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
877    // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
878    if crate_name == "memmap2" {
879        let m = path.rsplit("::").next().unwrap_or(path);
880        if m.starts_with("map")
881            || m == "flush"
882            || m == "flush_async"
883            || m == "flush_range"
884            || m == "flush_async_range"
885            || m == "remap"
886            || m.starts_with("make_")
887            || m == "advise"
888            || m == "advise_range"
889            || m == "lock"
890            || m == "unlock"
891        {
892            return Some("Fs");
893        }
894        return None;
895    }
896    // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
897    // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
898    // the file on disk; `close` removes it.
899    if crate_name == "tempfile"
900        && (path.ends_with("::tempfile")
901            || path.ends_with("::tempfile_in")
902            || path.ends_with("::tempdir")
903            || path.ends_with("::tempdir_in")
904            || path.ends_with("NamedTempFile::new")
905            || path.ends_with("NamedTempFile::new_in")
906            || path.ends_with("TempDir::new")
907            || path.ends_with("TempDir::new_in")
908            || path.ends_with("::persist")
909            || path.ends_with("::persist_noclobber")
910            || path.ends_with("::keep"))
911    {
912        return Some("Fs");
913    }
914    // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
915    // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
916    if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
917        return Some("Fs");
918    }
919    // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
920    // argon2 landed on exactly the call a security review cares about). In this engine's
921    // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
922    // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
923    // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
924    // carries the OsRng source itself (otherwise the most common salt mint is invisible).
925    if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
926        if path.contains("SaltString::generate") {
927            return Some("Rand");
928        }
929        return None;
930    }
931    if crate_name == "bcrypt" {
932        if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
933            return Some("Rand");
934        }
935        return None;
936    }
937    if crate_name == "rand_core" {
938        if path.contains("OsRng")
939            || path.ends_with("::next_u32")
940            || path.ends_with("::next_u64")
941            || path.ends_with("::fill_bytes")
942        {
943            return Some("Rand");
944        }
945        return None;
946    }
947    // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
948    // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
949    // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
950    // over-reported those as `Rand`; match only the calls that actually consume randomness — the
951    // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
952    // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
953    if crate_name == "getrandom" {
954        return Some("Rand");
955    }
956    // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
957    // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
958    // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
959    // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
960    // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
961    // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
962    // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
963    // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
964    // (i.e. a value draw) is Rand.
965    if crate_name == "fastrand" {
966        let m = path.rsplit("::").next().unwrap_or(path);
967        // Provably pure: deterministic seeded ctor + state split/copy.
968        if m == "with_seed" || m == "fork" || m == "clone" {
969            return None;
970        }
971        // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
972        // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
973        // method we don't recognise — keep the effect, the safe direction.)
974        return Some("Rand");
975    }
976    if crate_name == "rand" {
977        let rng_verb = path.ends_with("::gen")
978            || path.ends_with("::gen_range")
979            || path.ends_with("::gen_bool")
980            || path.ends_with("::gen_ratio")
981            || path.ends_with("::random")
982            || path.ends_with("::random_range")
983            || path.ends_with("::random_bool")
984            || path.ends_with("::random_ratio")
985            || path.ends_with("::random_iter") // rand 0.9 iterator generator
986            || path.ends_with("::gen_iter")
987            || path.ends_with("::fill")
988            || path.ends_with("::fill_bytes")
989            || path.ends_with("::try_fill")
990            || path.ends_with("::try_fill_bytes")
991            || path.ends_with("::sample")
992            || path.ends_with("::sample_iter")
993            || path.ends_with("::next_u32")
994            || path.ends_with("::next_u64")
995            || path.ends_with("::thread_rng")
996            || path.ends_with("::rng")
997            || path.ends_with("::from_entropy")
998            || path.ends_with("::from_os_rng");
999        // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
1000        // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
1001        // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
1002        // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
1003        // review: OsRng is a unit struct, cloning consumes nothing).
1004        let m = path.rsplit("::").next().unwrap_or(path);
1005        let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
1006        if rng_verb || os_rng {
1007            return Some("Rand");
1008        }
1009        return None;
1010    }
1011    // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
1012    // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
1013    // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
1014    // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
1015    // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
1016    if path.starts_with("std::process::Command")
1017        || path.starts_with("std::process::Child")
1018        || path.starts_with("tokio::process::Command")
1019        || path.starts_with("tokio::process::Child")
1020        || path.starts_with("async_std::process::Command")
1021        || path.starts_with("async_std::process::Child")
1022    {
1023        // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
1024        // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
1025        // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
1026        // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
1027        if path.ends_with("::get_program")
1028            || path.ends_with("::get_args")
1029            || path.ends_with("::get_envs")
1030            || path.ends_with("::get_current_dir")
1031            || path.ends_with("Child::id")
1032        {
1033            return None;
1034        }
1035        return Some("Exec");
1036    }
1037    // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1038    // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1039    // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1040    // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1041    // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1042    // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1043    // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1044    // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1045    if crate_name == "async_process" || crate_name == "portable_pty" {
1046        let m = path.rsplit("::").next().unwrap_or(path);
1047        // configuration read-back getters — pure (no spawn).
1048        if m.starts_with("get_") || m == "as_unix_command_line" {
1049            return None;
1050        }
1051        // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
1052        if matches!(
1053            m,
1054            "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
1055                | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
1056                | "cwd" | "current_dir" | "rows" | "cols"
1057                | "clone" | "fmt" | "eq" | "ne" | "hash"
1058        ) {
1059            return None;
1060        }
1061        return Some("Exec");
1062    }
1063    // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
1064    // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
1065    if crate_name == "duct"
1066        && (path.ends_with("::run")
1067            || path.ends_with("::read")
1068            || path.ends_with("::start")
1069            || path.ends_with("::read_chars"))
1070    {
1071        return Some("Exec");
1072    }
1073    if path.starts_with("std::env::") {
1074        return Some("Env");
1075    }
1076    // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
1077    // environment). Match the load/read entry points; `Error`/builder types stay pure.
1078    if matches!(crate_name, "dotenvy" | "dotenv")
1079        && (path.ends_with("::dotenv")
1080            || path.ends_with("::dotenv_override")
1081            || path.ends_with("::from_path")
1082            || path.ends_with("::from_path_override")
1083            || path.ends_with("::from_filename")
1084            || path.ends_with("::from_filename_override")
1085            || path.ends_with("::from_read")
1086            || path.ends_with("::from_read_override")
1087            || path.ends_with("::load")
1088            || path.ends_with("::var")
1089            || path.ends_with("::vars"))
1090    {
1091        return Some("Env");
1092    }
1093    // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
1094    // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
1095    // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
1096    if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
1097        return Some("Clock");
1098    }
1099    if crate_name == "time"
1100        && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
1101    {
1102        return Some("Clock");
1103    }
1104    // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
1105    // the emit, not the whole crate. The actual program output is the macro-expanded
1106    // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
1107    // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
1108    // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
1109    // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
1110    // fabricated Log on them. Match the emit verbs; everything else returns None.
1111    if crate_name == "tracing" {
1112        let m = path.rsplit("::").next().unwrap_or(path);
1113        // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
1114        // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
1115        // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
1116        // have other tails and stay None.
1117        if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
1118            || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
1119            || m == "error_span" || m == "span"
1120            || m == "event"
1121            || m == "new_span"
1122            || m == "record"
1123            || m == "record_follows_from"
1124            || m == "enter"
1125            || m == "exit"
1126            || m == "in_scope"
1127            || m == "entered"
1128            || path.contains("::__macro_support")
1129            || path.contains("::__tracing")
1130            || path.contains("Subscriber::event")
1131            || path.contains("Subscriber::new_span")
1132            || path.contains("Subscriber::enter")
1133            || path.contains("Subscriber::exit")
1134        {
1135            return Some("Log");
1136        }
1137        return None;
1138    }
1139    // The `log` facade: its macros route through `log::__private_api`; the crate's types
1140    // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
1141    if crate_name == "log" {
1142        // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
1143        // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
1144        let m = path.rsplit("::").next().unwrap_or(path);
1145        if path.contains("::__private_api")
1146            || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
1147        {
1148            return Some("Log");
1149        }
1150    }
1151    // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
1152    // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
1153    // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
1154    // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
1155    // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
1156    if crate_name == "rustc_lint"
1157        && (path.ends_with("::emit_span_lint")
1158            || path.ends_with("::span_lint")
1159            || path.ends_with("::span_lint_hir"))
1160    {
1161        return Some("Log");
1162    }
1163    if crate_name == "rustc_errors"
1164        && (path.ends_with("::emit")
1165            || path.ends_with("::emit_diagnostic")
1166            || path.ends_with("::emit_now"))
1167    {
1168        return Some("Log");
1169    }
1170    // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
1171    // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
1172    // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
1173    // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
1174    // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
1175    // connection to the clipboard server, so it's an effect too; `get`/`set` return the
1176    // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
1177    if crate_name == "arboard" {
1178        let m = path.rsplit("::").next().unwrap_or(path);
1179        if m == "new"
1180            || m == "get"
1181            || m == "set"
1182            || m == "clear"
1183            || m == "get_text"
1184            || m == "set_text"
1185            || m == "set_html"
1186            || m == "get_image"
1187            || m == "set_image"
1188            || m == "text"
1189            || m == "image"
1190            || m == "html"
1191        {
1192            return Some("Clipboard");
1193        }
1194        return None;
1195    }
1196    // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
1197    //    notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
1198
1199    // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
1200    // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
1201    // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
1202    // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
1203    if crate_name == "etcetera" {
1204        let m = path.rsplit("::").next().unwrap_or(path);
1205        if m == "home_dir"
1206            || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
1207            || m == "config_dir" || m == "data_dir" || m == "cache_dir"
1208            || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
1209        {
1210            return Some("Env");
1211        }
1212        return None;
1213    }
1214    // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
1215    // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
1216    // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
1217    // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
1218    if crate_name == "sqlx_core" {
1219        if path.ends_with("::connect") || path.ends_with("::connect_with") {
1220            return Some("Net");
1221        }
1222        if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
1223            || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
1224            || path.ends_with("::execute") || path.ends_with("::execute_many")
1225            || path.ends_with("::prepare") || path.ends_with("::prepare_with")
1226            || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
1227        {
1228            return Some("Db");
1229        }
1230        return None;
1231    }
1232    // `walkdir` — recursive directory traversal. The disk read (`read_dir` + `stat`) happens lazily in
1233    // `IntoIter::next` (driving the iterator), and `DirEntry::metadata` issues a `stat`. The
1234    // `WalkDir::new`/`max_depth`/`follow_links`/`sort_by` BUILDERS, `WalkDir::into_iter` (constructs the
1235    // iterator, no I/O until pulled), and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
1236    // accessors (`file_type` makes NO syscall) are PURE. (Companion to the already-modeled `ignore`.)
1237    if crate_name == "walkdir" {
1238        if path.ends_with("::IntoIter::next") || path.ends_with("::DirEntry::metadata") {
1239            return Some("Fs");
1240        }
1241        return None;
1242    }
1243    // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
1244    // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
1245    // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
1246    // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
1247    if crate_name == "filetime" {
1248        if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
1249            || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
1250            || path.ends_with("::set_file_handle_times")
1251        {
1252            return Some("Fs");
1253        }
1254        if path.ends_with("::FileTime::now") {
1255            return Some("Clock");
1256        }
1257        return None;
1258    }
1259    // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
1260    // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
1261    // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
1262    if crate_name == "execute" {
1263        if path.contains("::execute") {
1264            return Some("Exec");
1265        }
1266        return None;
1267    }
1268    // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
1269    // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
1270    // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
1271    if crate_name == "ctrlc" {
1272        if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
1273            return Some("Ipc");
1274        }
1275        return None;
1276    }
1277    // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
1278    // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
1279    // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
1280    // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
1281    // read argv) so they stay pure too. (`Arg::env` reads an env var at builder time but bare `::env` is
1282    // too generic to gate safely, so it's left unmodeled — under-report over fabrication.)
1283    if crate_name == "clap" {
1284        if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
1285            || path.ends_with("::try_get_matches")
1286            || path.ends_with("::parse") || path.ends_with("::try_parse")
1287        {
1288            return Some("Env");
1289        }
1290        return None;
1291    }
1292    // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
1293    // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
1294    // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
1295    // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
1296    if crate_name == "jiff" {
1297        if path.ends_with("::now") || path.ends_with("::now_with") {
1298            return Some("Clock");
1299        }
1300        if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
1301            || path.ends_with("::TimeZoneDatabase::get")
1302        {
1303            return Some("Fs");
1304        }
1305        return None;
1306    }
1307    // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
1308    // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
1309    // `Builder::new`/`build` and the format/filter/target config setters are PURE.
1310    if crate_name == "env_logger" {
1311        if path.ends_with("::init") || path.ends_with("::try_init")
1312            || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
1313        {
1314            return Some("Log");
1315        }
1316        return None;
1317    }
1318    // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
1319    // console dialogue with the user — Ipc, like the other local-channel effects). The
1320    // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
1321    if crate_name == "dialoguer" {
1322        if path.ends_with("::interact") || path.ends_with("::interact_on")
1323            || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
1324            || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
1325        {
1326            return Some("Ipc");
1327        }
1328        return None;
1329    }
1330    // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
1331    // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
1332    // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
1333    // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
1334    if crate_name == "console" {
1335        if path.ends_with("::write_line") || path.ends_with("::read_line")
1336            || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
1337            || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
1338            || path.ends_with("::read_secure_line")
1339        {
1340            return Some("Ipc");
1341        }
1342        if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
1343            || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
1344        {
1345            return Some("Env");
1346        }
1347        return None;
1348    }
1349    // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
1350    // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
1351    if crate_name == "terminal_colorsaurus" {
1352        if path.ends_with("::background_color") || path.ends_with("::foreground_color")
1353            || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
1354        {
1355            return Some("Ipc");
1356        }
1357        return None;
1358    }
1359    // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
1360    // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
1361    // effects are out of scope here — we model only backoff's own Clock effect.)
1362    if crate_name == "backoff" {
1363        if path.ends_with("::retry") || path.ends_with("::retry_notify") {
1364            return Some("Clock");
1365        }
1366        return None;
1367    }
1368    // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
1369    // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
1370    if crate_name == "lscolors" {
1371        if path.ends_with("::from_env") {
1372            return Some("Env");
1373        }
1374        return None;
1375    }
1376    // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
1377    if crate_name == "wild" {
1378        if path.ends_with("::args") || path.ends_with("::args_os") {
1379            return Some("Env");
1380        }
1381        return None;
1382    }
1383    // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
1384    // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
1385    // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
1386    // they read no data; flagging them would be an inconsistent over-report.
1387    if crate_name == "grep_cli" {
1388        if path.ends_with("::build") {
1389            return Some("Exec");
1390        }
1391        return None;
1392    }
1393    // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
1394    // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
1395    // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
1396    // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
1397    if crate_name == "clircle" {
1398        if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
1399            return Some("Fs");
1400        }
1401        return None;
1402    }
1403    None
1404}
1405
1406pub fn cap_from_name(name: &str) -> Option<&'static str> {
1407    EFFECTS.iter().copied().find(|e| *e == name)
1408}
1409
1410/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
1411/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
1412/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
1413/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
1414/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
1415/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
1416/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
1417/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
1418/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
1419/// the one boundary every engine hits — refines identically (the §4-consistency argument).
1420pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
1421    // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
1422    // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
1423    // the under-report rule forbids it, so such heads keep the bare cliff.
1424    match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
1425        "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
1426        "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
1427        // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
1428        "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
1429        | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
1430        _ => &[],
1431    }
1432}
1433
1434/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
1435/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
1436/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
1437/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
1438/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
1439/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
1440/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
1441/// discloses an uncovered provider like any other.
1442pub const MODEL_HOSTS: &[&str] = &[
1443    "api.openai.com",
1444    "api.anthropic.com",
1445    "generativelanguage.googleapis.com",
1446    "api.mistral.ai",
1447    "api.cohere.ai",
1448    "api.cohere.com",
1449    "api.groq.com",
1450    "api.together.xyz",
1451    "api.perplexity.ai",
1452    "openrouter.ai",
1453];
1454
1455/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
1456/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
1457/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
1458/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
1459/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
1460pub fn is_model_host(host_literal: &str) -> bool {
1461    // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
1462    let host = policy::host_part(host_literal).to_ascii_lowercase();
1463    // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
1464    // host on :11434" fabricated Llm on unrelated internal services on that port).
1465    if let Some((_, port)) = host_literal.rsplit_once(':') {
1466        if port == "11434" {
1467            return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
1468        }
1469    }
1470    if MODEL_HOSTS.contains(&host.as_str()) {
1471        return true;
1472    }
1473    // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
1474    if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
1475        return true;
1476    }
1477    // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
1478    // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
1479    // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
1480    host.ends_with(".amazonaws.com")
1481        && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
1482}
1483
1484/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
1485/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
1486/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
1487/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
1488/// under-gate `deny Net[unknown-host]`.
1489pub const TELEMETRY_HOSTS: &[&str] = &[
1490    "sentry.io",
1491    "bugsnag.com",
1492    "rollbar.com",
1493    "segment.io",
1494    "segment.com",
1495    "mixpanel.com",
1496    "amplitude.com",
1497    "google-analytics.com",
1498    "analytics.google.com",
1499    "datadoghq.com",
1500    "datadoghq.eu",
1501    "newrelic.com",
1502    "nr-data.net",
1503    "honeycomb.io",
1504    "logtail.com",
1505    // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
1506    // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
1507    "posthog.com",
1508    "plausible.io",
1509    "usefathom.com",
1510    "heapanalytics.com",
1511    "fullstory.com",
1512    "hotjar.com",
1513    "logrocket.com",
1514    "cloudflareinsights.com",
1515];
1516
1517/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
1518/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
1519/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
1520pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
1521    let host = policy::host_part(host_literal).to_ascii_lowercase();
1522    set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
1523}
1524
1525/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
1526pub fn is_telemetry_host(host_literal: &str) -> bool {
1527    host_in_set(host_literal, TELEMETRY_HOSTS)
1528}
1529
1530/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
1531/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
1532/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
1533/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
1534/// Mirrors candor-java's `Literals.netDestClass`.
1535pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
1536    if is_telemetry_host(host_literal) {
1537        return "known-telemetry";
1538    }
1539    let host = policy::host_part(host_literal).to_ascii_lowercase();
1540    let partner_match = partners.contains(&host)
1541        || partners.iter().any(|p| host.ends_with(&format!(".{p}")));
1542    if partner_match || is_model_host(host_literal) {
1543        return "known-partner";
1544    }
1545    "unknown-host"
1546}
1547
1548/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
1549pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
1550
1551/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
1552/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
1553/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
1554/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
1555/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
1556pub const MODEL_SDK_CRATES: &[&str] = &[
1557    "async_openai",           // async-openai — the de-facto OpenAI client
1558    "anthropic_sdk",          // anthropic-sdk
1559    "anthropic",              // anthropic (community client crate)
1560    "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
1561    "ollama_rs",              // ollama-rs — local Ollama client
1562    "langchain_rust",         // langchain-rust — the LangChain invoke surfaces
1563    "mistralai",              // mistralai (Mistral client)
1564    "genai",                  // genai — a multi-provider model client
1565];
1566
1567/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
1568/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
1569/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
1570pub fn is_model_sdk_crate(crate_name: &str) -> bool {
1571    MODEL_SDK_CRATES.contains(&crate_name)
1572}
1573
1574/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
1575/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
1576/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
1577/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
1578/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
1579/// method is the call path's last segment.
1580pub fn is_cmd_builder_method(method: &str) -> bool {
1581    matches!(
1582        method,
1583        "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
1584            | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
1585            | "groups" | "process_group"
1586    )
1587}
1588
1589/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
1590/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
1591/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
1592/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
1593/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
1594/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
1595/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
1596/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
1597/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
1598pub fn is_cmd_naming_method(method: &str) -> bool {
1599    matches!(method, "new" | "cmd")
1600}
1601
1602/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
1603/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
1604/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
1605/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
1606/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
1607/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
1608/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
1609/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
1610pub fn is_net_establishing(method: &str) -> bool {
1611    matches!(
1612        method,
1613        "connect"
1614            | "connect_timeout"
1615            | "get"
1616            | "post"
1617            | "put"
1618            | "patch"
1619            | "delete"
1620            | "head"
1621            | "request"
1622            | "send_to"
1623            | "lookup_host"
1624            | "to_socket_addrs"
1625    )
1626}
1627
1628/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
1629/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
1630/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
1631/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
1632/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
1633/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
1634/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
1635/// is not here, so a missing literal there never false-positives. Under-catching an unusual
1636/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
1637/// method/fn leaf (the path's last segment).
1638pub fn is_fs_path_arg(leaf: &str) -> bool {
1639    matches!(
1640        leaf,
1641        // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
1642        "write"
1643            | "read"
1644            | "read_to_string"
1645            | "read_dir"
1646            | "read_link"
1647            | "copy"
1648            | "rename"
1649            | "remove_file"
1650            | "remove_dir"
1651            | "remove_dir_all"
1652            | "create_dir"
1653            | "create_dir_all"
1654            | "hard_link"
1655            | "soft_link"
1656            | "symlink"
1657            | "symlink_file"
1658            | "symlink_dir"
1659            | "symlink_metadata"
1660            | "canonicalize"
1661            | "metadata"
1662            | "set_permissions"
1663            | "exists"
1664            | "try_exists"
1665            // File / OpenOptions constructors taking a path argument
1666            | "open"
1667            | "create"
1668            | "create_new"
1669    )
1670}
1671
1672/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
1673/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
1674/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
1675/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
1676/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
1677/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
1678/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
1679/// structurally (never a maskable string literal) so a missing literal must not false-positive.
1680/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
1681/// The arg is the method leaf (the path's last segment).
1682pub fn is_db_query_arg(leaf: &str) -> bool {
1683    matches!(
1684        leaf,
1685        "execute"
1686            | "execute_batch"
1687            | "execute_unprepared"
1688            | "batch_execute"
1689            | "simple_query"
1690            | "query"
1691            | "query_one"
1692            | "query_opt"
1693            | "query_raw"
1694            | "query_row"
1695            | "query_map"
1696            | "query_and_then"
1697            | "query_typed"
1698            | "query_all"
1699            | "prepare"
1700            | "prepare_typed"
1701            | "prepare_cached"
1702            | "exec"
1703            | "exec_first"
1704            | "exec_iter"
1705            | "exec_map"
1706            | "exec_fold"
1707            | "exec_drop"
1708            | "exec_batch"
1709            | "prep"
1710            | "run_command"
1711    )
1712}
1713
1714/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
1715/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
1716/// treats it as a declared capability, exactly like its own `&Fs` token.
1717pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
1718    if !crate_name.starts_with("cap_") {
1719        return None;
1720    }
1721    Some(match type_name {
1722        "Dir" => "Fs",
1723        "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
1724        "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
1725        "SystemClock" | "MonotonicClock" => "Clock",
1726        _ => return None,
1727    })
1728}
1729
1730/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
1731/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
1732/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
1733/// open with a SQL statement keyword, and only identifiers in table position are taken —
1734/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
1735/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
1736/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
1737/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
1738/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
1739/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
1740/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
1741pub fn tables_in_sql(sql: &str) -> Vec<String> {
1742    const STMT: &[&str] =
1743        &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
1744    // Tokens that can FOLLOW a table-introducing keyword without being a table.
1745    const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
1746    // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
1747    const STOP: &[&str] = &[
1748        "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
1749        "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
1750        "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
1751        "from", "join", "into", "update", "delete", "insert",
1752    ];
1753    // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
1754    // list without fabricating from other comma-ridden positions (column lists, ON clauses).
1755    let cleaned: String = sql
1756        .to_lowercase()
1757        .chars()
1758        .flat_map(|c| match c {
1759            '(' | ')' | ';' => vec![' '],
1760            ',' => vec![' ', ',', ' '],
1761            _ => vec![c],
1762        })
1763        .collect();
1764    let toks: Vec<&str> = cleaned.split_whitespace().collect();
1765    let Some(first) = toks.first() else { return Vec::new() };
1766    if !STMT.contains(first) {
1767        return Vec::new(); // not SQL — nothing to certify, nothing fabricated
1768    }
1769    let ident = |t: &str| -> Option<String> {
1770        let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
1771        let mut chars = t.chars();
1772        let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
1773        let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
1774        (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
1775    };
1776    let mut out: Vec<String> = Vec::new();
1777    let mut push = |t: Option<String>| {
1778        if let Some(t) = t {
1779            if !out.contains(&t) {
1780                out.push(t);
1781            }
1782        }
1783    };
1784    for (i, tok) in toks.iter().enumerate() {
1785        let table_pos = match *tok {
1786            "from" | "join" | "into" | "table" => true,
1787            // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
1788            "update" | "truncate" => i == 0,
1789            _ => false,
1790        };
1791        if !table_pos {
1792            continue;
1793        }
1794        let mut j = i + 1;
1795        while j < toks.len() && SKIP.contains(&toks[j]) {
1796            j += 1;
1797        }
1798        let Some(next) = toks.get(j) else { continue };
1799        let Some(first) = ident(next) else { continue };
1800        push(Some(first));
1801        // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
1802        // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
1803        // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
1804        // list, whose parens are spaces by the time we tokenize).
1805        while j + 2 < toks.len() && toks[j + 1] == "," {
1806            let Some(more) = ident(toks[j + 2]) else { break };
1807            push(Some(more));
1808            j += 2;
1809        }
1810    }
1811    out
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816    #[test]
1817    fn model_host_recognizes_known_providers_and_special_forms() {
1818        use super::is_model_host as m;
1819        // exact known hosts (case-insensitive), with/without a port
1820        assert!(m("api.openai.com"));
1821        assert!(m("API.OpenAI.com"));
1822        assert!(m("api.anthropic.com:443"));
1823        assert!(m("generativelanguage.googleapis.com"));
1824        assert!(m("api.mistral.ai"));
1825        assert!(m("api.cohere.ai"));
1826        assert!(m("api.cohere.com")); // BOTH cohere hosts
1827        assert!(m("api.groq.com"));
1828        assert!(m("api.together.xyz"));
1829        assert!(m("api.perplexity.ai"));
1830        assert!(m("openrouter.ai"));
1831        // a subdomain of a known host counts
1832        assert!(m("eu.api.openai.com"));
1833        // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
1834        assert!(m("localhost:11434"));
1835        assert!(m("127.0.0.1:11434"));
1836        assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
1837        // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
1838        assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
1839        assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
1840        assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
1841        // NOT model hosts (never guessed)
1842        assert!(!m("example.com"));
1843        assert!(!m("api.stripe.com"));
1844        assert!(!m("localhost:8080")); // a non-Ollama local port
1845        assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
1846        assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
1847        assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
1848        assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
1849    }
1850
1851    #[test]
1852    fn model_sdk_crate_is_crate_level_no_method_gating() {
1853        use super::is_model_sdk_crate as s;
1854        assert!(s("async_openai"));
1855        assert!(s("aws_sdk_bedrockruntime"));
1856        assert!(s("ollama_rs"));
1857        assert!(s("langchain_rust"));
1858        assert!(!s("reqwest"));
1859        assert!(!s("aws_sdk_s3"));
1860    }
1861
1862    #[test]
1863    fn sql_table_extraction_is_conservative() {
1864        use super::tables_in_sql as t;
1865        assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
1866        assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
1867                   vec!["ledger.entries", "customers"]);
1868        assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
1869        assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
1870        assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
1871        assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
1872        assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
1873        // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
1874        assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
1875        // a subquery in FROM position yields nothing for that position
1876        assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
1877        // not SQL -> nothing (never fabricate)
1878        assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
1879        assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
1880        // comma-ADJACENT continuation: a FROM list takes every table in the chain…
1881        assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
1882        // …but an alias breaks it (under-report, never a guess)…
1883        assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
1884        // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
1885        assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
1886        // a subquery after the comma stops the chain too
1887        assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
1888    }
1889
1890    use super::*;
1891
1892    #[test]
1893    fn db_crates_are_calibrated() {
1894        // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
1895        // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
1896        for c in DB_CRATES {
1897            assert!(
1898                CALIBRATED_CRATES.contains(&c),
1899                "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
1900            );
1901        }
1902    }
1903
1904    #[test]
1905    fn calibrated_crates_are_live() {
1906        // Conversely, every crate advertised as calibrated must actually be matched by classify() for
1907        // some representative path — a dead entry would silently suppress a real coverage warning.
1908        for c in CALIBRATED_CRATES {
1909            assert!(
1910                CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
1911                "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
1912            );
1913        }
1914    }
1915
1916    #[test]
1917    fn async_http_stack_classifies() {
1918        // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
1919        // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
1920        assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
1921        assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
1922        assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
1923        assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
1924        assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
1925        assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
1926        assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
1927        assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
1928        assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
1929        assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
1930        assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
1931        assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
1932        // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
1933        assert_eq!(classify("num_cpus", "num_cpus::get"), None);
1934        assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
1935        // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
1936        assert_eq!(classify("hyper", "hyper::Request::builder"), None);
1937        assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
1938        assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
1939        assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
1940    }
1941
1942    #[test]
1943    fn coverage_differential_crates_classify() {
1944        // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
1945        // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
1946        // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
1947
1948        // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
1949        assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
1950        assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
1951        assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
1952        assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
1953        assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
1954
1955        // native-tls variants — handshake is Net; builder is pure.
1956        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
1957        assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
1958        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
1959
1960        // etcetera — dir resolution reads env; the args data type is pure.
1961        assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
1962        assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
1963        assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
1964        assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
1965
1966        // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
1967        assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
1968        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
1969        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
1970        assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
1971        assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
1972
1973        // walkdir — the lazy read happens in next()/metadata(); builders + cached accessors pure.
1974        assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
1975        assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
1976        assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), None); // builder
1977        assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
1978        assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
1979
1980        // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
1981        assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
1982        assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
1983        assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
1984        assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
1985        assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
1986
1987        // execute — the execute* verbs spawn (Exec); command/shell builders pure.
1988        assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
1989        assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
1990        assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
1991        assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
1992        assert_eq!(classify("execute", "execute::shell"), None);
1993
1994        // ctrlc — install signal handler (Ipc).
1995        assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
1996        assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
1997
1998        // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
1999        assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
2000        assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
2001        assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
2002        assert_eq!(classify("clap", "clap::Command::new"), None); // builder
2003        assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
2004        assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
2005
2006        // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
2007        assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
2008        assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
2009        assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
2010        assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
2011        assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
2012
2013        // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
2014        assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
2015        assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
2016        assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
2017        assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
2018        assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
2019
2020        // dialoguer — interact* is tty I/O (Ipc); builders pure.
2021        assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
2022        assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
2023        assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
2024        assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
2025
2026        // console — Term I/O is Ipc, detection is Env, Style is pure.
2027        assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
2028        assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
2029        assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
2030        assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
2031        assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
2032
2033        // terminal_colorsaurus — tty colour query (Ipc).
2034        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
2035        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
2036
2037        // backoff — retry sleeps + reads the clock (Clock); config pure.
2038        assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
2039        assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
2040        assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
2041
2042        // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
2043        assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
2044        assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
2045        assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
2046
2047        // wild — argv readers (Env).
2048        assert_eq!(classify("wild", "wild::args"), Some("Env"));
2049        assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
2050
2051        // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
2052        assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
2053        assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
2054        assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
2055
2056        // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
2057        assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
2058        assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
2059    }
2060
2061    #[test]
2062    fn log_tracing_emit_macros_classify_pre_expansion() {
2063        // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
2064        // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
2065        assert_eq!(classify("log", "log::info"), Some("Log"));
2066        assert_eq!(classify("log", "log::error"), Some("Log"));
2067        assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
2068        assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
2069        // pure data-type surface stays None (no fabricated Log)
2070        assert_eq!(classify("log", "log::Level::as_str"), None);
2071        assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
2072    }
2073
2074    #[test]
2075    fn classify_core_effects() {
2076        // A representative smoke test of the classifier's main families, so the published crate is not
2077        // shipped untested (these used to live only in the nightly-only src/lib.rs).
2078        assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
2079        // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
2080        // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
2081        assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
2082        assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
2083        assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
2084        assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
2085        assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
2086        assert_eq!(classify("std", "std::path::Path::parent"), None);
2087        assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
2088        assert_eq!(classify("std", "std::env::var"), Some("Env"));
2089        assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
2090        // one-shot convenience fns send immediately → Net.
2091        assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
2092        assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
2093        // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
2094        // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
2095        // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
2096        assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
2097        assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
2098        assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
2099        assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
2100        assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
2101        // the PURE builder surface stays None (no URL, no dispatch).
2102        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
2103        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
2104        assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
2105        // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
2106        // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
2107        // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
2108        // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
2109        // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
2110        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
2111        assert_eq!(classify("libc", "libc::socket"), Some("Net"));
2112        assert_eq!(classify("libc", "libc::bind"), Some("Net"));
2113        assert_eq!(classify("libc", "libc::accept"), Some("Net"));
2114        // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
2115        assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
2116        assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
2117        assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
2118        assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
2119        assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
2120        assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
2121        // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
2122        assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
2123        assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
2124        assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
2125        assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
2126        // pnet raw packet capture: channel openers are Net, packet construction stays pure.
2127        assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
2128        assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
2129        assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
2130        assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
2131        assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
2132        // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
2133        assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
2134        assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
2135        assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
2136        assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
2137        assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
2138        assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
2139        assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
2140        // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
2141        assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
2142        assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
2143        assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
2144        assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
2145        assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
2146        assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
2147        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
2148        // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
2149        assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
2150        assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
2151        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
2152        assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
2153        assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
2154        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
2155        // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
2156        assert_eq!(classify("postgres", "postgres::Client::open"), None);
2157        assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
2158        // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
2159        assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
2160        assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
2161        assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
2162        // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
2163        assert_eq!(classify("sqlx", "sqlx::query"), None);
2164        // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
2165        // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
2166        // pure, same principle as the `log` facade).
2167        assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
2168        assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
2169        assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
2170        assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
2171        assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
2172        assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
2173        assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
2174        assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
2175        assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
2176        // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
2177        // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
2178        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
2179        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
2180        assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
2181        assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
2182        assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
2183        assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
2184        assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
2185        assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
2186        // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
2187        // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
2188        assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
2189        assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
2190        assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
2191        assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
2192        assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
2193        assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
2194        assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
2195        // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
2196        // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
2197        assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
2198        assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
2199        assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
2200        assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
2201        assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
2202        assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
2203        assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
2204        // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
2205        // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
2206        assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
2207        assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
2208        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
2209        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
2210        assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
2211        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
2212        assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
2213        assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
2214        assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
2215        assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
2216        // FFI tiers (matched by distinctive leaf, alias-independent)
2217        assert_eq!(classify("libc", "libc::open"), Some("Fs"));
2218        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
2219        assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
2220        assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
2221        assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
2222        // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
2223        // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
2224        // `src/build.rs` module was being dropped as if it were the Cargo build script).
2225        assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
2226        assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
2227        assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
2228        assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
2229        // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
2230        // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
2231        // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
2232        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
2233        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
2234        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
2235        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
2236        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
2237        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
2238        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
2239        // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
2240        assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
2241        assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
2242        assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
2243        assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
2244        assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
2245        assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
2246        // pure crates stay pure
2247        assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
2248        assert_eq!(classify("std", "std::vec::Vec::push"), None);
2249
2250        // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
2251        // [24] std::net socket accessors are pure; the I/O verbs stay Net.
2252        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2253        assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
2254        assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
2255        assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
2256        assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
2257        // [37] std DNS resolution is Net (was floored).
2258        assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
2259        assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
2260        // [23] std::process getters are pure; spawn/new stay Exec.
2261        assert_eq!(classify("std", "std::process::Command::get_program"), None);
2262        assert_eq!(classify("std", "std::process::Command::get_args"), None);
2263        assert_eq!(classify("std", "std::process::Child::id"), None);
2264        assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
2265        // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
2266        assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
2267        assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
2268        // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
2269        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
2270        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
2271        assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
2272    }
2273
2274    #[test]
2275    fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
2276        // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
2277        // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
2278        assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
2279        assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
2280        assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
2281        assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
2282        assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
2283        assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
2284    }
2285
2286    #[test]
2287    fn redis_connection_manager_config_builder_is_pure() {
2288        // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
2289        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
2290        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
2291        // the LIVE manager still round-trips (Db).
2292        assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
2293        assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
2294    }
2295
2296    #[test]
2297    fn pure_fd_transfer_is_not_an_effect() {
2298        // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
2299        // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
2300        // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
2301        // `as_raw_fd` all fabricated effects.)
2302        assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
2303        assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
2304        assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
2305        assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
2306        assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
2307        assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
2308        assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
2309        assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
2310        assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
2311        // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
2312        assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
2313        assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
2314        assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
2315        assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
2316        // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
2317        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2318        assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
2319        assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
2320        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
2321        assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
2322    }
2323
2324    #[test]
2325    fn command_head_refines_the_exec_cliff() {
2326        use super::classify_command_head as h;
2327        // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
2328        assert_eq!(h("curl"), &["Net"]);
2329        assert_eq!(h("telnet"), &["Net"]);
2330        assert_eq!(h("sftp"), &["Net"]);
2331        assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
2332        assert_eq!(h("mongo"), &["Db"]);
2333        assert_eq!(h("cqlsh"), &["Db"]);
2334        // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
2335        assert_eq!(h("candor-scan"), &["Env", "Fs"]);
2336        assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
2337        // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
2338        // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
2339        // cliff rather than fabricate an effect for the common case.
2340        assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
2341        assert_eq!(h("make"), &[] as &[&str]);
2342        assert_eq!(h("npm"), &[] as &[&str]);
2343        assert_eq!(h("git"), &[] as &[&str]);
2344        assert_eq!(h("rsync"), &[] as &[&str]);
2345        // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
2346        // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
2347        assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
2348        assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
2349        assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
2350        // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
2351        // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
2352        // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
2353        // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
2354        // name a program, so only they may refine.
2355        assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
2356        assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
2357        assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
2358    }
2359
2360    #[test]
2361    fn net_establishing_allowlist() {
2362        // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
2363        // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
2364        assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
2365        assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
2366        assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
2367        // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
2368        assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
2369        assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
2370    }
2371
2372    #[test]
2373    fn fs_path_arg_allowlist() {
2374        // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
2375        // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
2376        // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
2377        // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
2378        assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
2379        assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
2380        assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
2381        assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
2382        // handle ops / pure builders take NO path arg — never path-naming.
2383        assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
2384        assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
2385    }
2386
2387    #[test]
2388    fn db_query_arg_allowlist() {
2389        // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
2390        // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
2391        // ops carry no SQL string and must NOT flag.
2392        assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
2393        assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
2394        assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
2395        // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
2396        assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
2397        assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
2398        // connection / lifecycle ops take no SQL — must NOT flag.
2399        assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
2400        assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
2401    }
2402}