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}