crates\spt-daemon\src\linkhost.rs:47:use crate::grants::{self, EscalationAsk, GrantDecision};
crates\spt-daemon\src\linkhost.rs:84:    NeedsApproval(EscalationAsk, bool),
crates\spt-daemon\src\linkhost.rs:124:pub fn act_gate_decide(
crates\spt-daemon\src\linkhost.rs:131:) -> Option<(EscalationAsk, bool)> {
crates\spt-daemon\src\linkhost.rs:138:    let ask = EscalationAsk {
crates\spt-daemon\src\linkhost.rs:146:    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
crates\spt-daemon\src\linkhost.rs:147:    if persist_allowed {
crates\spt-daemon\src\linkhost.rs:154:    Some((ask, persist_allowed))
crates\spt-daemon\src\linkhost.rs:158:/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced
crates\spt-daemon\src\linkhost.rs:168:) -> Option<(EscalationAsk, bool)> {
crates\spt-daemon\src\linkhost.rs:182:    act_gate_decide(&store, shell, op, owner, &node, target)
crates\spt-daemon\src\linkhost.rs:632:        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)
crates\spt-daemon\src\linkhost.rs:637:        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());
crates\spt-daemon\src\linkhost.rs:642:            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
crates\spt-daemon\src\linkhost.rs:653:            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),
crates\spt-daemon\src\linkhost.rs:658:            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),
crates\spt-daemon\src\linkhost.rs:666:        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)


Read the act-gate impl + its unit tests, and how the CLI catches it.


40	use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell, ShellApproval};
41	use spt_store::grants::GrantStore;
42	use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};
43
44	use crate::access::{access_check, AccessDecision, InboundClass};
45	use crate::brain::{Brain, BrokerEvent};
46	use crate::consent::ConsentTarget;
47	use crate::grants::{self, EscalationAsk, GrantDecision};
48	use crate::shellchan;
49	use crate::shellhost;
50
51	/// Capability-id prefix for a per-capability act-gate grant (M11, REQ-CONSENT-3)
52	/// — `shell-act:<op>`, namespaced so it never collides with the spawn-gate id
53	/// (`spawn-shell`) or a reserved capability. The grant qualifier is the
54	/// manifest `class_key` (scoping finer than the op), agent = the owner
55	/// endpoint_id, node = this (target) node.
56	pub const CAP_SHELL_ACT_PREFIX: &str = "shell-act:";
57
58	/// A shell-link local action failure — CLI-facing text, shared by the wire
59	/// serve's reply. Carries no secret material.
60	#[derive(Debug, Clone, PartialEq, Eq)]
61	pub enum ShellLinkError {
62	    /// No instance of this owner matches the ref (canonical id or alias) —
63	    /// also the foreign-owner shape (owner exclusivity is resolution scope).
64	    NoShell,
65	    /// The instance's adapter is not registered/active on this node.
66	    AdapterUnregistered(String),
67	    /// `command_receipt = "http"` is modeled but unimplemented — refuse loud.
68	    ReceiptUnsupported,
69	    /// No link token parked (the instance never launched) — nothing to stamp
70	    /// a command frame against.
71	    NoLink,
72	    /// The op failed the manifest vocabulary check.
73	    Vocab(String),
74	    /// Anything mechanical (spool write, binary launch).
75	    Failed(String),
76	    /// Relink on an already-online instance (it IS the online switch).
77	    AlreadyOnline,
78	    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
79	    /// ungranted — the command did NOT spool. Carries the escalation ask plus
80	    /// whether allow-always may persist (`remembered` ⇒ true, `always` ⇒
81	    /// false), so the caller routes the consent prompt (the decide/caller-fires
82	    /// split mirrors the spawn gate: this core decides, the CLI/serve front
83	    /// door prompts).
84	    NeedsApproval(EscalationAsk, bool),
85	}
86
87	impl std::fmt::Display for ShellLinkError {
88	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89	        match self {
90	            ShellLinkError::NoShell => write!(f, "no such instance for that owner"),
91	            ShellLinkError::AdapterUnregistered(a) => {
92	                write!(f, "adapter {a} is not registered/active")
93	            }
94	            ShellLinkError::ReceiptUnsupported => {
95	                write!(f, "command_receipt=http is not implemented yet")
96	            }
97	            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
98	            ShellLinkError::Vocab(e) => write!(f, "{e}"),
99	            ShellLinkError::Failed(e) => write!(f, "{e}"),
100	            ShellLinkError::AlreadyOnline => {
101	                write!(f, "already online — relink is the online switch")
102	            }
103	            ShellLinkError::NeedsApproval(ask, _) => {
104	                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
105	            }
106	        }
107	    }
108	}
109
110	/// The pure per-capability act-gate decision (M11, REQ-CONSENT-3): does driving
111	/// `op` need an interactive approval first? `None` ⇒ proceed. `Some(ask)` ⇒
112	/// blocked, with the escalation ask the caller routes. Pure over the passed
113	/// store + resolved `target` (mirrors [`grants::decide`]) so it is
114	/// deterministic and testable.
115	///
116	/// Modes follow the spawn gate (CONTEXT §per-capability approval gates):
117	/// `none` ⇒ ungated; `remembered` ⇒ a grant row passes silently, else prompt
118	/// (allow-always persists); `always` ⇒ prompt every time (a grant never
119	/// auto-passes — allow-always is suppressed at the prompt). The manifest value
120	/// is the **floor**; a node/endpoint profile overlay may tighten it (the
121	/// overlay already merges through `resolve_option`, so a tightened
122	/// `require_approval` arrives on `shell` here — no separate floor plumbing).
123	// [impl->REQ-CONSENT-3]
124	pub fn act_gate_decide(
125	    store: &GrantStore,
126	    shell: &Shell,
127	    op: &str,
128	    owner: &str,
129	    node: &str,
130	    target: Option<ConsentTarget>,
131	) -> Option<(EscalationAsk, bool)> {
132	    let cap = shell.capabilities.get(op)?;
133	    if cap.require_approval == ShellApproval::None {
134	        return None; // ungated — the common path, zero grant I/O
135	    }
136	    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
137	    let qualifier = cap.class_key.clone();
138	    let ask = EscalationAsk {
139	        capability: capability.clone(),
140	        agent: owner.to_string(),
141	        node: node.to_string(),
142	        qualifier: qualifier.clone(),
143	    };
144	    // `remembered` may persist allow-always + consults the grant row;
145	    // `always` prompts unconditionally with allow-always suppressed.
146	    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
147	    if persist_allowed {
148	        if let GrantDecision::Allowed =
149	            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
150	        {
151	            return None;
152	        }
153	    }
154	    Some((ask, persist_allowed))
155	}
156
157	/// The act-gate's I/O front door: load this node's grant store + resolve the
158	/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced
159	/// **target-node-local** — for a cross-node cmd the grant is checked (and any
160	/// prompt routed) on the shell's node, where the grant store and the binary
161	/// live.
162	// [impl->REQ-CONSENT-3]
163	fn act_gate_pending(
164	    owlery: &Path,
165	    shell: &Shell,
166	    op: &str,
167	    owner: &str,
168	) -> Option<(EscalationAsk, bool)> {
169	    // Cheap pre-check: skip all I/O when the op is ungated (the default).
170	    if shell
171	        .capabilities
172	        .get(op)
173	        .is_none_or(|c| c.require_approval == ShellApproval::None)
174	    {
175	        return None;
176	    }
177	    let node = spt_store::nodeid::load_existing()
178	        .map(|id| id.public_key().to_hex())
179	        .unwrap_or_default();
180	    let store = GrantStore::load();
181	    let target = crate::consent::most_recently_active(owlery);
182	    act_gate_decide(&store, shell, op, owner, &node, target)
183	}
184
185	/// Resolve a shell ref into everything a link action needs: the canonical id,
186	/// the perch record, and the registered adapter's `[shell]` section
187	/// (capabilities resolve from the manifest by `adapter_name`, never the
188	/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
189	// [impl->REQ-SHELL-1]
190	pub fn resolve_link_target(
191	    owlery: &Path,
192	    owner: &str,
193	    shell_ref: &str,
194	) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
195	    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
196	        return Err(ShellLinkError::NoShell);
197	    };
198	    // Capabilities resolve from the merged view: the stored adapter_name is the
199	    // composite option, so a profiled shell links against its overlaid [shell].
200	    let adapters_dir = spt_store::perch::adapters_dir();
201	    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
202	        .ok()
203	        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
204	        .map(|(_, m)| m);
205	    let Some(shell) = manifest.and_then(|m| m.shell) else {
206	        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
207	    };
208	    Ok((id, info, shell))
209	}
210
211	/// Drive one vocabulary-checked typed command down the durable command
212	/// channel: vocab-check → compose → MAC-stamp under the parked token's link
213	/// key → spool on the shell perch. The shared core of the local
214	/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
215	/// the caller drains per the manifest receipt.
216	// [impl->REQ-SHELL-1]
217	pub fn drive_shell(
218	    owlery: &Path,
219	    owner: &str,
220	    shell_ref: &str,
221	    op: &str,
222	    args: &[String],
223	) -> Result<(String, Shell), ShellLinkError> {
224	    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
225	    if shell.command_receipt == Some(CommandReceipt::Http) {
226	        // Spawn already refuses http; this guards a manifest swapped after
227	        // spawn — an unimplemented mode never falls back silently.
228	        return Err(ShellLinkError::ReceiptUnsupported);
229	    }
230	    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
231	    let Some(token) = shellhost::read_link_token(&perch_path) else {
232	        return Err(ShellLinkError::NoLink);
233	    };
234	    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
235	    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
236	    // until granted — the command never lands in the durable channel on an
237	    // ungranted ask. Enforced here in the shared core so the cross-node serve
238	    // is gated identically to the local CLI (grants are target-node-local).
239	    // [impl->REQ-CONSENT-3]

use spt_runtime::manifest::{AdapterKind, CommandReceipt, Shell, ShellApproval};
use spt_store::grants::GrantStore;
use spt_store::shellinfo::{self, ShellInfo, SHELL_STATUS_ONLINE};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::consent::ConsentTarget;
use crate::grants::{self, EscalationAsk, GrantDecision};
use crate::shellchan;
use crate::shellhost;

/// Capability-id prefix for a per-capability act-gate grant (M11, REQ-CONSENT-3)
/// — `shell-act:<op>`, namespaced so it never collides with the spawn-gate id
/// (`spawn-shell`) or a reserved capability. The grant qualifier is the
/// manifest `class_key` (scoping finer than the op), agent = the owner
/// endpoint_id, node = this (target) node.
pub const CAP_SHELL_ACT_PREFIX: &str = "shell-act:";

/// A shell-link local action failure — CLI-facing text, shared by the wire
/// serve's reply. Carries no secret material.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellLinkError {
    /// No instance of this owner matches the ref (canonical id or alias) —
    /// also the foreign-owner shape (owner exclusivity is resolution scope).
    NoShell,
    /// The instance's adapter is not registered/active on this node.
    AdapterUnregistered(String),
    /// `command_receipt = "http"` is modeled but unimplemented — refuse loud.
    ReceiptUnsupported,
    /// No link token parked (the instance never launched) — nothing to stamp
    /// a command frame against.
    NoLink,
    /// The op failed the manifest vocabulary check.
    Vocab(String),
    /// Anything mechanical (spool write, binary launch).
    Failed(String),
    /// Relink on an already-online instance (it IS the online switch).
    AlreadyOnline,
    /// The op is approval-gated (per-capability act-gate, REQ-CONSENT-3) and
    /// ungranted — the command did NOT spool. Carries the escalation ask plus
    /// whether allow-always may persist (`remembered` ⇒ true, `always` ⇒
    /// false), so the caller routes the consent prompt (the decide/caller-fires
    /// split mirrors the spawn gate: this core decides, the CLI/serve front
    /// door prompts).
    NeedsApproval(EscalationAsk, bool),
}

