Skip to main content

newt_core/agentic/
permissions.rs

1//! Prompted ocap grants (issue #263) — the seam between a capability denial
2//! and an interactive human decision.
3//!
4//! Default behavior is UNCHANGED: without a [`PermissionGate`] every denial
5//! fails the tool call exactly as before. The TUI may supply a gate (the
6//! `--prompt-for-permissions` flag / `[tui.permissions] prompt = true`);
7//! headless callers (ACP worker, `newt-eval`) never construct one, so a
8//! denial there can never block on a prompt — the eval gate stays honest.
9//!
10//! Ocap honesty: **attenuation-only is the invariant — a live key is never
11//! widened.** An *allow* decision is implemented gate-side by re-minting a
12//! fresh operating authority from the user root as (previous caveats ∪ new
13//! grant); [`widen_caveats`] only builds that *policy* value — the signed
14//! re-mint itself lives with the key holder (the TUI). The widened caveats
15//! returned in [`PermissionDecision::Allow`] apply to the single re-executed
16//! call; "allow for this session" is the gate remembering the grant so the
17//! next denial of the same target re-mints without re-prompting. The
18//! session's enforced baseline (`ChatCtx::caveats`) is never mutated.
19//!
20//! Every prompted decision is recorded ([`PermissionRecord`]) with the
21//! conversation id so implicit denials can be promoted to explicit config
22//! grants — or stay denied — deliberately. The record is a REVIEW artifact,
23//! not config: nothing reads it back into authority. Promotion to a durable
24//! grant is a human editing `[tui.permissions]`.
25
26use crate::caveats::{Caveats, Scope};
27use serde::{Deserialize, Serialize};
28use std::io::Write as _;
29use std::path::Path;
30
31/// The capability axis a denial occurred on.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub enum DenialKind {
34    /// `exec` — a command outside the granted exec allowlist.
35    Exec,
36    /// `fs_read` — a path outside the granted read scope.
37    FsRead,
38    /// `fs_write` — a path outside the granted write scope.
39    FsWrite,
40    /// `net` — a host outside the granted net allowlist.
41    Net,
42    /// FR-2 (#1001): a remote MCP `server__tool` the active persona does not
43    /// grant. Unlike the four axes above it maps to NO fs/exec/net caveat — a
44    /// remote tool's leash is name-based — so an `Allow` here is purely "proceed
45    /// with this call" (it widens nothing). `target` is the tool name.
46    RemoteTool,
47    /// #1056: a LOCAL git write (`add`/`commit`/`reset`/`branch`/…) the embedded
48    /// git tool's projected [`GitCaveats`](crate::git_caveats::GitCaveats) does
49    /// not grant. Like [`RemoteTool`](Self::RemoteTool) it maps to NO
50    /// fs/exec/net caveat — the git tool's leash is its own op-class lattice, not
51    /// the shell allowlist — so an `Allow` means "proceed with local git writes
52    /// this call" (it widens no `Caveats` axis; the git arm re-dispatches under a
53    /// local-write `GitCaveats`). `target` is the git op (`commit`, `add`, …).
54    /// The network verbs (push/fetch/clone) are NOT this capability — they stay
55    /// deferred / shell-net-gated. A readonly `/mode` still denies it (the gate
56    /// refuses the grant when the preset floor projects no git write).
57    GitWrite,
58}
59
60impl DenialKind {
61    /// Stable string form — also the `kind` field of [`PermissionRecord`].
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Self::Exec => "exec",
65            Self::FsRead => "fs_read",
66            Self::FsWrite => "fs_write",
67            Self::Net => "net",
68            Self::RemoteTool => "remote_tool",
69            Self::GitWrite => "git_write",
70        }
71    }
72}
73
74/// Inverse of [`DenialKind::as_str`] — parse a persisted `kind`. `Err(())` for
75/// an unknown string so a corrupt/forward-incompatible denylist line is skipped
76/// rather than trusted (#904). A trait impl (not an inherent `from_str`) so
77/// `"net".parse::<DenialKind>()` works and clippy's `should_implement_trait` is
78/// satisfied.
79impl std::str::FromStr for DenialKind {
80    type Err = ();
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        match s {
83            "exec" => Ok(Self::Exec),
84            "fs_read" => Ok(Self::FsRead),
85            "fs_write" => Ok(Self::FsWrite),
86            "net" => Ok(Self::Net),
87            "remote_tool" => Ok(Self::RemoteTool),
88            "git_write" => Ok(Self::GitWrite),
89            _ => Err(()),
90        }
91    }
92}
93
94/// #904: one durable "permanently deny" entry — a `(kind, target)` the human
95/// chose to deny across restarts, so the gate refuses it WITHOUT re-prompting.
96/// Stored one JSON line per entry in `~/.newt/permission-denials.jsonl` (a
97/// sibling of `permission-log.jsonl`).
98///
99/// Unlike the permission LOG (a review artifact that is never read back into
100/// authority), this file IS read back — at gate construction — and consulted
101/// before prompting. That is sound precisely because it is **deny-only**: a
102/// denylist can never widen authority, so reading it back cannot break the
103/// attenuation-only invariant. A permanent *allow* has no analogue here; a
104/// durable grant stays a deliberate `[tui.permissions]` edit.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct PersistentDenial {
107    /// Capability axis: `exec` / `fs_read` / `fs_write` / `net`.
108    pub kind: String,
109    /// What is denied — a command name (exec), a path (fs_*), or a host (net).
110    pub target: String,
111    /// Wall-clock at decision time (RFC 3339, UTC) — a display claim, never
112    /// an ordering key.
113    pub ts_claim: String,
114}
115
116/// Parse a denials file body into `(kind, target)` pairs — PURE (no I/O), so it
117/// unit-tests without a filesystem. Malformed lines and unknown `kind`s are
118/// skipped (a corrupt entry must never crash the gate or, worse, be trusted).
119pub fn parse_denials(contents: &str) -> Vec<(DenialKind, String)> {
120    contents
121        .lines()
122        .filter(|l| !l.trim().is_empty())
123        .filter_map(|line| {
124            let d: PersistentDenial = serde_json::from_str(line).ok()?;
125            let kind: DenialKind = d.kind.parse().ok()?;
126            (!d.target.is_empty()).then_some((kind, d.target))
127        })
128        .collect()
129}
130
131/// Load the persistent denylist from `path`. A missing file is an empty list
132/// (not an error) — the common first-run case.
133pub fn load_denials(path: &Path) -> Vec<(DenialKind, String)> {
134    match std::fs::read_to_string(path) {
135        Ok(body) => parse_denials(&body),
136        Err(_) => Vec::new(),
137    }
138}
139
140/// Append one permanent-deny entry as a JSON line, creating parent dirs as
141/// needed. Mirrors [`PermissionRecord::append_jsonl`].
142pub fn append_denial(path: &Path, kind: DenialKind, target: &str) -> std::io::Result<()> {
143    if let Some(parent) = path.parent() {
144        std::fs::create_dir_all(parent)?;
145    }
146    let entry = PersistentDenial {
147        kind: kind.as_str().to_string(),
148        target: target.to_string(),
149        ts_claim: chrono::Utc::now().to_rfc3339(),
150    };
151    let line = serde_json::to_string(&entry).map_err(std::io::Error::other)?;
152    let mut file = std::fs::OpenOptions::new()
153        .create(true)
154        .append(true)
155        .open(path)?;
156    writeln!(file, "{line}")
157}
158
159/// One denied capability surfaced for a human decision.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct PermissionRequest {
162    /// The tool the model called (`run_command`, `read_file`, …).
163    pub tool: String,
164    /// Capability axis the denial occurred on.
165    pub kind: DenialKind,
166    /// What an *allow* would grant: a command name (exec), an absolute
167    /// path (fs_read / fs_write), or a host (net).
168    pub target: String,
169    /// The denial text the model would otherwise see — shown to the human
170    /// for context.
171    pub reason: String,
172}
173
174/// Verdict from consulting the gate.
175pub enum PermissionDecision {
176    /// Re-execute the denied call under these caveats. They are a freshly
177    /// minted authority (root ∪ grant) — the session's live key/baseline is
178    /// untouched, and the value does not outlive the re-executed call.
179    Allow(Caveats),
180    /// Keep the standard structured denial, bit-for-bit.
181    Deny,
182}
183
184/// The interactive human-interface seam (mirrors `NoteSink` / `RecallSource`):
185/// the one gate the agentic loop consults whenever it must reach the human. It
186/// carries two distinct interactions that share the same operator presence:
187///
188/// * [`PermissionGate::ask`] — decide a capability GRANT (the #263 / #721 ocap
189///   flow). It can WIDEN authority by re-minting caveats.
190/// * [`PermissionGate::ask_question`] — ask a free-text QUESTION and read back
191///   the answer (the #728 `request_user_input` tool). It only gathers text; it
192///   never touches authority.
193///
194/// Keeping both on one gate realizes "both surface to the human" without merging
195/// the tools: grants flow through `ask`, questions through `ask_question`.
196///
197/// The call blocks the agentic loop like a long tool call (issue #263 §6 of
198/// the design notes). Implementations that auto-allow previously
199/// session-granted targets must still return freshly minted caveats.
200pub trait PermissionGate {
201    /// Ask about a batch of denials from ONE tool call (a compound command
202    /// can be refused on several targets at once). `Allow` means every
203    /// request was allowed; any single deny keeps the whole denial.
204    fn ask(&mut self, requests: &[PermissionRequest]) -> PermissionDecision;
205
206    /// #728: ask the human a free-text `question` and return their typed
207    /// answer — the GENERIC ask-the-human primitive behind the
208    /// `request_user_input` tool. Distinct from [`PermissionGate::ask`], which
209    /// decides capability grants: `ask` can widen authority, `ask_question`
210    /// only gathers text. Returns `None` when there is no human to consult
211    /// (no interactive operator this session, or stdin closed) so the caller
212    /// degrades to a recoverable "no human available" result instead of
213    /// blocking — a headless caller must NEVER hang on it.
214    fn ask_question(&mut self, question: &str) -> Option<String>;
215}
216
217/// Build the widened *policy* for a re-mint: `base` with each grant's target
218/// added to its axis. `Scope::All` axes are left untouched (nothing to add);
219/// this never narrows and never touches `max_calls` /
220/// `valid_for_generation`. This is a plain policy value — minting it into a
221/// signed key (and thereby proving base ⊑ root still holds) is the gate
222/// implementation's job.
223pub fn widen_caveats(base: &Caveats, grants: &[(DenialKind, String)]) -> Caveats {
224    let mut out = base.clone();
225    for (kind, target) in grants {
226        let scope = match kind {
227            DenialKind::Exec => &mut out.exec,
228            DenialKind::FsRead => &mut out.fs_read,
229            DenialKind::FsWrite => &mut out.fs_write,
230            DenialKind::Net => &mut out.net,
231            // FR-2 (#1001): a remote-tool grant maps to no caveat axis — the
232            // `Allow` means "proceed with this call", not "widen an axis". Skip.
233            DenialKind::RemoteTool => continue,
234            // #1056: a git-write grant maps to no caveat axis either — the git
235            // tool's authority is projected separately (GitCaveats); the git arm
236            // re-dispatches under a local-write surface on Allow. Widen nothing.
237            DenialKind::GitWrite => continue,
238        };
239        if let Scope::Only(set) = scope {
240            set.insert(target.clone());
241        }
242    }
243    out
244}
245
246/// One prompted permission decision, recorded with the session for later
247/// review (issue #263). Serialized as one JSON line of
248/// `~/.newt/permission-log.jsonl` until the Phase 17 store grows a
249/// first-class events home for it.
250///
251/// This file is a record, NOT config: nothing reads it back into authority.
252/// Promoting an `allow` to a durable grant is a human editing
253/// `[tui.permissions]` (`extra_exec` / `net` / preset) — see issue #181.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct PermissionRecord {
256    /// Wall-clock at decision time (RFC 3339, UTC). A display claim (§6) —
257    /// never an ordering key; ordering is the file's append order.
258    pub ts_claim: String,
259    /// The conversation/session the decision belongs to.
260    pub conversation_id: String,
261    /// Tool whose call was denied.
262    pub tool: String,
263    /// Capability axis: `exec` / `fs_read` / `fs_write` / `net`.
264    pub kind: String,
265    /// What the decision was about (command name / path / host).
266    pub target: String,
267    /// `allow` or `deny`.
268    pub decision: String,
269    /// `once` (this call only) or `session` (until the session ends).
270    pub scope: String,
271}
272
273impl PermissionRecord {
274    /// Record one decision now. `ts_claim` is stamped from the wall clock —
275    /// a display claim per the §6 discipline.
276    pub fn new(
277        conversation_id: &str,
278        tool: &str,
279        kind: DenialKind,
280        target: &str,
281        decision: &str,
282        scope: &str,
283    ) -> Self {
284        Self {
285            ts_claim: chrono::Utc::now().to_rfc3339(),
286            conversation_id: conversation_id.to_string(),
287            tool: tool.to_string(),
288            kind: kind.as_str().to_string(),
289            target: target.to_string(),
290            decision: decision.to_string(),
291            scope: scope.to_string(),
292        }
293    }
294
295    /// Append this record as one JSON line, creating parent dirs as needed.
296    pub fn append_jsonl(&self, path: &Path) -> std::io::Result<()> {
297        if let Some(parent) = path.parent() {
298            std::fs::create_dir_all(parent)?;
299        }
300        let line = serde_json::to_string(self).map_err(std::io::Error::other)?;
301        let mut file = std::fs::OpenOptions::new()
302            .create(true)
303            .append(true)
304            .open(path)?;
305        writeln!(file, "{line}")
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::caveats::{CaveatsExt as _, CountBound};
313
314    fn base() -> Caveats {
315        Caveats {
316            fs_read: Scope::only(["/ws".to_string()]),
317            fs_write: Scope::only(["/ws".to_string()]),
318            exec: Scope::only(["cargo".to_string()]),
319            net: Scope::none(),
320            max_calls: CountBound::AtMost(7),
321            valid_for_generation: Scope::All,
322        }
323    }
324
325    #[test]
326    fn denial_kind_strings_are_stable() {
327        // Load-bearing: these are the `kind` values in the on-disk record
328        // and the keys a human greps the log for.
329        assert_eq!(DenialKind::Exec.as_str(), "exec");
330        assert_eq!(DenialKind::FsRead.as_str(), "fs_read");
331        assert_eq!(DenialKind::FsWrite.as_str(), "fs_write");
332        assert_eq!(DenialKind::Net.as_str(), "net");
333    }
334
335    #[test]
336    fn widen_adds_each_grant_to_its_axis_only() {
337        let widened = widen_caveats(
338            &base(),
339            &[
340                (DenialKind::Exec, "npm".to_string()),
341                (DenialKind::Net, "docs.rs".to_string()),
342                (DenialKind::FsRead, "/etc/hosts".to_string()),
343                (DenialKind::FsWrite, "/tmp/out".to_string()),
344            ],
345        );
346        assert!(widened.permits_exec("npm"));
347        assert!(widened.permits_exec("cargo"), "existing grants kept");
348        assert!(widened.permits_net("docs.rs"));
349        assert!(widened.permits_fs_read("/etc/hosts"));
350        assert!(widened.permits_fs_write("/tmp/out"));
351        // Untouched axes / non-granted targets stay denied.
352        assert!(!widened.permits_exec("rm"));
353        assert!(!widened.permits_net("evil.example.com"));
354        // Non-scope axes are never altered by a grant.
355        assert_eq!(widened.max_calls, CountBound::AtMost(7));
356    }
357
358    #[test]
359    fn widen_leaves_base_unchanged_and_all_stays_all() {
360        let b = base();
361        let _ = widen_caveats(&b, &[(DenialKind::Exec, "npm".to_string())]);
362        assert_eq!(b, base(), "widen builds a NEW policy; base is immutable");
363
364        let all = Caveats::top();
365        let widened = widen_caveats(&all, &[(DenialKind::Exec, "npm".to_string())]);
366        assert_eq!(widened, all, "Scope::All has nothing to add");
367    }
368
369    #[test]
370    fn widen_with_no_grants_is_identity() {
371        assert_eq!(widen_caveats(&base(), &[]), base());
372    }
373
374    #[test]
375    fn record_serializes_the_issue_shape() {
376        let rec = PermissionRecord::new(
377            "conv-1",
378            "run_command",
379            DenialKind::Exec,
380            "npm",
381            "allow",
382            "session",
383        );
384        let json: serde_json::Value =
385            serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
386        assert_eq!(json["conversation_id"], "conv-1");
387        assert_eq!(json["tool"], "run_command");
388        assert_eq!(json["kind"], "exec");
389        assert_eq!(json["target"], "npm");
390        assert_eq!(json["decision"], "allow");
391        assert_eq!(json["scope"], "session");
392        // ts_claim is present and RFC 3339-shaped (a display claim, §6).
393        let ts = json["ts_claim"].as_str().unwrap();
394        assert!(
395            chrono::DateTime::parse_from_rfc3339(ts).is_ok(),
396            "got: {ts}"
397        );
398    }
399
400    #[test]
401    fn append_jsonl_appends_one_line_per_record_and_creates_dirs() {
402        let dir = tempfile::TempDir::new().unwrap();
403        let path = dir.path().join("nested").join("permission-log.jsonl");
404        let a = PermissionRecord::new(
405            "conv-1",
406            "read_file",
407            DenialKind::FsRead,
408            "/etc/hosts",
409            "deny",
410            "once",
411        );
412        let b = PermissionRecord::new(
413            "conv-1",
414            "web_fetch",
415            DenialKind::Net,
416            "docs.rs",
417            "allow",
418            "once",
419        );
420        a.append_jsonl(&path).unwrap();
421        b.append_jsonl(&path).unwrap();
422        let body = std::fs::read_to_string(&path).unwrap();
423        let lines: Vec<&str> = body.lines().collect();
424        assert_eq!(lines.len(), 2);
425        let parsed: PermissionRecord = serde_json::from_str(lines[0]).unwrap();
426        assert_eq!(parsed, a, "round-trips losslessly");
427        let parsed: PermissionRecord = serde_json::from_str(lines[1]).unwrap();
428        assert_eq!(parsed.kind, "net");
429    }
430
431    // ---- #904: persistent "permanently deny" store ----
432
433    #[test]
434    fn denial_kind_from_str_round_trips_and_rejects_unknown() {
435        for k in [
436            DenialKind::Exec,
437            DenialKind::FsRead,
438            DenialKind::FsWrite,
439            DenialKind::Net,
440        ] {
441            assert_eq!(k.as_str().parse::<DenialKind>(), Ok(k));
442        }
443        assert!("nope".parse::<DenialKind>().is_err());
444        assert!("".parse::<DenialKind>().is_err());
445    }
446
447    #[test]
448    fn parse_denials_is_pure_and_skips_bad_lines() {
449        let body = "\
450{\"kind\":\"net\",\"target\":\"github.com\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
451{\"kind\":\"exec\",\"target\":\"rm\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
452
453not json at all
454{\"kind\":\"bogus\",\"target\":\"x\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
455{\"kind\":\"net\",\"target\":\"\",\"ts_claim\":\"2026-07-04T00:00:00Z\"}
456";
457        let got = parse_denials(body);
458        // Only the two well-formed, known-kind, non-empty-target lines survive.
459        assert_eq!(
460            got,
461            vec![
462                (DenialKind::Net, "github.com".to_string()),
463                (DenialKind::Exec, "rm".to_string()),
464            ]
465        );
466    }
467
468    #[test]
469    fn load_denials_missing_file_is_empty_not_error() {
470        let dir = tempfile::TempDir::new().unwrap();
471        let missing = dir.path().join("nope").join("permission-denials.jsonl");
472        assert!(load_denials(&missing).is_empty());
473    }
474
475    #[test]
476    fn append_denial_then_load_round_trips_and_creates_dirs() {
477        let dir = tempfile::TempDir::new().unwrap();
478        let path = dir.path().join("nested").join("permission-denials.jsonl");
479        append_denial(&path, DenialKind::Net, "github.com").unwrap();
480        append_denial(&path, DenialKind::Exec, "curl").unwrap();
481        let loaded = load_denials(&path);
482        assert_eq!(
483            loaded,
484            vec![
485                (DenialKind::Net, "github.com".to_string()),
486                (DenialKind::Exec, "curl".to_string()),
487            ]
488        );
489    }
490}