Skip to main content

tear_types/
shutai.rs

1//! shutai (主体) — the acting entity behind a connection.
2//!
3//! Everything in tear's authorization surface is a projection of one
4//! question: **what is driving this pane, and can I trust the answer?**
5//!
6//! Today the answer is `Option<u64>` set by `Request::IdentifyClient(u64)`
7//! — a number the peer chooses, on a socket, with no verification. And
8//! `SessionSource` is worse than unverified: it is *caller-declared*. A
9//! client passes `SessionSource::Human` and the daemon records it, so the
10//! one field an operator would use to triage what an agent started is set
11//! by the thing being triaged.
12//!
13//! ## The split that makes this honest
14//!
15//! A shutai has two halves and **they are different tiers**. Flattening
16//! them into one enum is the mistake this type exists to prevent, because
17//! it would let a later reader believe the whole thing is verified.
18//!
19//! | half | source | can it be forged? | tier |
20//! |---|---|---|---|
21//! | [`Attested`] | the kernel, from the connection | **no** | truly-unrepresentable at the local boundary |
22//! | [`Declared`] | the peer says so | yes, by any same-uid process | only-mitigated |
23//!
24//! The attested half costs a syscall — `getpeereid` / `SO_PEERCRED` — and
25//! **no network call, no PKI, no broker**. It works offline, on a plane,
26//! with Akeyless deleted from the flake. That is why identity roots here
27//! rather than in a credential plane.
28//!
29//! The declared half is *provenance the daemon records*, not identity it
30//! verifies. Same-uid processes are mutually trusting by construction:
31//! anything that can open your socket can also read your files and send
32//! you signals. Claiming otherwise would be the round-up this project is
33//! most likely to commit.
34//!
35//! ## Why there is no `Deserialize`
36//!
37//! [`Shutai`] deliberately does **not** implement `Deserialize`. A peer
38//! cannot send one, because there is no code path that turns wire bytes
39//! into one — the daemon mints it from the connection it already holds.
40//! That is the structural difference from `IdentifyClient(u64)`, where the
41//! identity *is* the payload.
42//!
43//! `Serialize` is implemented: the daemon reports shutai outward (audit,
44//! `tear list`, MCP reads). Information flows out, authority does not flow
45//! in.
46
47use serde::Serialize;
48
49use crate::session::SessionSource;
50
51/// What the kernel says about the peer. Not forgeable by a payload.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "snake_case", tag = "kind")]
54pub enum Attested {
55    /// A local peer on the Unix socket, at this uid, per the kernel.
56    ///
57    /// The daemon's socket is `0600`, so in practice this is always the
58    /// operator's own uid — the mode is what makes that true, and the
59    /// peer credential is what makes it *knowable* for attribution.
60    LocalUid { uid: u32 },
61    /// A peer that arrived over TCP. There is no uid to attest: the
62    /// listener refuses to bind a non-loopback address without a token,
63    /// so this means either loopback (same trust boundary as the UDS) or
64    /// a token-bearing peer.
65    Remote,
66}
67
68/// What the peer says it is. Self-asserted; recorded, never trusted.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "snake_case", tag = "kind")]
71pub enum Declared {
72    /// Nothing was declared. The honest default — a connection that never
73    /// said what it is stays unknown rather than being assumed human.
74    Unknown,
75    /// An interactive operator.
76    Human,
77    /// An AI agent — Claude Code, Cursor, the mado MCP surface.
78    Agent { label: Option<String> },
79    /// An in-process reconciler: a vigy tatara-lisp script. Distinguished
80    /// from `Agent` because its actuator is different — it mutates
81    /// privileged state directly rather than typing into a pane.
82    Reconciler { label: Option<String> },
83}
84
85/// The acting entity behind one connection.
86///
87/// Minted by the daemon from a connection it holds; never parsed from a
88/// payload. See the module docs for why the two halves are separate.
89#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
90pub struct Shutai {
91    attested: Attested,
92    declared: Declared,
93}
94
95impl Shutai {
96    /// Mint from a local peer credential the daemon read from the socket.
97    ///
98    /// `uid` must come from the kernel (`getpeereid` / `SO_PEERCRED`) and
99    /// never from a request field. This is the one place the local arm is
100    /// created, so there is a single site to audit.
101    #[must_use]
102    pub const fn from_peer_uid(uid: u32) -> Self {
103        Self {
104            attested: Attested::LocalUid { uid },
105            declared: Declared::Unknown,
106        }
107    }
108
109    /// Mint for a peer with no attestable uid (TCP).
110    #[must_use]
111    pub const fn remote() -> Self {
112        Self {
113            attested: Attested::Remote,
114            declared: Declared::Unknown,
115        }
116    }
117
118    /// Record what the peer says it is.
119    ///
120    /// Takes `self` by value and returns a new value rather than mutating
121    /// in place, so a declaration is applied at one point in a connection's
122    /// setup rather than drifting later in its life.
123    #[must_use]
124    pub fn declaring(self, declared: Declared) -> Self {
125        Self { declared, ..self }
126    }
127
128    #[must_use]
129    pub const fn attested(&self) -> &Attested {
130        &self.attested
131    }
132
133    #[must_use]
134    pub const fn declared(&self) -> &Declared {
135        &self.declared
136    }
137
138    /// Is this a non-human actuator? The predicate `freio` needs to know
139    /// which panes to brake, and `ashiato` needs to attribute a block.
140    ///
141    /// Reads the DECLARED half, so it is exactly as trustworthy as the
142    /// peer's own claim — which is the honest answer, because a same-uid
143    /// process could lie and there is no local mechanism that would catch
144    /// it.
145    #[must_use]
146    pub const fn is_automation(&self) -> bool {
147        matches!(
148            self.declared,
149            Declared::Agent { .. } | Declared::Reconciler { .. }
150        )
151    }
152
153    /// The session provenance this actor implies.
154    ///
155    /// **This is the point of the type.** `SessionSource` is currently a
156    /// parameter the caller passes to `new_session_with_source`, so the
157    /// field an operator uses to triage what an agent started is set by
158    /// the thing being triaged. Deriving it from the connection closes
159    /// that: a client can still *lie about what it is*, but it can no
160    /// longer declare one thing and be recorded as another.
161    ///
162    /// The nix repo's `readOnly`-derived-option reflex, applied here: a
163    /// value that is a function of typed inputs is derived once, never
164    /// hand-passed by each consumer.
165    #[must_use]
166    pub fn session_source(&self) -> SessionSource {
167        match &self.declared {
168            Declared::Agent { label: Some(l) } | Declared::Reconciler { label: Some(l) } => {
169                SessionSource::Named(l.clone())
170            }
171            Declared::Agent { label: None } | Declared::Reconciler { label: None } => {
172                SessionSource::Agent
173            }
174            // An undeclared connection is recorded as Human, matching the
175            // existing `#[serde(default)]` on SessionSource so pre-shutai
176            // sessions keep their meaning.
177            Declared::Unknown | Declared::Human => SessionSource::Human,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn a_fresh_local_shutai_declares_nothing() {
188        let s = Shutai::from_peer_uid(501);
189        assert_eq!(*s.attested(), Attested::LocalUid { uid: 501 });
190        assert_eq!(*s.declared(), Declared::Unknown);
191        assert!(
192            !s.is_automation(),
193            "an undeclared connection must not be assumed to be an agent"
194        );
195    }
196
197    #[test]
198    fn declaring_does_not_touch_the_attested_half() {
199        let s = Shutai::from_peer_uid(501).declaring(Declared::Agent {
200            label: Some("claude-code".into()),
201        });
202        assert_eq!(
203            *s.attested(),
204            Attested::LocalUid { uid: 501 },
205            "a declaration must never be able to rewrite what the kernel said"
206        );
207        assert!(s.is_automation());
208    }
209
210    #[test]
211    fn session_source_is_derived_rather_than_declared() {
212        assert_eq!(
213            Shutai::from_peer_uid(1).session_source(),
214            SessionSource::Human,
215            "undeclared stays Human so pre-shutai sessions keep their meaning"
216        );
217        assert_eq!(
218            Shutai::from_peer_uid(1)
219                .declaring(Declared::Agent { label: None })
220                .session_source(),
221            SessionSource::Agent
222        );
223        assert_eq!(
224            Shutai::from_peer_uid(1)
225                .declaring(Declared::Agent {
226                    label: Some("pleme-ci".into())
227                })
228                .session_source(),
229            SessionSource::Named("pleme-ci".into())
230        );
231    }
232
233    /// A reconciler is an agent for triage purposes but a distinct
234    /// actuator: it mutates privileged state directly rather than typing.
235    #[test]
236    fn a_reconciler_is_automation_but_keeps_its_own_arm() {
237        let s = Shutai::from_peer_uid(1).declaring(Declared::Reconciler {
238            label: Some("ghost-session-sweeper".into()),
239        });
240        assert!(s.is_automation());
241        assert!(matches!(s.declared(), Declared::Reconciler { .. }));
242    }
243
244    /// ★ THE STRUCTURAL PROPERTY, as a forcing function.
245    ///
246    /// `Shutai` must never gain `Deserialize`. The whole difference from
247    /// `IdentifyClient(u64)` is that identity is *derived from the
248    /// connection* rather than *carried in the payload* — and a
249    /// `Deserialize` impl would silently restore the payload path.
250    ///
251    /// This cannot be asserted by the type system (you cannot test for the
252    /// absence of a trait impl at runtime), so it is a comment-stripped
253    /// source scan, the same construction as mado's `ux_unification.rs`
254    /// and garasu's `pane.rs` escape-hatch scan.
255    #[test]
256    fn shutai_never_becomes_deserializable() {
257        let src = include_str!("shutai.rs");
258        let code: String = src
259            .lines()
260            .map(str::trim_start)
261            .filter(|l| !l.starts_with("//"))
262            .collect::<Vec<_>>()
263            .join("\n");
264        let code = code.split("mod tests").next().unwrap_or(&code);
265
266        assert!(
267            !code.contains("Deserialize"),
268            "`Deserialize` appeared in shutai.rs. A peer would then be able \
269             to SEND a Shutai, which is exactly the payload-supplied identity \
270             this type replaces. If this is deliberate, the module docs must \
271             be re-graded in the same commit."
272        );
273        // Anti-vacuity: the scan must be looking at real code.
274        assert!(
275            code.contains("pub struct Shutai"),
276            "the scan lost sight of Shutai — fix the scan, not the assert"
277        );
278        assert!(
279            code.contains("Serialize"),
280            "Shutai must still serialise OUTWARD (audit, list, MCP reads)"
281        );
282    }
283}