impl std::fmt::Display for ShellLinkError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ShellLinkError::NoShell => write!(f, "no such instance for that owner"),
            ShellLinkError::AdapterUnregistered(a) => {
                write!(f, "adapter {a} is not registered/active")
            }
            ShellLinkError::ReceiptUnsupported => {
                write!(f, "command_receipt=http is not implemented yet")
            }
            ShellLinkError::NoLink => write!(f, "no link token parked (never launched)"),
            ShellLinkError::Vocab(e) => write!(f, "{e}"),
            ShellLinkError::Failed(e) => write!(f, "{e}"),
            ShellLinkError::AlreadyOnline => {
                write!(f, "already online — relink is the online switch")
            }
            ShellLinkError::NeedsApproval(ask, _) => {
                write!(f, "op '{}' needs approval (ungranted)", ask.capability)
            }
        }
    }
}

/// The pure per-capability act-gate decision (M11, REQ-CONSENT-3): does driving
/// `op` need an interactive approval first? `None` ⇒ proceed. `Some(ask)` ⇒
/// blocked, with the escalation ask the caller routes. Pure over the passed
/// store + resolved `target` (mirrors [`grants::decide`]) so it is
/// deterministic and testable.
///
/// Modes follow the spawn gate (CONTEXT §per-capability approval gates):
/// `none` ⇒ ungated; `remembered` ⇒ a grant row passes silently, else prompt
/// (allow-always persists); `always` ⇒ prompt every time (a grant never
/// auto-passes — allow-always is suppressed at the prompt). The manifest value
/// is the **floor**; a node/endpoint profile overlay may tighten it (the
/// overlay already merges through `resolve_option`, so a tightened
/// `require_approval` arrives on `shell` here — no separate floor plumbing).
// [impl->REQ-CONSENT-3]
pub fn act_gate_decide(
    store: &GrantStore,
    shell: &Shell,
    op: &str,
    owner: &str,
    node: &str,
    target: Option<ConsentTarget>,
) -> Option<(EscalationAsk, bool)> {
    let cap = shell.capabilities.get(op)?;
    if cap.require_approval == ShellApproval::None {
        return None; // ungated — the common path, zero grant I/O
    }
    let capability = format!("{CAP_SHELL_ACT_PREFIX}{op}");
    let qualifier = cap.class_key.clone();
    let ask = EscalationAsk {
        capability: capability.clone(),
        agent: owner.to_string(),
        node: node.to_string(),
        qualifier: qualifier.clone(),
    };
    // `remembered` may persist allow-always + consults the grant row;
    // `always` prompts unconditionally with allow-always suppressed.
    let persist_allowed = cap.require_approval == ShellApproval::Remembered;
    if persist_allowed {
        if let GrantDecision::Allowed =
            grants::decide(store, &capability, owner, node, qualifier.as_deref(), target)
        {
            return None;
        }
    }
    Some((ask, persist_allowed))
}

/// The act-gate's I/O front door: load this node's grant store + resolve the
/// most-recently-active escalation target, then [`act_gate_decide`]. Enforced
/// **target-node-local** — for a cross-node cmd the grant is checked (and any
/// prompt routed) on the shell's node, where the grant store and the binary
/// live.
// [impl->REQ-CONSENT-3]
fn act_gate_pending(
    owlery: &Path,
    shell: &Shell,
    op: &str,
    owner: &str,
) -> Option<(EscalationAsk, bool)> {
    // Cheap pre-check: skip all I/O when the op is ungated (the default).
    if shell
        .capabilities
        .get(op)
        .is_none_or(|c| c.require_approval == ShellApproval::None)
    {
        return None;
    }
    let node = spt_store::nodeid::load_existing()
        .map(|id| id.public_key().to_hex())
        .unwrap_or_default();
    let store = GrantStore::load();
    let target = crate::consent::most_recently_active(owlery);
    act_gate_decide(&store, shell, op, owner, &node, target)
}

/// Resolve a shell ref into everything a link action needs: the canonical id,
/// the perch record, and the registered adapter's `[shell]` section
/// (capabilities resolve from the manifest by `adapter_name`, never the
/// perch). The shared front half of [`drive_shell`] / [`relink_shell`].
// [impl->REQ-SHELL-1]
pub fn resolve_link_target(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, ShellInfo, Shell), ShellLinkError> {
    let Some((id, info)) = shellinfo::resolve_shell_ref(owlery, owner, shell_ref) else {
        return Err(ShellLinkError::NoShell);
    };
    // Capabilities resolve from the merged view: the stored adapter_name is the
    // composite option, so a profiled shell links against its overlaid [shell].
    let adapters_dir = spt_store::perch::adapters_dir();
    let manifest = spt_runtime::registry::resolve_option(&adapters_dir, &info.adapter_name)
        .ok()
        .filter(|(_, m)| m.adapter.kind == AdapterKind::Shell)
        .map(|(_, m)| m);
    let Some(shell) = manifest.and_then(|m| m.shell) else {
        return Err(ShellLinkError::AdapterUnregistered(info.adapter_name));
    };
    Ok((id, info, shell))
}

