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}