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
//! The session's signed operating capability: establish it once, narrow it
//! only.
//!
//! Rooted in the per-user key and the configured preset, and enforcing
//! in-session monotonic narrowing — a reapplied policy can tighten the live
//! authority but never widen it. The policy it is built from (`policy_for`,
//! `mint_operating_key`, `resolve_tui`) stays in `super`: those are consumed
//! by permissions.rs and chat.rs independently of this type.
use super::*;
/// The session's signed operating capability.
///
/// Established once from the per-user key (`~/.newt/identity.pem`) and the
/// configured preset, it enforces **in-session monotonic narrowing**:
/// re-applying a policy (e.g. after a config reload) can only ever *narrow* the live
/// authority, never widen it — widening would require re-rooting from the user
/// key, which only happens on a fresh session. The running agent can tighten its
/// own leash but never loosen it.
///
/// Safe by default: an absent config, or any identity error, yields read-only —
/// never `Caveats::top()`. The one exception is the explicit per-invocation
/// `--full-access` / `NEWT_FULL_ACCESS=1` preset override (see `policy_for`),
/// which is operator-asserted per run and loudly surfaced at session start.
pub(crate) struct SessionCapability {
/// The live operating key. `None` if the per-user key is unavailable; the
/// capability then degrades to a plain caveats floor (still narrowing-only).
op: Option<newt_identity::AgentKey>,
caveats: newt_core::caveats::Caveats,
delegation: Option<newt_identity::VerifiedDelegation>,
frozen_at_launch: bool,
#[cfg(target_os = "macos")]
exec_pins: newt_core::exec_grants::ExecPins,
#[cfg(target_os = "macos")]
exec_notice: Option<String>,
}
impl SessionCapability {
/// Establish the session capability from the configured policy + per-user key.
#[cfg(test)]
pub(crate) fn establish(
tui: Option<newt_core::TuiConfig>,
key_path: Option<&std::path::Path>,
workspace: &str,
delegation: Option<newt_identity::VerifiedDelegation>,
) -> Self {
let policy = policy_for(tui, workspace);
Self::from_policy(policy, key_path, delegation, None)
}
/// Establish an operator-reviewed launch policy independently of mutable
/// configuration. Saved profile edits are applied by a new process only.
#[cfg(test)]
pub(crate) fn establish_frozen(
policy: newt_core::Caveats,
key_path: Option<&std::path::Path>,
delegation: Option<newt_identity::VerifiedDelegation>,
) -> Self {
let mut session = Self::from_policy(policy, key_path, delegation, None);
session.frozen_at_launch = true;
session
}
/// Saved approvals must participate in pin exclusions before signing.
/// They remain recalled grants, not additions to the signed base policy.
pub(crate) fn establish_launch(
policy: newt_core::Caveats,
key_path: Option<&std::path::Path>,
frozen: bool,
permissions: &crate::permissions::PermissionPromptState,
) -> anyhow::Result<Self> {
let writes = permissions.recalled_caveats(&policy, None)?.fs_write;
let mut session = Self::from_policy(policy, key_path, None, Some(&writes));
session.frozen_at_launch = frozen;
Ok(session)
}
fn from_policy(
policy: newt_core::Caveats,
key_path: Option<&std::path::Path>,
delegation: Option<newt_identity::VerifiedDelegation>,
effective_writes: Option<&newt_core::Scope<String>>,
) -> Self {
#[cfg(not(target_os = "macos"))]
let _ = effective_writes;
// A delegated session inherits its ceiling; it does not establish one.
//
// `key_path` is deliberately still accepted and deliberately still
// ignored here. The dangerous shape is not "a delegated session with no
// key" — it is a delegated session that CAN see an operator key, on
// disk, at a path it was handed, and must decline to root itself in it
// anyway. So the enforcement is written for that case rather than for
// the absence of the temptation: no key is minted, no existing key is
// read or rewritten, and `op` stays `None`, which is what makes
// `plugin_envelope_for` refuse to mint nested envelopes.
//
// The ceiling is MET with the local policy, never taken from it: a
// delegated session may narrow itself further (a tighter preset still
// applies) but the parent's signed ceiling is the cap. That is
// attenuate-never-amplify at the session boundary — the same law
// `enforced_caveats` provides on the ordinary path.
if let Some(d) = delegation {
let caveats = policy.meet(d.caveats());
return Self {
op: None,
caveats,
delegation: Some(d),
frozen_at_launch: false,
#[cfg(target_os = "macos")]
exec_pins: Default::default(),
#[cfg(target_os = "macos")]
exec_notice: None,
};
}
// Resolve only at the root session boundary, after all write grants.
// Delegated sessions above retain their signed ceiling unchanged.
#[cfg(target_os = "macos")]
let (policy, exec_pins, exec_notice) = {
let mut policy = policy;
let path = newt_core::exec_grants::dispatch_path();
let writes = effective_writes.unwrap_or(&policy.fs_write).clone();
let (pins, unresolved) = pin_exec_policy(&mut policy, &writes, path.as_deref());
let notice = (!unresolved.is_empty()).then(|| format!(
"Executable grants kept as basenames (not found or untrusted on PATH): {}. Confined execution may need an explicit trusted executable path.", unresolved.join(", ")));
(policy, pins, notice)
};
let op = key_path.and_then(|p| mint_operating_key(p, &policy).ok());
let caveats = match &op {
Some(k) => newt_identity::enforced_caveats(k).unwrap_or(policy),
None => policy,
};
Self {
op,
caveats,
delegation: None,
frozen_at_launch: false,
#[cfg(target_os = "macos")]
exec_pins,
#[cfg(target_os = "macos")]
exec_notice,
}
}
/// Render once through the chat surface, never directly over its terminal.
#[cfg(target_os = "macos")]
pub(crate) fn take_exec_notice(&mut self) -> Option<String> {
self.exec_notice.take()
}
/// The inherited ceiling is distinct from a removable named posture.
pub(crate) fn delegation(&self) -> Option<&newt_identity::VerifiedDelegation> {
self.delegation.as_ref()
}
/// The active enforcement caveats the tool loop consults.
pub(crate) fn caveats(&self) -> &newt_core::caveats::Caveats {
&self.caveats
}
/// Mint a plugin-side envelope for a subprocess running under `role`
/// with `child_caveats`, by delegating from the live operating key.
///
/// **Issue #93:** when the TUI eventually spawns a subprocess
/// plugin (today: in-process tool calls only), the resulting
/// `AgentKey` it hands the plugin MUST chain back to the operator's
/// `UserKey` from `~/.newt/identity.pem` — never a synthetic key
/// minted at spawn time. This helper is the chokepoint the TUI's
/// future subprocess-spawn path will route through.
///
/// Returns:
/// - `Some(Ok(envelope))` when the operating key is present and the
/// delegation succeeded — the envelope's cert chain roots back to
/// the operator.
/// - `Some(Err(_))` when delegation refused (`child_caveats` would
/// amplify the operating key's authority).
/// - `None` when the per-user key is unavailable (`SessionCapability`
/// degraded to a plain caveats floor).
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn plugin_envelope_for(
&self,
role: &str,
child_caveats: newt_core::Caveats,
) -> Option<std::result::Result<String, newt_identity::EnvelopeError>> {
let op = self.op.as_ref()?;
Some(newt_identity::delegate_for_plugin(op, role, child_caveats))
}
/// Re-apply a (possibly changed) policy, **narrowing-only**. Returns `true`
/// if the request asked for *more* authority than the session currently
/// holds and was therefore clamped (so the caller can tell the user a
/// restart is required to widen).
pub(crate) fn reapply(&mut self, tui: Option<newt_core::TuiConfig>, workspace: &str) -> bool {
if self.frozen_at_launch {
return false;
}
let requested = policy_for(tui, workspace);
#[cfg(target_os = "macos")]
let requested = {
let mut requested = requested;
self.exec_pins.apply(&mut requested);
requested
};
let narrowed = requested.meet(&self.caveats);
let clamped = narrowed != requested;
if let Some(op) = self.op.take() {
match newt_identity::attenuate(&op, &narrowed)
.and_then(|child| newt_identity::enforced_caveats(&child).map(|c| (child, c)))
{
Ok((child, c)) => {
self.op = Some(child);
self.caveats = c;
}
// Unreachable in practice (narrowed ⊑ op): keep the old key but
// still apply the narrowed caveats.
Err(_) => {
self.op = Some(op);
self.caveats = narrowed;
}
}
} else {
self.caveats = narrowed;
}
clamped
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_tests {
use super::*;
/// agent-bridle #421: the actual session constructor must pin before mint,
/// including an operator-reviewed frozen profile.
#[test]
fn macos_session_pins_before_mint() {
let _env = newt_core::process_env::lock();
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("identity.pem");
let policy = newt_core::Caveats {
exec: newt_core::Scope::only(["echo".to_owned()]),
fs_write: newt_core::Scope::none(),
net: newt_core::Scope::none(),
..newt_core::Caveats::top()
};
let permissions = crate::permissions::PermissionPromptState::default();
let session =
SessionCapability::establish_launch(policy.clone(), Some(&key), true, &permissions)
.unwrap();
let newt_core::Scope::Only(grants) = &session.caveats().exec else {
panic!("session must retain restricted exec");
};
assert!(grants.contains("echo"));
assert!(grants
.iter()
.any(|path| std::path::Path::new(path).is_absolute()));
assert_eq!(session.caveats().net, policy.net);
assert_eq!(session.caveats().fs_write, policy.fs_write);
let signed = newt_identity::enforced_caveats(session.op.as_ref().unwrap()).unwrap();
assert_eq!(signed.exec, session.caveats().exec);
let unrestricted_writes = newt_core::Caveats {
fs_write: newt_core::Scope::All,
..policy.clone()
};
let mut session = SessionCapability::establish_launch(
unrestricted_writes.clone(),
None,
true,
&permissions,
)
.unwrap();
assert_eq!(session.caveats(), &unrestricted_writes);
assert!(session.take_exec_notice().unwrap().contains("echo"));
assert!(session.take_exec_notice().is_none());
let missing = newt_core::Caveats {
exec: newt_core::Scope::only(["newt-nonexistent-exec-fixture-421".into()]),
..policy
};
let mut session =
SessionCapability::establish_launch(missing.clone(), None, true, &permissions).unwrap();
assert_eq!(session.caveats().exec, missing.exec);
assert!(session
.take_exec_notice()
.unwrap()
.contains("newt-nonexistent-exec-fixture-421"));
assert!(session.take_exec_notice().is_none());
}
}
// Keep the trust calculation portable even though only macOS expands sessions.
#[cfg(any(all(test, unix), target_os = "macos"))]
fn pin_exec_policy(
policy: &mut newt_core::Caveats,
effective_writes: &newt_core::Scope<String>,
path: Option<&std::ffi::OsStr>,
) -> (newt_core::exec_grants::ExecPins, Vec<String>) {
let mut lookup = policy.clone();
lookup.fs_write = effective_writes.clone();
let result = newt_core::exec_grants::resolve_basenames(&mut lookup, path);
// Recalled authority excludes paths; it does not widen the signed base.
policy.exec = lookup.exec;
result
}
#[cfg(all(test, unix))]
mod pin_tests {
use super::*;
use newt_core::{Caveats, DenialKind, Scope};
use std::os::unix::fs::PermissionsExt;
/// PR #2816: a recalled saved write approval must exclude its executable
/// directory before the session captures any implicit absolute exec grant.
#[test]
fn saved_write_grant_excludes_exec_pin() {
let _env = newt_core::process_env::lock();
let temp = tempfile::tempdir().unwrap();
std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let bin = temp.path().canonicalize().unwrap();
let program = bin.join("gh");
std::fs::write(&program, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
let policy = Caveats {
exec: Scope::only(["gh".into()]),
fs_write: Scope::none(),
..Caveats::top()
};
let mut state = crate::permissions::PermissionPromptState::default();
let mut trusted = policy.clone();
let writes = state.recalled_caveats(&policy, None).unwrap().fs_write;
assert!(
pin_exec_policy(&mut trusted, &writes, Some(bin.as_os_str()))
.1
.is_empty()
);
assert_ne!(trusted.exec, policy.exec);
state
.durable_grants
.insert((DenialKind::FsWrite, bin.to_string_lossy().into_owned()));
let writes = state.recalled_caveats(&policy, None).unwrap().fs_write;
assert!(newt_core::caveats::permits_path(
&writes,
program.to_str().unwrap()
));
let mut denied = policy.clone();
assert_eq!(
pin_exec_policy(&mut denied, &writes, Some(bin.as_os_str())).1,
["gh"]
);
assert_eq!(denied, policy);
#[cfg(target_os = "macos")]
{
use crate::disable_ocap_session_tests::EnvVar;
let _venv = EnvVar::unset("NEWT_VENV");
let _virtual_env = EnvVar::unset("VIRTUAL_ENV");
let _paths = EnvVar::set("NEWT_EXEC_PATHS", bin.to_str().unwrap());
let _path = EnvVar::set("PATH", "/usr/bin:/bin");
let key = temp.path().join("identity.pem");
for frozen in [false, true] {
let mut session =
SessionCapability::establish_launch(policy.clone(), Some(&key), frozen, &state)
.unwrap();
assert_eq!(session.caveats(), &policy);
assert!(session.take_exec_notice().unwrap().contains("gh"));
assert!(session.take_exec_notice().is_none());
let signed = newt_identity::enforced_caveats(session.op.as_ref().unwrap()).unwrap();
assert_eq!(signed.exec, policy.exec);
}
}
}
}