/// Drive one vocabulary-checked typed command down the durable command
/// channel: vocab-check → compose → MAC-stamp under the parked token's link
/// key → spool on the shell perch. The shared core of the local
/// `spt shell cmd` and the remote serve. Returns `(canonical_id, shell)` —
/// the caller drains per the manifest receipt.
// [impl->REQ-SHELL-1]
pub fn drive_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
    op: &str,
    args: &[String],
) -> Result<(String, Shell), ShellLinkError> {
    let (id, _info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
    if shell.command_receipt == Some(CommandReceipt::Http) {
        // Spawn already refuses http; this guards a manifest swapped after
        // spawn — an unimplemented mode never falls back silently.
        return Err(ShellLinkError::ReceiptUnsupported);
    }
    let perch_path = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, &id);
    let Some(token) = shellhost::read_link_token(&perch_path) else {
        return Err(ShellLinkError::NoLink);
    };
    let args_obj = shellchan::vocab_check(&shell, op, args).map_err(ShellLinkError::Vocab)?;
    // Per-capability act-gate (M11, REQ-CONSENT-3): a gated op refuses to spool
    // until granted — the command never lands in the durable channel on an
    // ungranted ask. Enforced here in the shared core so the cross-node serve
    // is gated identically to the local CLI (grants are target-node-local).
    // [impl->REQ-CONSENT-3]


600	            );
601
602	            // Vocab gate holds over the wire path too.
603	            let bad = vec!["selfdestruct".to_string()];
604	            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
605	            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
606	        });
607	    }
608
609	    fn gated_shell() -> Shell {
610	        // `attach` is class-keyed + remembered; `list` is ungated; `wipe` is
611	        // always-prompt.
612	        let src = "[adapter]\nname = \"usbip\"\nkind = \"shell\"\nversion = \"1\"\n\
613	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'p {link_token}'\n\n\
614	             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
615	             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
616	             [shell.capabilities.list]\n\n\
617	             [shell.capabilities.wipe]\nrequire_approval = \"always\"\n";
618	        spt_runtime::Manifest::from_toml_str(src)
619	            .unwrap()
620	            .shell
621	            .unwrap()
622	    }
623
624	    // [unit->REQ-CONSENT-3] the per-capability act-gate: an ungated op proceeds;
625	    // a `remembered` gated op blocks until a class-scoped grant exists, and a
626	    // grant for a DIFFERENT class does not cross; an `always` op prompts even
627	    // with a grant row. The ask carries the namespaced capability + the class
628	    // qualifier so the answer writes the right scope.
629	    #[test]
630	    fn per_capability_act_gate_blocks_until_class_scoped_grant() {
631	        let shell = gated_shell();
632	        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)
633	        let _ = owlery;
634	        let node = "nodehex";
635
636	        // Ungated `list` → proceed.
637	        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());
638
639	        // `remembered` `attach`, no grant → blocked, ask names the scope, and
640	        // allow-always may persist (remembered).
641	        let (ask, persist) =
642	            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
643	                .expect("ungranted attach blocks");
644	        assert_eq!(ask.capability, "shell-act:attach");
645	        assert_eq!(ask.agent, "gw");
646	        assert_eq!(ask.qualifier.as_deref(), Some("hid"));
647	        assert!(persist, "remembered mode allows persisting allow-always");
648
649	        // A grant for the HID class passes; a storage-class grant does NOT.
650	        let mut store = GrantStore::default();
651	        store.grant("shell-act:attach", "gw", node, Some("storage"));
652	        assert!(
653	            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),
654	            "a storage-class grant must not authorize a hid-class attach"
655	        );
656	        store.grant("shell-act:attach", "gw", node, Some("hid"));
657	        assert!(
658	            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),
659	            "the hid-class grant passes the gate"
660	        );
661
662	        // `always` `wipe` prompts even with a matching grant row, and
663	        // allow-always is suppressed (persist=false).
664	        let mut s2 = GrantStore::default();
665	        s2.grant("shell-act:wipe", "gw", node, None);
666	        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)
667	            .expect("always-mode prompts unconditionally");
668	        assert!(!persist, "always mode suppresses allow-always");
669	    }
670
671	    // [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated
672	    // ungranted op refuses and NEVER spools (the durable channel stays clean
673	    // until approval); an ungated op on the same shell spools as before.
674	    #[test]
675	    fn run_action_refuses_gated_op_without_spooling() {
676	        with_home(|_| {
677	            let owlery = perch::owlery_dir();
678	            // Register a shell whose `note` op is gated remembered.
679	            let src = perch::spt_home().join("srcs").join("Gated");
680	            std::fs::create_dir_all(&src).unwrap();
681	            #[cfg(windows)]
682	            let spawn = "cmd /C exit 0";
683	            #[cfg(unix)]
684	            let spawn = "true";
685	            std::fs::write(
686	                src.join("manifest.toml"),
687	                format!(
688	                    "[adapter]\nname = \"Gated\"\nkind = \"shell\"\nversion = \"1\"\n\
689	                     min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}}'\n\

            );

            // Vocab gate holds over the wire path too.
            let bad = vec!["selfdestruct".to_string()];
            let (out, _) = run_action(&owlery, "doyle", "Scout", SHELL_LINK_CMD, &bad, "op5");
            assert!(matches!(out, ShellLinkServeOutcome::Failed(_)), "{out:?}");
        });
    }

    fn gated_shell() -> Shell {
        // `attach` is class-keyed + remembered; `list` is ungated; `wipe` is
        // always-prompt.
        let src = "[adapter]\nname = \"usbip\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'p {link_token}'\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.capabilities.list]\n\n\
             [shell.capabilities.wipe]\nrequire_approval = \"always\"\n";
        spt_runtime::Manifest::from_toml_str(src)
            .unwrap()
            .shell
            .unwrap()
    }

    // [unit->REQ-CONSENT-3] the per-capability act-gate: an ungated op proceeds;
    // a `remembered` gated op blocks until a class-scoped grant exists, and a
    // grant for a DIFFERENT class does not cross; an `always` op prompts even
    // with a grant row. The ask carries the namespaced capability + the class
    // qualifier so the answer writes the right scope.
    #[test]
    fn per_capability_act_gate_blocks_until_class_scoped_grant() {
        let shell = gated_shell();
        let owlery = Path::new("/unused"); // act_gate_decide is pure (no I/O)
        let _ = owlery;
        let node = "nodehex";

        // Ungated `list` → proceed.
        assert!(act_gate_decide(&GrantStore::default(), &shell, "list", "gw", node, None).is_none());

        // `remembered` `attach`, no grant → blocked, ask names the scope, and
        // allow-always may persist (remembered).
        let (ask, persist) =
            act_gate_decide(&GrantStore::default(), &shell, "attach", "gw", node, None)
                .expect("ungranted attach blocks");
        assert_eq!(ask.capability, "shell-act:attach");
        assert_eq!(ask.agent, "gw");
        assert_eq!(ask.qualifier.as_deref(), Some("hid"));
        assert!(persist, "remembered mode allows persisting allow-always");

        // A grant for the HID class passes; a storage-class grant does NOT.
        let mut store = GrantStore::default();
        store.grant("shell-act:attach", "gw", node, Some("storage"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_some(),
            "a storage-class grant must not authorize a hid-class attach"
        );
        store.grant("shell-act:attach", "gw", node, Some("hid"));
        assert!(
            act_gate_decide(&store, &shell, "attach", "gw", node, None).is_none(),
            "the hid-class grant passes the gate"
        );

        // `always` `wipe` prompts even with a matching grant row, and
        // allow-always is suppressed (persist=false).
        let mut s2 = GrantStore::default();
        s2.grant("shell-act:wipe", "gw", node, None);
        let (_, persist) = act_gate_decide(&s2, &shell, "wipe", "gw", node, None)
            .expect("always-mode prompts unconditionally");
        assert!(!persist, "always mode suppresses allow-always");
    }

    // [unit->REQ-CONSENT-3] the gate holds in the serve action core: a gated
    // ungranted op refuses and NEVER spools (the durable channel stays clean
    // until approval); an ungated op on the same shell spools as before.
    #[test]
    fn run_action_refuses_gated_op_without_spooling() {
        with_home(|_| {
            let owlery = perch::owlery_dir();
            // Register a shell whose `note` op is gated remembered.
            let src = perch::spt_home().join("srcs").join("Gated");
            std::fs::create_dir_all(&src).unwrap();
            #[cfg(windows)]
            let spawn = "cmd /C exit 0";
            #[cfg(unix)]
            let spawn = "true";
            std::fs::write(
                src.join("manifest.toml"),
                format!(
                    "[adapter]\nname = \"Gated\"\nkind = \"shell\"\nversion = \"1\"\n\
                     min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn} {{link_token}}'\n\


Read drive_shell gate continuation + CLI escalate_act + the spawn-gate consent CLI test.


240	    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
241	        return Err(ShellLinkError::NeedsApproval(ask, persist));
242	    }
243	    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
244	    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
245	    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
246	    Ok((id, shell))
247	}
248
249	/// Re-spawn an offline (persistent) instance's binary — the manual online
250	/// switch (REQ-SHELL-2). No re-approval: the spawn gates govern minting; this
251	/// instance was approved at mint. A fresh link token is minted by the launch;
252	/// the perch onlines at the binary's bind, never at launch (the D3b
253	/// contract). The shared core of the local `spt shell relink` and the remote
254	/// serve. Returns `(canonical_id, launch_pid)`.
255	// [impl->REQ-SHELL-2]
256	pub fn relink_shell(
257	    owlery: &Path,
258	    owner: &str,
259	    shell_ref: &str,
260	) -> Result<(String, u32), ShellLinkError> {
261	    let (id, info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
262	    if info.status == SHELL_STATUS_ONLINE {
263	        return Err(ShellLinkError::AlreadyOnline);
264	    }
265	    let pid = shellhost::launch_shell(owlery, owner, &id, &info.adapter_name, &shell)
266	        .map_err(|e| ShellLinkError::Failed(format!("launch: {e}")))?;
267	    Ok((id, pid))
268	}
269
270	/// Why a shell-link serve returned — loud + typed, rendered into the
271	/// dispatch log.
272	#[derive(Debug, Clone, PartialEq, Eq)]
273	pub enum ShellLinkServeOutcome {
274	    /// The access gate refused the requesting origin — nothing ran, no reply.
275	    Refused,
276	    /// The action ran; the reply carried the report.
277	    Ok(String),
278	    /// The action failed; the failure text went back in the reply.
279	    Failed(String),
280	    /// The stream never produced a parseable request (or named an unknown
281	    /// action) — dropped.
282	    BadRequest(String),
283	}
284
285	/// Serve one shell-link stream against this node, blocking until the single
286	/// request runs (or refuses). `origin_node` MUST be the handshake-proven
287	/// remote id from the broker's stream table — never payload bytes
288	/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The gate subject is the wire-named owner
289	/// (the xfer posture: the wire picks whose whitelist, the origin it is
290	/// checked against stays transport-proven).
291	// [impl->REQ-SHELL-2]
292	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
293	pub fn serve_shell_link(
294	    brain: &mut Brain,
295	    stream_id: u64,
296	    origin_node: &str,
297	    owlery: &Path,
298	) -> io::Result<ShellLinkServeOutcome> {
299	    brain.net_stream_subscribe(stream_id, 0)?;

    if let Some((ask, persist)) = act_gate_pending(owlery, &shell, op, owner) {
        return Err(ShellLinkError::NeedsApproval(ask, persist));
    }
    let frame = shellchan::compose_command_frame(owner, op, &args_obj);
    let stamped = shellchan::stamp_frame(&shellhost::link_key(&token), &frame);
    shellchan::spool_shell_frame(owlery, owner, &id, &stamped).map_err(ShellLinkError::Failed)?;
    Ok((id, shell))
}

/// Re-spawn an offline (persistent) instance's binary — the manual online
/// switch (REQ-SHELL-2). No re-approval: the spawn gates govern minting; this
/// instance was approved at mint. A fresh link token is minted by the launch;
/// the perch onlines at the binary's bind, never at launch (the D3b
/// contract). The shared core of the local `spt shell relink` and the remote
/// serve. Returns `(canonical_id, launch_pid)`.
// [impl->REQ-SHELL-2]
pub fn relink_shell(
    owlery: &Path,
    owner: &str,
    shell_ref: &str,
) -> Result<(String, u32), ShellLinkError> {
    let (id, info, shell) = resolve_link_target(owlery, owner, shell_ref)?;
    if info.status == SHELL_STATUS_ONLINE {
        return Err(ShellLinkError::AlreadyOnline);
    }
    let pid = shellhost::launch_shell(owlery, owner, &id, &info.adapter_name, &shell)
        .map_err(|e| ShellLinkError::Failed(format!("launch: {e}")))?;
    Ok((id, pid))
}

/// Why a shell-link serve returned — loud + typed, rendered into the
/// dispatch log.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellLinkServeOutcome {
    /// The access gate refused the requesting origin — nothing ran, no reply.
    Refused,
    /// The action ran; the reply carried the report.
    Ok(String),
    /// The action failed; the failure text went back in the reply.
    Failed(String),
    /// The stream never produced a parseable request (or named an unknown
    /// action) — dropped.
    BadRequest(String),
}

/// Serve one shell-link stream against this node, blocking until the single
/// request runs (or refuses). `origin_node` MUST be the handshake-proven
/// remote id from the broker's stream table — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). The gate subject is the wire-named owner
/// (the xfer posture: the wire picks whose whitelist, the origin it is
/// checked against stays transport-proven).
// [impl->REQ-SHELL-2]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_shell_link(
    brain: &mut Brain,
    stream_id: u64,
    origin_node: &str,
    owlery: &Path,
) -> io::Result<ShellLinkServeOutcome> {
    brain.net_stream_subscribe(stream_id, 0)?;


Grep

fn escalate_act|fn escalate_spawn|fn apply_escalation_answer|NeedsApproval|escalate_act


crates\spt\src\cli.rs:5628:                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {
crates\spt\src\cli.rs:5629:                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
crates\spt\src\cli.rs:5775:        // The act-gate is intercepted at the cmd call site (escalate_act); a
crates\spt\src\cli.rs:5778:        E::NeedsApproval(ask, _) => eprintln!(
crates\spt\src\cli.rs:5895:fn escalate_spawn(
crates\spt\src\cli.rs:5963:fn escalate_act(


5600	            // [impl->REQ-SHELL-1]
5601	            let Some(owner) = owner_or_self(owner) else {
5602	                eprintln!("NO_SELF: shell cmd needs an owner (--owner <id> or $OWL_SESSION_ID)");
5603	                return 1;
5604	            };
5605	            // The cross-node arm (M5-D8c): `<ref>@<node>` ships the verb to
5606	            // the shell's node — commands ride Iroh, the action runs there.
5607	            // [impl->REQ-SHELL-2]
5608	            if let Some((r, node)) = shell_ref.split_once('@') {
5609	                return shell_link_remote(
5610	                    &owner,
5611	                    node,
5612	                    r,
5613	                    spt_net::net::shelllink::SHELL_LINK_CMD,
5614	                    &op,
5615	                );
5616	            }
5617	            let Some((op_name, args)) = op.split_first() else {
5618	                eprintln!("USAGE: shell cmd <ref> <op> [args…]");
5619	                return 2;
5620	            };
5621	            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
5622	                Ok((id, shell)) => {
5623	                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
5624	                }
5625	                // Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did
5626	                // NOT spool — route the approval prompt (the same plumbing the
5627	                // spawn gate uses) before the command may land.
5628	                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {
5629	                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
5630	                }
5631	                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
5632	            }
5633	        }
5634	        ShellCmd::Send {
5635	            shell_ref,
5636	            text,
5637	            file,
5638	            owner,
5639	        } => {
5640	            // The durable 2-way text+file channel, agent→shell direction
5641	            // (M5-D3c). File transfers land under the shell perch's `files/`
5642	            // with a progress-queryable XferProgress record.
5643	            // [impl->REQ-SHELL-1]
5644	            let Some(owner) = owner_or_self(owner) else {
5645	                eprintln!("NO_SELF: shell send needs an owner (--owner <id> or $OWL_SESSION_ID)");
5646	                return 1;
5647	            };
5648	            if text.is_none() && file.is_none() {
5649	                eprintln!("USAGE: shell send <ref> [text] [--file <path>]");
5650	                return 2;
5651	            }
5652	            let Some((id, shell, key)) = resolve_driveable(&owlery, &owner, &shell_ref) else {
5653	                return 1;
5654	            };
5655	            let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &id);
5656	            let mut what = Vec::new();
5657	            if let Some(text) = &text {
5658	                let frame = spt_daemon::shellchan::compose_text_frame(&owner, text);
5659	                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);

            // [impl->REQ-SHELL-1]
            let Some(owner) = owner_or_self(owner) else {
                eprintln!("NO_SELF: shell cmd needs an owner (--owner <id> or $OWL_SESSION_ID)");
                return 1;
            };
            // The cross-node arm (M5-D8c): `<ref>@<node>` ships the verb to
            // the shell's node — commands ride Iroh, the action runs there.
            // [impl->REQ-SHELL-2]
            if let Some((r, node)) = shell_ref.split_once('@') {
                return shell_link_remote(
                    &owner,
                    node,
                    r,
                    spt_net::net::shelllink::SHELL_LINK_CMD,
                    &op,
                );
            }
            let Some((op_name, args)) = op.split_first() else {
                eprintln!("USAGE: shell cmd <ref> <op> [args…]");
                return 2;
            };
            match spt_daemon::drive_shell(&owlery, &owner, &shell_ref, op_name, args) {
                Ok((id, shell)) => {
                    drain_per_receipt(&owlery, &owner, &id, &shell, &format!("op={op_name}"))
                }
                // Per-capability act-gate (M11, REQ-CONSENT-3): the gated op did
                // NOT spool — route the approval prompt (the same plumbing the
                // spawn gate uses) before the command may land.
                Err(spt_daemon::ShellLinkError::NeedsApproval(ask, persist)) => {
                    escalate_act(&owlery, &ask, persist, &format!("op '{op_name}' is gated"))
                }
                Err(e) => render_link_err("SHELL_CMD", &shell_ref, &owner, e),
            }
        }
        ShellCmd::Send {
            shell_ref,
            text,
            file,
            owner,
        } => {
            // The durable 2-way text+file channel, agent→shell direction
            // (M5-D3c). File transfers land under the shell perch's `files/`
            // with a progress-queryable XferProgress record.
            // [impl->REQ-SHELL-1]
            let Some(owner) = owner_or_self(owner) else {
                eprintln!("NO_SELF: shell send needs an owner (--owner <id> or $OWL_SESSION_ID)");
                return 1;
            };
            if text.is_none() && file.is_none() {
                eprintln!("USAGE: shell send <ref> [text] [--file <path>]");
                return 2;
            }
            let Some((id, shell, key)) = resolve_driveable(&owlery, &owner, &shell_ref) else {
                return 1;
            };
            let shell_perch = perch::resolve_shell_perch_path_in(&owlery, &owner, &id);
            let mut what = Vec::new();
            if let Some(text) = &text {
                let frame = spt_daemon::shellchan::compose_text_frame(&owner, text);
                let stamped = spt_daemon::shellchan::stamp_frame(&key, &frame);


5895	fn escalate_spawn(
5896	    owlery: &std::path::Path,
5897	    ask: &spt_daemon::grants::EscalationAsk,
5898	    persist_allowed: bool,
5899	    why: &str,
5900	) -> i32 {
5901	    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
5902	    use std::io::IsTerminal;
5903	    let adapter = ask.qualifier.as_deref().unwrap_or("?");
5904	    if std::io::stdin().is_terminal() {
5905	        let menu = if persist_allowed {
5906	            "[o]nce / [a]lways / [d]eny"
5907	        } else {
5908	            "[o]nce / [d]eny"
5909	        };
5910	        eprintln!("SHELL_SPAWN_APPROVAL:{adapter}: {why} — approve? {menu}");
5911	        let mut line = String::new();
5912	        let _ = std::io::stdin().read_line(&mut line);
5913	        let mut store = spt_store::grants::GrantStore::load();
5914	        match apply_escalation_answer(
5915	            &mut store,
5916	            ask,
5917	            parse_escalation_choice(&line, persist_allowed),
5918	        ) {
5919	            EscalationOutcome::ProceedOnce => 0,
5920	            EscalationOutcome::ProceedGranted => match store.save() {
5921	                Ok(()) => {
5922	                    eprintln!("GRANT_ADD:{}:{}:{adapter}", ask.capability, ask.agent);
5923	                    0
5924	                }
5925	                Err(e) => {
5926	                    eprintln!("GRANT_FAIL: {e}");
5927	                    1
5928	                }
5929	            },
5930	            EscalationOutcome::Denied => {
5931	                eprintln!("SHELL_SPAWN_DENIED:{adapter}: {why}");
5932	                1
5933	            }
5934	        }
5935	    } else if persist_allowed {
5936	        fire_spawn_consent_notif(owlery, ask);
5937	        eprintln!(
5938	            "CONSENT_PENDING:{adapter}: spawn needs approval ({why}) — answer the consent \
5939	             notif, or allow always: `spt grant add {} {} --qualifier {adapter}`",
5940	            ask.capability, ask.agent
5941	        );
5942	        1
5943	    } else {
5944	        eprintln!(
5945	            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
5946	             by definition (never persisted); re-run from a terminal"
5947	        );
5948	        1
5949	    }
5950	}
5951
5952	/// The interactive affordance for a gated shell **capability** ask (M11,
5953	/// REQ-CONSENT-3 — the per-capability act-gate). Mirrors [`escalate_spawn`]:
5954	/// a TTY prompts `[o]nce / [a]lways / [d]eny` (allow-always offered only on a
5955	/// `remembered` capability — `persist_allowed`), terminating through D1b's
5956	/// `apply_escalation_answer`; a non-TTY `remembered` fires the consent notif
5957	/// (whose body carries the durable `spt grant add …` path) and exits
5958	/// `CONSENT_PENDING`; a non-TTY `always` refuses outright (per-act approval is
5959	/// interactive by definition). Returns the exit code; `0` = proceed (the caller
5960	/// re-drives, which now passes the gate). The grant qualifier is the class key
5961	/// (`None` ⇒ the op id is the whole scope).
5962	// [impl->REQ-CONSENT-3]
5963	fn escalate_act(
5964	    owlery: &std::path::Path,
5965	    ask: &spt_daemon::grants::EscalationAsk,
5966	    persist_allowed: bool,
5967	    why: &str,
5968	) -> i32 {
5969	    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
5970	    use std::io::IsTerminal;
5971	    let class = ask.qualifier.as_deref().unwrap_or("—");
5972	    if std::io::stdin().is_terminal() {
5973	        let menu = if persist_allowed {
5974	            "[o]nce / [a]lways / [d]eny"
5975	        } else {
5976	            "[o]nce / [d]eny"
5977	        };
5978	        eprintln!("SHELL_ACT_APPROVAL:{}: {why} (class {class}) — approve? {menu}", ask.capability);
5979	        let mut line = String::new();
5980	        let _ = std::io::stdin().read_line(&mut line);
5981	        let mut store = spt_store::grants::GrantStore::load();
5982	        match apply_escalation_answer(
5983	            &mut store,
5984	            ask,
5985	            parse_escalation_choice(&line, persist_allowed),
5986	        ) {
5987	            EscalationOutcome::ProceedOnce => 0,
5988	            EscalationOutcome::ProceedGranted => match store.save() {
5989	                Ok(()) => {
5990	                    eprintln!("GRANT_ADD:{}:{}:{class}", ask.capability, ask.agent);
5991	                    0
5992	                }
5993	                Err(e) => {
5994	                    eprintln!("GRANT_FAIL: {e}");
5995	                    1
5996	                }
5997	            },
5998	            EscalationOutcome::Denied => {
5999	                eprintln!("SHELL_ACT_DENIED:{}: {why}", ask.capability);
6000	                1
6001	            }
6002	        }
6003	    } else if persist_allowed {
6004	        fire_spawn_consent_notif(owlery, ask);
6005	        let qual = ask
6006	            .qualifier
6007	            .as_deref()
6008	            .map(|q| format!(" --qualifier {q}"))
6009	            .unwrap_or_default();
6010	        eprintln!(
6011	            "CONSENT_PENDING:{}: {why} — answer the consent notif, or allow always: \
6012	             `spt grant add {} {}{qual}`",
6013	            ask.capability, ask.capability, ask.agent
6014	        );

fn escalate_spawn(
    owlery: &std::path::Path,
    ask: &spt_daemon::grants::EscalationAsk,
    persist_allowed: bool,
    why: &str,
) -> i32 {
    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
    use std::io::IsTerminal;
    let adapter = ask.qualifier.as_deref().unwrap_or("?");
    if std::io::stdin().is_terminal() {
        let menu = if persist_allowed {
            "[o]nce / [a]lways / [d]eny"
        } else {
            "[o]nce / [d]eny"
        };
        eprintln!("SHELL_SPAWN_APPROVAL:{adapter}: {why} — approve? {menu}");
        let mut line = String::new();
        let _ = std::io::stdin().read_line(&mut line);
        let mut store = spt_store::grants::GrantStore::load();
        match apply_escalation_answer(
            &mut store,
            ask,
            parse_escalation_choice(&line, persist_allowed),
        ) {
            EscalationOutcome::ProceedOnce => 0,
            EscalationOutcome::ProceedGranted => match store.save() {
                Ok(()) => {
                    eprintln!("GRANT_ADD:{}:{}:{adapter}", ask.capability, ask.agent);
                    0
                }
                Err(e) => {
                    eprintln!("GRANT_FAIL: {e}");
                    1
                }
            },
            EscalationOutcome::Denied => {
                eprintln!("SHELL_SPAWN_DENIED:{adapter}: {why}");
                1
            }
        }
    } else if persist_allowed {
        fire_spawn_consent_notif(owlery, ask);
        eprintln!(
            "CONSENT_PENDING:{adapter}: spawn needs approval ({why}) — answer the consent \
             notif, or allow always: `spt grant add {} {} --qualifier {adapter}`",
            ask.capability, ask.agent
        );
        1
    } else {
        eprintln!(
            "SHELL_APPROVAL_REQUIRED:{adapter}: {why} — per-spawn approval is interactive \
             by definition (never persisted); re-run from a terminal"
        );
        1
    }
}

/// The interactive affordance for a gated shell **capability** ask (M11,
/// REQ-CONSENT-3 — the per-capability act-gate). Mirrors [`escalate_spawn`]:
/// a TTY prompts `[o]nce / [a]lways / [d]eny` (allow-always offered only on a
/// `remembered` capability — `persist_allowed`), terminating through D1b's
/// `apply_escalation_answer`; a non-TTY `remembered` fires the consent notif
/// (whose body carries the durable `spt grant add …` path) and exits
/// `CONSENT_PENDING`; a non-TTY `always` refuses outright (per-act approval is
/// interactive by definition). Returns the exit code; `0` = proceed (the caller
/// re-drives, which now passes the gate). The grant qualifier is the class key
/// (`None` ⇒ the op id is the whole scope).
// [impl->REQ-CONSENT-3]
fn escalate_act(
    owlery: &std::path::Path,
    ask: &spt_daemon::grants::EscalationAsk,
    persist_allowed: bool,
    why: &str,
) -> i32 {
    use spt_daemon::grants::{apply_escalation_answer, EscalationOutcome};
    use std::io::IsTerminal;
    let class = ask.qualifier.as_deref().unwrap_or("—");
    if std::io::stdin().is_terminal() {
        let menu = if persist_allowed {
            "[o]nce / [a]lways / [d]eny"
        } else {
            "[o]nce / [d]eny"
        };
        eprintln!("SHELL_ACT_APPROVAL:{}: {why} (class {class}) — approve? {menu}", ask.capability);
        let mut line = String::new();
        let _ = std::io::stdin().read_line(&mut line);
        let mut store = spt_store::grants::GrantStore::load();
        match apply_escalation_answer(
            &mut store,
            ask,
            parse_escalation_choice(&line, persist_allowed),
        ) {
            EscalationOutcome::ProceedOnce => 0,
            EscalationOutcome::ProceedGranted => match store.save() {
                Ok(()) => {
                    eprintln!("GRANT_ADD:{}:{}:{class}", ask.capability, ask.agent);
                    0
                }
                Err(e) => {
                    eprintln!("GRANT_FAIL: {e}");
                    1
                }
            },
            EscalationOutcome::Denied => {
                eprintln!("SHELL_ACT_DENIED:{}: {why}", ask.capability);
                1
            }
        }
    } else if persist_allowed {
        fire_spawn_consent_notif(owlery, ask);
        let qual = ask
            .qualifier
            .as_deref()
            .map(|q| format!(" --qualifier {q}"))
            .unwrap_or_default();
        eprintln!(
            "CONSENT_PENDING:{}: {why} — answer the consent notif, or allow always: \
             `spt grant add {} {}{qual}`",
            ask.capability, ask.capability, ask.agent
        );


Good understanding of the chain. Now check `grant add` CLI (qualifier + node handling) and the mock-shell binary drain behavior to design the E2E.


crates\spt\src\cli.rs:105:        /// node — home is assigned at creation, never guessed).
crates\spt\src\cli.rs:116:    /// each other across every member node. Bare `spt subnet` shows the
crates\spt\src\cli.rs:144:    /// the degenerate single-node case of the cross-node attach (one pump,
crates\spt\src\cli.rs:155:        /// drive (REQ-KICK-1). Use on an endpoint another node controls.
crates\spt\src\cli.rs:159:    /// Consent grant store: gated capabilities held on this node.
crates\spt\src\cli.rs:162:    /// ungranted ask escalates interactively; `add` is the durable
crates\spt\src\cli.rs:168:    /// Adapter registration: what this node can drive/launch.
crates\spt\src\cli.rs:170:    /// The node-local registered set (one command for harness and shell
crates\spt\src\cli.rs:209:    /// The per-machine daemon: run, stop, or read node status.
crates\spt\src\cli.rs:211:    /// Bare `spt daemon` renders the node status view — daemon state,
crates\spt\src\cli.rs:232:    /// node's perches instead (id, state, address, ready, alive);
crates\spt\src\cli.rs:236:        /// List this node's local perches instead of the subnet view.
crates\spt\src\cli.rs:256:        /// registered `kind="harness"` adapter on this node). Omit (with --id)
crates\spt\src\cli.rs:287:    /// Same-node only today (one node holds one perch per name, so a
crates\spt\src\cli.rs:290:        /// The source endpoint (must exist on this node).
crates\spt\src\cli.rs:292:        /// The fork's id (must differ from the source on the same node).
crates\spt\src\cli.rs:305:    /// context save still fires first. Accepts a qualified `id@node` to
crates\spt\src\cli.rs:308:        /// The endpoint id (qualified `id@node` reaches a paired peer).
crates\spt\src\cli.rs:316:    /// qualified `id@node` for an instance on a paired peer.
crates\spt\src\cli.rs:318:        /// The endpoint id (qualified `id@node` reaches a paired peer).
crates\spt\src\cli.rs:363:    /// Endpoint access whitelist for unsolicited off-node inbound.
crates\spt\src\cli.rs:365:    /// Controls which origin nodes may send an endpoint unsolicited
crates\spt\src\cli.rs:366:    /// off-node inbound. Absent entry = open; `allow` flips the endpoint
crates\spt\src\cli.rs:367:    /// to restricted; revoking the last node leaves it locked down;
crates\spt\src\cli.rs:375:    /// Bare `description` shows your own; `set` authors it. The cross-node
crates\spt\src\cli.rs:403:    /// an empty string clears it back to the node-config seed).
crates\spt\src\cli.rs:460:    /// Whitelist a node for an endpoint (creates the restriction if absent).
crates\spt\src\cli.rs:464:        /// The origin node's pubkey hex (as shown by `spt endpoint list` / pairing).
crates\spt\src\cli.rs:465:        node: String,
crates\spt\src\cli.rs:467:    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
crates\spt\src\cli.rs:468:    /// last node leaves the endpoint locked down (all unsolicited refused).
crates\spt\src\cli.rs:469:    Revoke { endpoint: String, node: String },
crates\spt\src\cli.rs:505:    /// Create (or overwrite) a **local** profile — a node-local sparse overlay
crates\spt\src\cli.rs:565:    /// Record a grant: `agent` may exercise `capability` on this node.
crates\spt\src\cli.rs:570:        /// The gated capability id (e.g. spawn-shell, owner-shutdown).
crates\spt\src\cli.rs:574:        /// Narrower target within the node (e.g. the shell-adapter name for
crates\spt\src\cli.rs:575:        /// spawn-shell). Omitted = the node-wide row; the two never match
crates\spt\src\cli.rs:578:        qualifier: Option<String>,
crates\spt\src\cli.rs:581:    /// the named (capability, agent, qualifier) tuple goes.
crates\spt\src\cli.rs:586:        qualifier: Option<String>,

[Showing results with pagination = limit: 40]


adapters\mock\Cargo.toml
adapters\mock\manifest.toml
adapters\mock\src\shell.rs
adapters\mock\tests\manifest_valid.rs
adapters\mock-gateway\manifest.toml
adapters\mock\src\main.rs


Check the mock-shell binary's drain behavior (op-agnostic?) and grant add node handling.


1	//! `mock-shell` — the generic mock SHELL adapter binary (M5-D3e). The
2	//! relay-receipt stand-in a real shell binary (game robot, OS-notify) is
3	//! shaped like: broker-launched from the manifest's `spawn` template, it
4	//! proves the whole owner↔shell link through nothing but `spt api` calls:
5	//!
6	//! 1. `api bind-shell --link {link_token}` — the local-link handshake
7	//!    (onlines the perch; "owner from the link", the binary never knows it).
8	//! 2. `api poll <id> --link` — the relay drain: loop until the owner's
9	//!    command frame lands, write what was drained to the `--evidence` file.
10	//! 3. `api emit <id> --type sighted … --link` — a sensory push back at the
11	//!    owner's live session.
12	//! 4. Park resident — a relay shell idles between drains; teardown's kill is
13	//!    what ends it.
14	//!
15	//! Like `mock-session`, the `spt` binary resolves from `--spt <path>`, else
16	//! `$SPT_BIN`, else PATH — a dumb helper driving the public surface.
17
18	use std::process::Command;
19	use std::time::{Duration, Instant};
20
21	fn arg(args: &[String], flag: &str) -> Option<String> {
22	    args.iter()
23	        .position(|a| a == flag)
24	        .and_then(|i| args.get(i + 1).cloned())
25	}
26
27	/// An `spt` invocation that never surfaces a console window: this binary runs
28	/// DETACHED (console-less — KH 5.6 no-inherit spawn), and a console-subsystem
29	/// child with no console to inherit makes Windows allocate a visible one
30	/// (Windows 11 hosts it in a Terminal window — one popup per poll tick).
31	/// `CREATE_NO_WINDOW` + captured stdio keep the whole chain headless.
32	fn spt_cmd(spt: &str, args: &[&str]) -> Command {
33	    let mut c = Command::new(spt);
34	    c.args(args);
35	    #[cfg(windows)]
36	    {
37	        use std::os::windows::process::CommandExt;
38	        c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
39	    }
40	    c
41	}
42
43	fn required(args: &[String], flag: &str) -> String {
44	    arg(args, flag).unwrap_or_else(|| {
45	        eprintln!("mock-shell: {flag} required");
46	        std::process::exit(2);
47	    })
48	}
49
50	fn main() {
51	    let args: Vec<String> = std::env::args().skip(1).collect();
52	    let link = required(&args, "--link");
53	    let id = required(&args, "--id");
54	    let spt = arg(&args, "--spt")
55	        .or_else(|| std::env::var("SPT_BIN").ok())
56	        .unwrap_or_else(|| "spt".to_string());
57	    let evidence = arg(&args, "--evidence");
58
59	    // 1. The local-link handshake: bind flips the perch online.
60	    let bind = spt_cmd(
61	        &spt,
62	        &[
63	            "api",
64	            "--adapter",
65	            "mock-shell",
66	            "bind-shell",
67	            "--link",
68	            &link,
69	        ],
70	    )
71	    .output();
72	    if !matches!(&bind, Ok(o) if o.status.success()) {
73	        eprintln!("mock-shell: api bind-shell failed: {bind:?}");
74	        std::process::exit(1);
75	    }
76
77	    // 2. The relay drain: poll until the owner's command frame arrives.
78	    let deadline = Instant::now() + Duration::from_secs(60);
79	    let mut drained = String::new();
80	    while Instant::now() < deadline && drained.trim().is_empty() {
81	        match spt_cmd(
82	            &spt,
83	            &[
84	                "api",
85	                "--adapter",
86	                "mock-shell",
87	                "poll",
88	                &id,
89	                "--link",
90	                &link,
91	            ],
92	        )
93	        .output()
94	        {
95	            Ok(out) if out.status.success() => {
96	                drained.push_str(&String::from_utf8_lossy(&out.stdout));
97	            }
98	            _ => {}
99	        }
100	        if drained.trim().is_empty() {
101	            std::thread::sleep(Duration::from_millis(50));
102	        }
103	    }
104	    if drained.trim().is_empty() {
105	        eprintln!("mock-shell: nothing drained before the deadline");
106	        std::process::exit(1);
107	    }
108	    if let Some(path) = &evidence {
109	        if let Err(e) = std::fs::write(path, &drained) {
110	            eprintln!("mock-shell: write evidence: {e}");
111	            std::process::exit(1);
112	        }
113	    }
114
115	    // 3. Sensory back at the owner (live-or-drop by definition; a drop is
116	    //    still exit 0 — the channel's documented offline behavior).
117	    let _ = spt_cmd(
118	        &spt,
119	        &[
120	            "api",
121	            "--adapter",
122	            "mock-shell",
123	            "emit",
124	            &id,
125	            "--type",
126	            "sighted",
127	            "drained one command",
128	            "--link",
129	            &link,
130	        ],
131	    )
132	    .output();
133
134	    // 4. Park resident: teardown's kill ends a relay shell, not its own exit.
135	    loop {
136	        std::thread::sleep(Duration::from_secs(3600));
137	    }
138	}
139

//! `mock-shell` — the generic mock SHELL adapter binary (M5-D3e). The
//! relay-receipt stand-in a real shell binary (game robot, OS-notify) is
//! shaped like: broker-launched from the manifest's `spawn` template, it
//! proves the whole owner↔shell link through nothing but `spt api` calls:
//!
//! 1. `api bind-shell --link {link_token}` — the local-link handshake
//!    (onlines the perch; "owner from the link", the binary never knows it).
//! 2. `api poll <id> --link` — the relay drain: loop until the owner's
//!    command frame lands, write what was drained to the `--evidence` file.
//! 3. `api emit <id> --type sighted … --link` — a sensory push back at the
//!    owner's live session.
//! 4. Park resident — a relay shell idles between drains; teardown's kill is
//!    what ends it.
//!
//! Like `mock-session`, the `spt` binary resolves from `--spt <path>`, else
//! `$SPT_BIN`, else PATH — a dumb helper driving the public surface.

use std::process::Command;
use std::time::{Duration, Instant};

fn arg(args: &[String], flag: &str) -> Option<String> {
    args.iter()
        .position(|a| a == flag)
        .and_then(|i| args.get(i + 1).cloned())
}

/// An `spt` invocation that never surfaces a console window: this binary runs
/// DETACHED (console-less — KH 5.6 no-inherit spawn), and a console-subsystem
/// child with no console to inherit makes Windows allocate a visible one
/// (Windows 11 hosts it in a Terminal window — one popup per poll tick).
/// `CREATE_NO_WINDOW` + captured stdio keep the whole chain headless.
fn spt_cmd(spt: &str, args: &[&str]) -> Command {
    let mut c = Command::new(spt);
    c.args(args);
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
    }
    c
}

fn required(args: &[String], flag: &str) -> String {
    arg(args, flag).unwrap_or_else(|| {
        eprintln!("mock-shell: {flag} required");
        std::process::exit(2);
    })
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let link = required(&args, "--link");
    let id = required(&args, "--id");
    let spt = arg(&args, "--spt")
        .or_else(|| std::env::var("SPT_BIN").ok())
        .unwrap_or_else(|| "spt".to_string());
    let evidence = arg(&args, "--evidence");

    // 1. The local-link handshake: bind flips the perch online.
    let bind = spt_cmd(
        &spt,
        &[
            "api",
            "--adapter",
            "mock-shell",
            "bind-shell",
            "--link",
            &link,
        ],
    )
    .output();
    if !matches!(&bind, Ok(o) if o.status.success()) {
        eprintln!("mock-shell: api bind-shell failed: {bind:?}");
        std::process::exit(1);
    }

    // 2. The relay drain: poll until the owner's command frame arrives.
    let deadline = Instant::now() + Duration::from_secs(60);
    let mut drained = String::new();
    while Instant::now() < deadline && drained.trim().is_empty() {
        match spt_cmd(
            &spt,
            &[
                "api",
                "--adapter",
                "mock-shell",
                "poll",
                &id,
                "--link",
                &link,
            ],
        )
        .output()
        {
            Ok(out) if out.status.success() => {
                drained.push_str(&String::from_utf8_lossy(&out.stdout));
            }
            _ => {}
        }
        if drained.trim().is_empty() {
            std::thread::sleep(Duration::from_millis(50));
        }
    }
    if drained.trim().is_empty() {
        eprintln!("mock-shell: nothing drained before the deadline");
        std::process::exit(1);
    }
    if let Some(path) = &evidence {
        if let Err(e) = std::fs::write(path, &drained) {
            eprintln!("mock-shell: write evidence: {e}");
            std::process::exit(1);
        }
    }

    // 3. Sensory back at the owner (live-or-drop by definition; a drop is
    //    still exit 0 — the channel's documented offline behavior).
    let _ = spt_cmd(
        &spt,
        &[
            "api",
            "--adapter",
            "mock-shell",
            "emit",
            &id,
            "--type",
            "sighted",
            "drained one command",
            "--link",
            &link,
        ],
    )
    .output();

    // 4. Park resident: teardown's kill ends a relay shell, not its own exit.
    loop {
        std::thread::sleep(Duration::from_secs(3600));
    }
}


5560	                    1
5561	                }
5562	            }
5563	        }
5564	        ShellCmd::Rename {
5565	            shell_ref,
5566	            alias,
5567	            owner,
5568	        } => {
5569	            let Some(owner) = owner_or_self(owner) else {
5570	                eprintln!("NO_SELF: shell rename needs an owner (--owner <id>)");
5571	                return 1;
5572	            };
5573	            // [impl->REQ-HAZARD-ID-CHARSET]
5574	            if let Err(e) = spt_proto::id::validate_endpoint_id(&alias) {
5575	                eprintln!("INVALID_ALIAS:{alias}: {e}");
5576	                return 2;
5577	            }
5578	            match shellinfo::rename_shell(&owlery, &owner, &shell_ref, &alias) {
5579	                Ok(id) => {
5580	                    eprintln!("SHELL_RENAMED:{id} alias={alias}");
5581	                    0
5582	                }
5583	                Err(e) => {
5584	                    eprintln!("SHELL_RENAME_FAIL: {e}");
5585	                    1
5586	                }
5587	            }
5588	        }
5589	        ShellCmd::Cmd {
5590	            shell_ref,
5591	            op,
5592	            owner,
5593	        } => {
5594	            // The durable command channel (M5-D3c): vocabulary-check, stamp
5595	            // with the link key, spool on the shell perch, then drain per the
5596	            // manifest's command_receipt. The spool is the durability — a
5597	            // failed/offline delivery leaves the rows pending, never lost.
5598	            // The shared core lives daemon-side (`linkhost::drive_shell`) —
5599	            // the cross-node serve runs the identical path.
5600	            // [impl->REQ-SHELL-1]
5601	            let Some(owner) = owner_or_self(owner) else {

                    1
                }
            }
        }
        ShellCmd::Rename {
            shell_ref,
            alias,
            owner,
        } => {
            let Some(owner) = owner_or_self(owner) else {
                eprintln!("NO_SELF: shell rename needs an owner (--owner <id>)");
                return 1;
            };
            // [impl->REQ-HAZARD-ID-CHARSET]
            if let Err(e) = spt_proto::id::validate_endpoint_id(&alias) {
                eprintln!("INVALID_ALIAS:{alias}: {e}");
                return 2;
            }
            match shellinfo::rename_shell(&owlery, &owner, &shell_ref, &alias) {
                Ok(id) => {
                    eprintln!("SHELL_RENAMED:{id} alias={alias}");
                    0
                }
                Err(e) => {
                    eprintln!("SHELL_RENAME_FAIL: {e}");
                    1
                }
            }
        }
        ShellCmd::Cmd {
            shell_ref,
            op,
            owner,
        } => {
            // The durable command channel (M5-D3c): vocabulary-check, stamp
            // with the link key, spool on the shell perch, then drain per the
            // manifest's command_receipt. The spool is the durability — a
            // failed/offline delivery leaves the rows pending, never lost.
            // The shared core lives daemon-side (`linkhost::drive_shell`) —
            // the cross-node serve runs the identical path.
            // [impl->REQ-SHELL-1]
            let Some(owner) = owner_or_self(owner) else {


Full picture clear. mock-shell binary is op-agnostic (drains any frame), binds with hardcoded `--adapter mock-shell`. Plan: register one `mock-shell` adapter with gated `attach`(class hid) + `wipe`(class storage); spawn ungated; drive attach ungranted→refuse(no spool)→wrong-class grant still refuses→hid grant proceeds→evidence drained. Mirrors unit's class-scope cross-check end-to-end.

Write the E2E test.


Write

//! M11-W1 act-gate E2E (REQ-CONSENT-3) — closes the per-capability
//! class-keyed act-gate through the **real** `spt` binary + the `mock-shell`
//! adapter binary, no in-process shortcuts on the driven path. The interactive
//! loopback mirror of `shell_e2e.rs`'s spawn-gate floor, one rung lower: the
//! gate is now on the *command*, keyed by the manifest `class_key`, and the
//! durable channel stays clean until the right-class grant lands:
//!
//! spawn (ungated) → online → drive a `remembered` gated `attach` UNGRANTED:
//! the CLI exits `CONSENT_PENDING` and the frame NEVER spools (evidence stays
//! empty) → a grant for the WRONG class (`storage`) still refuses (class scope
//! is real, not a blanket op grant) → the RIGHT-class grant (`hid`, the
//! allow-always write) flips the same `attach` through to the binary's drain →
//! evidence lands. The class scoping is end-to-end: the grant row authorizes
//! `(owner × shell-act:attach × hid × node)` and nothing wider.
//
// [int->REQ-CONSENT-3]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

use spt_store::perch::{self, ParentHint};

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

/// In-process seed daemon so the children's `ensure_running` pings succeed —
/// no REAL detached `spt daemon` ever spawns (shell_e2e's pattern).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, run off-thread — bounds the KH 5.6
/// detached-pipe-inherit regression into a clean failure instead of a hang.
// [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT]
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect(
            "captured spt call must complete — a hang here means a detached child \
             inherited the capture pipe (KNOWN-HAZARDS 5.6 regression)",
        )
        .expect("run spt")
}

/// Author + register a shell adapter manifest under the current home.
fn register_adapter(name: &str, manifest: &str) {
    let src = perch::spt_home().join("srcs").join(name);
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
}

#[test]
fn act_gate_blocks_command_until_class_scoped_grant() {
    // One test in this file: a plain set_var is safe, every child inherits the
    // isolated home.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let shell_bin = sibling_bin("mock-shell");
    assert!(
        shell_bin.exists(),
        "mock-shell must be built: {}",
        shell_bin.display()
    );
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // The adapter under the binary's hardcoded `--adapter mock-shell` bind name.
    // Spawn is ungated (the instance mints freely); the per-capability gate
    // rides the COMMAND: `attach` is class-keyed `hid` + remembered; `wipe` is
    // class-keyed `storage` + remembered (a second class, to prove the grant
    // never crosses class). Sensory left out — this test drives the command
    // leg, not the sensory leg.
    let evidence = home.path().join("evidence.txt");
    let spawn_tmpl = format!(
        "\"{}\" --link {{link_token}} --id {{id}} --spt \"{}\" --evidence \"{}\"",
        shell_bin.display(),
        spt_bin.display(),
        evidence.display(),
    );
    register_adapter(
        "mock-shell",
        &format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn_tmpl}'\n\
             broadcast = \"same-node\"\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.capabilities.wipe]\nargs = [\"target\"]\n\
             require_approval = \"remembered\"\nclass_key = \"storage\"\n"
        ),
    );

    // The owner: a bound, LIVE Self perch + node identity (the act-gate is
    // node-scoped and non-minting; the grant store is target-node-local).
    let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
    std::fs::create_dir_all(&owner_perch).unwrap();
    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
    spt_store::info::write_info(&owner_perch, &rec).unwrap();
    std::fs::write(perch::resolve_ready_file("doyle", ParentHint::Infer), "").unwrap();
    let _listener = spt_msg::listener::PollListener::bind("doyle").expect("owner listener");
    spt_store::nodeid::load_or_create().expect("node identity");

    // ── spawn (ungated) → online.
    let out = spt(&[
        "shell", "spawn", "mock-shell", "--alias", "Stick", "--owner", "doyle",
    ]);
    assert!(
        out.status.success(),
        "spawn: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let owlery = perch::owlery_dir();
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
    let deadline = Instant::now() + Duration::from_secs(30);
    loop {
        let status = spt_store::shellinfo::read_shell_info(&shell_perch)
            .map(|i| i.status)
            .unwrap_or_default();
        if status == spt_store::shellinfo::SHELL_STATUS_ONLINE {
            break;
        }
        assert!(
            Instant::now() < deadline,
            "bind-shell never onlined the perch ({status})"
        );
        std::thread::sleep(Duration::from_millis(50));
    }

    // ── the gate holds on the command: an ungranted `remembered attach`
    // refuses (non-TTY ⇒ CONSENT_PENDING) and the frame NEVER spools — the
    // durable channel stays clean (evidence absent, the binary drained nothing).
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        !out.status.success(),
        "ungranted gated attach must refuse"
    );
    let pending = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        pending.contains("CONSENT_PENDING") && pending.contains("shell-act:attach"),
        "refused as a pending act-gate, naming the namespaced capability: {pending}"
    );
    assert!(
        std::fs::read_to_string(&evidence).map(|s| s.trim().is_empty()).unwrap_or(true),
        "the gated command must NOT spool before approval (channel stays clean)"
    );

    // ── class scope is real: a grant for the WRONG class (`storage`) does not
    // authorize the `hid`-class `attach` — it still refuses.
    let out = spt(&[
        "grant", "add", "shell-act:attach", "doyle", "--qualifier", "storage",
    ]);
    assert!(out.status.success(), "grant add (storage): {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        !out.status.success(),
        "a storage-class grant must not authorize a hid-class attach"
    );
    assert!(
        std::fs::read_to_string(&evidence).map(|s| s.trim().is_empty()).unwrap_or(true),
        "still no spool under the wrong-class grant"
    );

    // ── the RIGHT-class grant (`hid`, the allow-always write) flips the same
    // `attach` through: it spools, the resident binary drains it, evidence lands.
    let out = spt(&[
        "grant", "add", "shell-act:attach", "doyle", "--qualifier", "hid",
    ]);
    assert!(out.status.success(), "grant add (hid): {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        out.status.success(),
        "the hid-class grant passes the act-gate: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let deadline = Instant::now() + Duration::from_secs(60);
    let drained = loop {
        if let Ok(s) = std::fs::read_to_string(&evidence) {
            if !s.trim().is_empty() {
                break s;
            }
        }
        assert!(
            Instant::now() < deadline,
            "the binary never drained the approved command frame"
        );
        std::thread::sleep(Duration::from_millis(50));
    };
    assert!(
        drained.contains("attach") && drained.contains("busid-001"),
        "the drained frame is the approved command: {drained}"
    );

    // ── a DIFFERENT gated op (`wipe`, class `storage`) is unaffected by the
    // attach grant — per-capability scope, not per-shell — and still refuses.
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "wipe", "everything",
    ]);
    assert!(
        !out.status.success(),
        "the attach grant must not authorize a different gated op"
    );
    let pending = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        pending.contains("CONSENT_PENDING") && pending.contains("shell-act:wipe"),
        "wipe refuses on its own ungranted capability: {pending}"
    );

    // ── teardown frees the slot.
    let out = spt(&["shell", "teardown", "Stick", "--owner", "doyle"]);
    assert!(
        out.status.success(),
        "teardown: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Reap any REAL daemon that auto-started against this throwaway home (KH 5.6
    // no-inherit spawn means it can't wedge; this is CI hygiene). Best-effort.
    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(pid) = pid.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &pid.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
        }
    }
}


File created successfully at: crates\spt\tests\shell_actgate_e2e.rs (file state is current in your context — no need to Read it back)

//! M11-W1 act-gate E2E (REQ-CONSENT-3) — closes the per-capability
//! class-keyed act-gate through the **real** `spt` binary + the `mock-shell`
//! adapter binary, no in-process shortcuts on the driven path. The interactive
//! loopback mirror of `shell_e2e.rs`'s spawn-gate floor, one rung lower: the
//! gate is now on the *command*, keyed by the manifest `class_key`, and the
//! durable channel stays clean until the right-class grant lands:
//!
//! spawn (ungated) → online → drive a `remembered` gated `attach` UNGRANTED:
//! the CLI exits `CONSENT_PENDING` and the frame NEVER spools (evidence stays
//! empty) → a grant for the WRONG class (`storage`) still refuses (class scope
//! is real, not a blanket op grant) → the RIGHT-class grant (`hid`, the
//! allow-always write) flips the same `attach` through to the binary's drain →
//! evidence lands. The class scoping is end-to-end: the grant row authorizes
//! `(owner × shell-act:attach × hid × node)` and nothing wider.
//
// [int->REQ-CONSENT-3]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

use spt_store::perch::{self, ParentHint};

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

/// In-process seed daemon so the children's `ensure_running` pings succeed —
/// no REAL detached `spt daemon` ever spawns (shell_e2e's pattern).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, run off-thread — bounds the KH 5.6
/// detached-pipe-inherit regression into a clean failure instead of a hang.
// [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT]
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect(
            "captured spt call must complete — a hang here means a detached child \
             inherited the capture pipe (KNOWN-HAZARDS 5.6 regression)",
        )
        .expect("run spt")
}

/// Author + register a shell adapter manifest under the current home.
fn register_adapter(name: &str, manifest: &str) {
    let src = perch::spt_home().join("srcs").join(name);
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), manifest).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
}

