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
//! Flight-recorder → candidate policy (#1176) — fold a `--full-access`
//! session's observed authority into a **reviewable, unsigned** `approve.toml`
//! proposal. This is the accumulation loop *inverted*: instead of
//! prompt → grant, it is **observe → catalog → promote**.
//!
//! The [`crate::flight_recorder`] captures what authority an unconfined session
//! actually used (a [`FlightCapture`]). This module turns that capture into a
//! proposal an operator reviews and then blesses with `newt doctor --sign-ocap`
//! — the *only* path by which an observed caveat becomes a durable grant. No
//! step here signs anything or widens the floor; it proposes.
//!
//! Two hard rules, both inherited from the policy contract (agent-bridle
//! `policy`) and the flight-recorder's own law:
//! - **Never an auto-grant.** Only caveats *not already accounted for* by the
//! current store ([`FlightCapture::gaps`]) are proposed, and every proposed
//! entry is written UNSIGNED (`sig = None`) — it does nothing until a present
//! human blesses it.
//! - **Danger-gated exactly as a real grant.** A high-danger target (per the
//! caller's danger table, injected as a predicate) is NEVER proposed into
//! `approve.toml` — `sign_approves`/`validate_approve` would refuse it and
//! `verified_approves` would drop it at load anyway. It is reported as a
//! [`DeferredCaveat`] ("keep prompting / offer passkey step-up") instead.
//!
//! Pure: given a folded capture, the (class, target) set the store already
//! covers, the danger predicate, and a timestamp, it returns a [`Proposal`].
//! The CLI (`newt ocap propose`) wires the fs read/write around it.
use std::collections::HashSet;
use crate::flight_recorder::{FlightCapture, ShadowAxis};
use crate::ocap_store::{
CapabilityClass, ExecEntry, FsEntry, NetEntry, PolicyFile, PolicySet, Verdict,
};
/// The provenance stamped on every proposed entry's `by` field — mirrors the
/// contract's free-form provenance (`human` / `seed` / a tool name). This value
/// tells a reviewer the entry came from an observed session, not a human
/// decision, so it earns extra scrutiny before blessing.
pub const PROVENANCE: &str = "flight-recorder";
/// An observed authority the proposal did NOT turn into a durable approve —
/// because the current danger table classifies it high-danger. Reported to the
/// operator so nothing observed is silently dropped: it stays a prompt, or the
/// operator can add it to `passkey.toml` for WebAuthn step-up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredCaveat {
/// The policy capability-class label: `exec` / `fs` / `net`.
pub class: &'static str,
/// The gated target (exec program, fs path, or net host).
pub target: String,
/// The raw command that produced the observation (the repro fixture).
pub command: String,
/// How many times it was observed this session.
pub count: u64,
/// Operator-facing reason it is not durably approvable.
pub reason: String,
}
/// The output of a propose pass: the low-danger additions to fold into
/// `approve.toml` (unsigned candidates) plus the high-danger observations that
/// were deferred rather than proposed.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Proposal {
/// New, low-danger entries to append to `approve.toml`. Every entry is
/// `sig = None`: a candidate awaiting `newt doctor --sign-ocap`.
pub additions: PolicyFile,
/// High-danger observations that were NOT proposed (see [`DeferredCaveat`]).
pub deferred: Vec<DeferredCaveat>,
}
impl Proposal {
/// The count of proposed additions across all classes.
pub fn additions_len(&self) -> usize {
self.additions.exec.len() + self.additions.fs.len() + self.additions.net.len()
}
/// True when there is nothing to propose AND nothing to defer — the capture
/// is fully accounted for by the current store.
pub fn is_empty(&self) -> bool {
self.additions_len() == 0 && self.deferred.is_empty()
}
}
/// The (class, target) pairs the current store already accounts for, across
/// EVERY verdict. A `deny`/`ask`/`passkey` entry answers a would-be gap just as
/// an `approve` does — the operator has already made a decision about it — so a
/// re-run does not re-propose it. Build this from the UNVERIFIED store (see the
/// CLI): an already-written-but-unsigned candidate must count as accounted-for,
/// or `propose` would re-offer it every run (the signature-verify load pass
/// would drop the unsigned candidate and never converge).
pub fn in_policy_pairs(set: &PolicySet) -> HashSet<(String, String)> {
let mut pairs = HashSet::new();
for file in set.files.values() {
for e in &file.exec {
pairs.insert(("exec".to_string(), e.target.clone()));
}
for e in &file.fs {
pairs.insert(("fs".to_string(), e.path.clone()));
}
for e in &file.net {
pairs.insert(("net".to_string(), e.host.clone()));
}
}
pairs
}
/// The policy capability class a shadow axis maps to (the same mapping
/// [`crate::ocap_store::class_for`] uses for the denial axes).
fn axis_class(axis: ShadowAxis) -> CapabilityClass {
match axis {
ShadowAxis::Exec => CapabilityClass::Exec,
ShadowAxis::FsRead | ShadowAxis::FsWrite => CapabilityClass::Fs,
ShadowAxis::Net => CapabilityClass::Net,
}
}
/// Why a high-danger observation cannot become a durable approve — operator
/// guidance, class-specific.
fn defer_reason(class: CapabilityClass) -> String {
match class {
CapabilityClass::Exec => "high-danger exec (interpreter / shell / command-runner) — \
never durably approvable; keep prompting, or add to passkey.toml for WebAuthn step-up"
.to_string(),
CapabilityClass::Fs => "high-danger fs (a broad root at or above $HOME, the workspace, \
or /) — never durably approvable; narrow the path, or use passkey.toml"
.to_string(),
// Net is Low by construction (a single host is narrow) — the danger
// table never returns High for it, so this arm is unreachable in
// practice; kept for exhaustiveness and honesty if the table changes.
CapabilityClass::Net => {
"high-danger net — never durably approvable; use passkey.toml".to_string()
}
}
}
/// Fold a capture into a [`Proposal`]: the gaps (observed − already-in-policy),
/// partitioned by danger. Low-danger gaps become unsigned `approve.toml`
/// candidates stamped with provenance and a repro note; high-danger gaps become
/// [`DeferredCaveat`]s. `now` is the ISO-8601 date to stamp on `granted`
/// (injected so the pure core stays wall-clock-free per the unit-test law).
pub fn propose_from_capture(
capture: &FlightCapture,
in_policy: &HashSet<(String, String)>,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
now: &str,
) -> Proposal {
let mut proposal = Proposal::default();
for c in capture.gaps(in_policy) {
let class = axis_class(c.axis);
if is_high_danger(class, &c.target) {
proposal.deferred.push(DeferredCaveat {
class: c.axis.class(),
target: c.target.clone(),
command: c.command.clone(),
count: c.count,
reason: defer_reason(class),
});
continue;
}
let note = Some(format!("learned from: {} ({}x)", c.command, c.count));
let granted = Some(now.to_string());
let by = Some(PROVENANCE.to_string());
match c.axis {
ShadowAxis::Exec => proposal.additions.exec.push(ExecEntry {
target: c.target.clone(),
note,
granted,
by,
sig: None,
}),
ShadowAxis::FsRead => proposal.additions.fs.push(FsEntry {
path: c.target.clone(),
write: false,
note,
granted,
by,
sig: None,
}),
ShadowAxis::FsWrite => proposal.additions.fs.push(FsEntry {
path: c.target.clone(),
write: true,
note,
granted,
by,
sig: None,
}),
ShadowAxis::Net => proposal.additions.net.push(NetEntry {
host: c.target.clone(),
note,
granted,
by,
sig: None,
}),
}
}
proposal
}
/// The reusable entry point the CLI (`newt ocap propose`) AND the TUI
/// (`/ocap propose`) both call. It is the SINGLE SOURCE OF TRUTH for folding a
/// capture into a proposal — the TUI must not re-derive the grouping, the
/// danger gate, or the provenance stamp. It reads the store files UNVERIFIED
/// (an already-written-but-unsigned candidate counts as accounted-for, so a
/// re-run never re-offers it), merges the low-danger additions on top of any
/// existing entries, and returns both the merged file and the pure
/// [`Proposal`]. `existing` is the raw text read from `approve.toml`, or `None`
/// when no file is configured.
///
/// `session` scopes the capture to one session: when `Some`, caveats recorded
/// under a different session are excluded from a shared capture file. The
/// CLI and TUI both pass [`newt_core::lifecycle::active_session`]; when it is
/// `None` the whole file is read (unscoped).
///
/// This is exactly the composition the CLI wires, so the TUI cannot drift from
/// it: parse the store, build the in-policy pair set, call
/// [`propose_from_capture`], then merge the additions on top of the existing
/// file. Any other caller gets the same result for the same inputs.
pub fn propose_for(
capture_text: &str,
store_files: &[(Verdict, Option<String>)],
existing: Option<&str>,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
now: &str,
session: Option<&str>,
) -> anyhow::Result<(PolicyFile, Proposal)> {
use crate::ocap_store::build_store;
let (set, _) = build_store(store_files);
let in_policy = in_policy_pairs(&set);
// Session-scoped read: with `session` set, caveats recorded under a
// different session are excluded (a shared capture file holds every
// session). `None` reads everything, preserving the unscoped behaviour.
let capture = crate::flight_recorder::read_capture_jsonl_sessioned(capture_text, session);
let proposal = propose_from_capture(&capture, &in_policy, is_high_danger, now);
// Append the proposed additions to the existing approve file, exactly as
// the CLI always has: every existing entry and signature is kept, and the
// proposer never re-proposes a target already in policy (`in_policy`).
let mut merged = match existing {
Some(text) => PolicyFile::parse(text).map_err(|e| anyhow::anyhow!("approve.toml: {e}"))?,
None => PolicyFile::default(),
};
merged.exec.extend(proposal.additions.exec.iter().cloned());
merged.fs.extend(proposal.additions.fs.iter().cloned());
merged.net.extend(proposal.additions.net.iter().cloned());
Ok((merged, proposal))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flight_recorder::read_capture_jsonl;
use crate::ocap_store::{build_store, Verdict};
/// A danger predicate matching the production shape: interpreters/shells are
/// high-danger exec; a `/`-rooted path is high-danger fs; net is always low.
fn danger(class: CapabilityClass, target: &str) -> bool {
match class {
CapabilityClass::Exec => matches!(target, "bash" | "sh" | "python3" | "env"),
CapabilityClass::Fs => target == "/" || target.starts_with("/home"),
CapabilityClass::Net => false,
}
}
fn capture(lines: &[&str]) -> FlightCapture {
read_capture_jsonl(&lines.join("\n"))
}
#[test]
fn low_danger_exec_gaps_become_unsigned_candidates() {
// `cargo` is a fresh, low-danger observation → proposed unsigned, with
// provenance + a repro note carrying the count.
let cap =
capture(&[r#"{"axis":"exec","target":"cargo","command":"cargo build","count":3}"#]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1);
let e = &p.additions.exec[0];
assert_eq!(e.target, "cargo");
assert_eq!(e.by.as_deref(), Some("flight-recorder"));
assert_eq!(e.granted.as_deref(), Some("2026-07-17"));
assert!(e.sig.is_none(), "candidates are unsigned until blessed");
assert_eq!(e.note.as_deref(), Some("learned from: cargo build (3x)"));
assert!(p.deferred.is_empty());
}
#[test]
fn high_danger_exec_is_deferred_never_proposed() {
// The whole point of #1176's danger gate: an observed `bash` is NOT
// laundered into approve.toml; it is reported for passkey/prompt.
let cap = capture(&[
r#"{"axis":"exec","target":"cargo","command":"cargo test","count":1}"#,
r#"{"axis":"exec","target":"bash","command":"bash -c 'x'","count":2}"#,
]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1, "only cargo proposed");
assert_eq!(p.additions.exec[0].target, "cargo");
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].target, "bash");
assert_eq!(p.deferred[0].class, "exec");
assert_eq!(p.deferred[0].count, 2);
assert!(p.deferred[0].reason.contains("passkey"));
}
#[test]
fn fs_read_and_write_axes_map_to_the_write_flag() {
let cap = capture(&[
r#"{"axis":"fs_read","target":"/ws/src","command":"cat x","count":1}"#,
r#"{"axis":"fs_write","target":"/ws/out","command":"tee y","count":1}"#,
r#"{"axis":"fs_write","target":"/home/x","command":"rm z","count":1}"#,
]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
// /ws paths are low-danger; the /home write is deferred.
assert_eq!(p.additions.fs.len(), 2);
let read = p.additions.fs.iter().find(|e| e.path == "/ws/src").unwrap();
assert!(!read.write, "fs_read → read-only");
let write = p.additions.fs.iter().find(|e| e.path == "/ws/out").unwrap();
assert!(write.write, "fs_write → write coverage");
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].target, "/home/x");
assert_eq!(p.deferred[0].class, "fs");
}
#[test]
fn net_gaps_become_candidates() {
let cap = capture(&[r#"{"axis":"net","target":"crates.io","command":"curl","count":1}"#]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.net.len(), 1);
assert_eq!(p.additions.net[0].host, "crates.io");
assert!(p.additions.net[0].sig.is_none());
}
#[test]
fn gaps_already_in_any_verdict_are_not_reproposed() {
// A caveat the store already accounts for — under ANY verdict — is not
// re-proposed. `cargo` is already an (unsigned) approve; `rm` is a deny.
let (set, _) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\n".to_string()),
),
(
Verdict::Deny,
Some("[[exec]]\ntarget = \"rm\"\n".to_string()),
),
]);
let in_policy = in_policy_pairs(&set);
let cap = capture(&[
r#"{"axis":"exec","target":"cargo","command":"cargo build","count":1}"#,
r#"{"axis":"exec","target":"rm","command":"rm -rf t","count":1}"#,
r#"{"axis":"exec","target":"git","command":"git push","count":1}"#,
]);
let p = propose_from_capture(&cap, &in_policy, danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1, "only the genuinely-new git");
assert_eq!(p.additions.exec[0].target, "git");
assert!(p.deferred.is_empty());
}
#[test]
fn empty_capture_yields_an_empty_proposal() {
let p = propose_from_capture(
&FlightCapture::default(),
&HashSet::new(),
danger,
"2026-07-17",
);
assert!(p.is_empty());
assert_eq!(p.additions_len(), 0);
}
#[test]
fn in_policy_pairs_walks_all_classes_and_verdicts() {
let (set, _) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\n[[net]]\nhost = \"crates.io\"\n".to_string()),
),
(
Verdict::Ask,
Some("[[fs]]\npath = \"/ws\"\nwrite = true\n".to_string()),
),
]);
let pairs = in_policy_pairs(&set);
assert!(pairs.contains(&("exec".to_string(), "cargo".to_string())));
assert!(pairs.contains(&("net".to_string(), "crates.io".to_string())));
assert!(pairs.contains(&("fs".to_string(), "/ws".to_string())));
assert_eq!(pairs.len(), 3);
}
/// The shared proposer appends, as the CLI always did: a same-path read
/// AND write addition both survive (round 1 retained-then-pushed by target,
/// which collapsed them), and existing entries are kept (#2687 review).
#[test]
fn propose_for_appends_and_keeps_same_path_read_and_write() {
let capture = concat!(
r#"{"axis":"fs_read","target":"data","command":"cat data/x","count":1}"#,
"\n",
r#"{"axis":"fs_write","target":"data","command":"cp a data/","count":1}"#,
"\n",
);
let existing = "[[exec]]\ntarget = \"rg\"\nsig = \"deadbeef\"\n";
let (merged, _) =
propose_for(capture, &[], Some(existing), danger, "2026-10-02", None).unwrap();
assert_eq!(
merged.fs.iter().filter(|e| e.path == "data").count(),
2,
"{:?}",
merged.fs
);
assert!(
merged.exec.iter().any(|e| e.target == "rg"),
"existing entry kept"
);
}
}