#[test]
fn act_gate_blocks_command_until_class_scoped_grant() {
    // One test in this file: a plain set_var is safe, every child inherits the
    // isolated home.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let shell_bin = sibling_bin("mock-shell");
    assert!(
        shell_bin.exists(),
        "mock-shell must be built: {}",
        shell_bin.display()
    );
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // The adapter under the binary's hardcoded `--adapter mock-shell` bind name.
    // Spawn is ungated (the instance mints freely); the per-capability gate
    // rides the COMMAND: `attach` is class-keyed `hid` + remembered; `wipe` is
    // class-keyed `storage` + remembered (a second class, to prove the grant
    // never crosses class). Sensory left out — this test drives the command
    // leg, not the sensory leg.
    let evidence = home.path().join("evidence.txt");
    let spawn_tmpl = format!(
        "\"{}\" --link {{link_token}} --id {{id}} --spt \"{}\" --evidence \"{}\"",
        shell_bin.display(),
        spt_bin.display(),
        evidence.display(),
    );
    register_adapter(
        "mock-shell",
        &format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn_tmpl}'\n\
             broadcast = \"same-node\"\n\n\
             [shell.capabilities.attach]\nargs = [\"busid\"]\n\
             require_approval = \"remembered\"\nclass_key = \"hid\"\n\n\
             [shell.capabilities.wipe]\nargs = [\"target\"]\n\
             require_approval = \"remembered\"\nclass_key = \"storage\"\n"
        ),
    );

    // The owner: a bound, LIVE Self perch + node identity (the act-gate is
    // node-scoped and non-minting; the grant store is target-node-local).
    let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
    std::fs::create_dir_all(&owner_perch).unwrap();
    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
    spt_store::info::write_info(&owner_perch, &rec).unwrap();
    std::fs::write(perch::resolve_ready_file("doyle", ParentHint::Infer), "").unwrap();
    let _listener = spt_msg::listener::PollListener::bind("doyle").expect("owner listener");
    spt_store::nodeid::load_or_create().expect("node identity");

    // ── spawn (ungated) → online.
    let out = spt(&[
        "shell", "spawn", "mock-shell", "--alias", "Stick", "--owner", "doyle",
    ]);
    assert!(
        out.status.success(),
        "spawn: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let owlery = perch::owlery_dir();
    let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
    let deadline = Instant::now() + Duration::from_secs(30);
    loop {
        let status = spt_store::shellinfo::read_shell_info(&shell_perch)
            .map(|i| i.status)
            .unwrap_or_default();
        if status == spt_store::shellinfo::SHELL_STATUS_ONLINE {
            break;
        }
        assert!(
            Instant::now() < deadline,
            "bind-shell never onlined the perch ({status})"
        );
        std::thread::sleep(Duration::from_millis(50));
    }

    // ── the gate holds on the command: an ungranted `remembered attach`
    // refuses (non-TTY ⇒ CONSENT_PENDING) and the frame NEVER spools — the
    // durable channel stays clean (evidence absent, the binary drained nothing).
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        !out.status.success(),
        "ungranted gated attach must refuse"
    );
    let pending = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        pending.contains("CONSENT_PENDING") && pending.contains("shell-act:attach"),
        "refused as a pending act-gate, naming the namespaced capability: {pending}"
    );
    assert!(
        std::fs::read_to_string(&evidence).map(|s| s.trim().is_empty()).unwrap_or(true),
        "the gated command must NOT spool before approval (channel stays clean)"
    );

    // ── class scope is real: a grant for the WRONG class (`storage`) does not
    // authorize the `hid`-class `attach` — it still refuses.
    let out = spt(&[
        "grant", "add", "shell-act:attach", "doyle", "--qualifier", "storage",
    ]);
    assert!(out.status.success(), "grant add (storage): {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        !out.status.success(),
        "a storage-class grant must not authorize a hid-class attach"
    );
    assert!(
        std::fs::read_to_string(&evidence).map(|s| s.trim().is_empty()).unwrap_or(true),
        "still no spool under the wrong-class grant"
    );

    // ── the RIGHT-class grant (`hid`, the allow-always write) flips the same
    // `attach` through: it spools, the resident binary drains it, evidence lands.
    let out = spt(&[
        "grant", "add", "shell-act:attach", "doyle", "--qualifier", "hid",
    ]);
    assert!(out.status.success(), "grant add (hid): {}", String::from_utf8_lossy(&out.stderr));
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "attach", "busid-001",
    ]);
    assert!(
        out.status.success(),
        "the hid-class grant passes the act-gate: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let deadline = Instant::now() + Duration::from_secs(60);
    let drained = loop {
        if let Ok(s) = std::fs::read_to_string(&evidence) {
            if !s.trim().is_empty() {
                break s;
            }
        }
        assert!(
            Instant::now() < deadline,
            "the binary never drained the approved command frame"
        );
        std::thread::sleep(Duration::from_millis(50));
    };
    assert!(
        drained.contains("attach") && drained.contains("busid-001"),
        "the drained frame is the approved command: {drained}"
    );

    // ── a DIFFERENT gated op (`wipe`, class `storage`) is unaffected by the
    // attach grant — per-capability scope, not per-shell — and still refuses.
    let out = spt(&[
        "shell", "cmd", "--owner", "doyle", "Stick", "wipe", "everything",
    ]);
    assert!(
        !out.status.success(),
        "the attach grant must not authorize a different gated op"
    );
    let pending = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        pending.contains("CONSENT_PENDING") && pending.contains("shell-act:wipe"),
        "wipe refuses on its own ungranted capability: {pending}"
    );

    // ── teardown frees the slot.
    let out = spt(&["shell", "teardown", "Stick", "--owner", "doyle"]);
    assert!(
        out.status.success(),
        "teardown: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Reap any REAL daemon that auto-started against this throwaway home (KH 5.6
    // no-inherit spawn means it can't wedge; this is CI hygiene). Best-effort.
    if let Ok(pid) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(pid) = pid.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &pid.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
        }
    }
}


Now build + run the new E2E.


Bash

cargo test -p spt --test shell_actgate_e2e -- --nocapture 2>&1 | tail -40

Build + run new act-gate E2E


   Compiling spt v0.7.1 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 10.79s
     Running tests\shell_actgate_e2e.rs (target\debug\deps\shell_actgate_e2e-983e7889ae22b4b4.exe)

running 1 test
test act_gate_blocks_command_until_class_scoped_grant ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.22s


E2E green first run. Now activate REQ-CONSENT-3 full stages + add doc tag. Check current toml state + CONTEXT:283.


traceable-reqs.toml:963:# ownership audit — proof-not-change), REQ-CONSENT-3 at W1, REQ-SHELL-3 at W2,
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-964-# REQ-SHELL-4 at W3, REQ-SHELL-5 unit/int at the W4 Gateway-owner capstone.) ---
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-965-[[requirements]]
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-966-id = "REQ-SHELL-3"
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-967-[Omitted long context line]
traceable-reqs.toml-968-required_stages = []  # M11-W2 (machinery + mock exerciser only — doyle C1: no real GameRobot/usbip shell code)
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-969-[[requirements]]
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-970-id = "REQ-SHELL-4"
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-971-[Omitted long context line]
--
traceable-reqs.toml:974:id = "REQ-CONSENT-3"
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-975-[Omitted long context line]
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-976-required_stages = []  # M11-W1
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-977-[[requirements]]
C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml-978-id = "REQ-SHELL-5"
traceable-reqs.toml-979-title = "Shell ownership is owner-type-agnostic: any non-Shell endpoint type may own/spawn/drive/command/link a shell (Gateway the named first) — control-exclusivity keys on the owner endpoint_id, NEVER on the owner's endpoint type. No ownership path (mint, launch, owner-from-link, cmd, drive, tunnel, sleep/wake, owner-shutdown) inspects the owner's type (CONTEXT:264, ratified 2026-06-11 Gateway grill)."
traceable-reqs.toml-980-required_stages = ["doc", "impl"]  # M11-T0 activated (the code-only ownership audit — proof not change): doc = CONTEXT §Shell model "Owner-linked, exclusive" owner-type-agnostic note; impl = shellhost::find_shell_by_token (the "owner from the link" resolution returns an opaque owner-id with NO type inspection — the load-bearing type-agnostic path). unit/int activate at the W4 Gateway-owner capstone (a Gateway-typed owner drives/cmds/tunnels/links a shell identically)