sessiond: make the verb surface enumerable and its refusals branchable
This commit is contained in:
parent
4cd7ddd046
commit
18bd4a0974
2 changed files with 233 additions and 14 deletions
|
|
@ -40,9 +40,175 @@ pub const DEFAULT_PAM_SERVICE: &str = "souveraine-sessiond";
|
|||
/// before deciding the shell is broken and taking the lock back.
|
||||
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// Why a request was refused, in a form a caller can branch on.
|
||||
///
|
||||
/// Refusals were prose. A human reads "a live shell owns the session lock" and
|
||||
/// knows what to do; an agent composing several verbs into one intent
|
||||
/// (doctrine §13) cannot branch on a sentence. The reason stays — it is the
|
||||
/// only thing that explains *this* refusal rather than its class — but the
|
||||
/// code is what a chain reads.
|
||||
///
|
||||
/// The taxonomy is deliberately small. RedFlag's executor exit codes are the
|
||||
/// precedent (`NET_LAYER_PLAN.md` cites them for the same reason): a fixed
|
||||
/// vocabulary a caller can exhaust, not a growing list it must keep up with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RefusalCode {
|
||||
/// The op does not exist, or the line did not parse as a request.
|
||||
/// Retrying is pointless; the caller is speaking a vocabulary this daemon
|
||||
/// does not have. `describe` is the cure.
|
||||
UnsupportedOp,
|
||||
/// The op exists and the arguments are wrong — out of range, mismatched,
|
||||
/// or self-contradictory. Retrying with the same arguments is pointless.
|
||||
InvalidArgument,
|
||||
/// The op and arguments are fine and the machine's current state forbids
|
||||
/// it. This is the one a chain most often wants: it means *not now*, and
|
||||
/// the state that forbade it is worth reading before deciding what next.
|
||||
RefusedByState,
|
||||
/// The caller may not do this. Reserved — nothing issues it yet, because
|
||||
/// caller identity is uid 1000 for everything (audit P3). It exists so
|
||||
/// that when capability tokens land, callers already branch on it.
|
||||
NotPermitted,
|
||||
/// A dependency this op needs is absent — no compositor, no PAM stack, no
|
||||
/// sensor. Not the caller's fault and possibly transient.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl RefusalCode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RefusalCode::UnsupportedOp => "unsupported_op",
|
||||
RefusalCode::InvalidArgument => "invalid_argument",
|
||||
RefusalCode::RefusedByState => "refused_by_state",
|
||||
RefusalCode::NotPermitted => "not_permitted",
|
||||
RefusalCode::Unavailable => "unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the verb table `describe` returns.
|
||||
///
|
||||
/// Serialize-only: it is a const table on this side of the wire and JSON on the
|
||||
/// other, so the borrowed `&'static` fields never need to come back.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VerbDoc {
|
||||
/// The `op` string, exactly as it goes on the wire.
|
||||
pub op: &'static str,
|
||||
/// True when the op changes something. A caller planning a chain needs to
|
||||
/// know which steps are reversible reads and which are not.
|
||||
pub mutates: bool,
|
||||
/// What it does, in one line.
|
||||
pub summary: &'static str,
|
||||
/// The refusal codes this op can return. A chain plans its branches from
|
||||
/// this rather than discovering them by being refused.
|
||||
pub refuses: &'static [RefusalCode],
|
||||
/// A minimal request line that parses. Knowing an op exists is not enough
|
||||
/// to call it — `panel` needs `on`, `sensor_input` needs a source and a
|
||||
/// matching value — and a caller should not have to discover required
|
||||
/// fields by being refused. The test round-trips every one of these, so an
|
||||
/// example that stops parsing fails the build rather than the agent.
|
||||
pub example: &'static str,
|
||||
}
|
||||
|
||||
/// The verb table. Every `Request` variant appears here; a test enforces it.
|
||||
///
|
||||
/// This exists because the surface was not enumerable. An agent that owns the
|
||||
/// device (doctrine §13) has to be able to ask what it may do rather than be
|
||||
/// told, and a verb that is missing from this table is as unreachable as one
|
||||
/// that was never written.
|
||||
pub const VERBS: &[VerbDoc] = &[
|
||||
VerbDoc {
|
||||
op: "describe",
|
||||
mutates: false,
|
||||
summary: "this table — the verbs, which ones mutate, how each can refuse",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"describe"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "status",
|
||||
mutates: false,
|
||||
summary: "liveness and who currently owns the session lock",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"status"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "shell_ready",
|
||||
mutates: true,
|
||||
summary: "the shell announces itself; this connection becomes the heartbeat",
|
||||
refuses: &[RefusalCode::RefusedByState],
|
||||
example: r#"{"op":"shell_ready"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "locked_ack",
|
||||
mutates: true,
|
||||
summary: "the shell's lock surface reached compositor-acknowledged secure",
|
||||
refuses: &[RefusalCode::RefusedByState],
|
||||
example: r#"{"op":"locked_ack"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "lock",
|
||||
mutates: true,
|
||||
summary: "sessiond takes the session lock itself; refused while a live shell owns it",
|
||||
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
|
||||
example: r#"{"op":"lock"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "device_state",
|
||||
mutates: false,
|
||||
summary: "the unified state machine: state, panel, evidence, confidence, health",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"device_state"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "sensor_input",
|
||||
mutates: true,
|
||||
summary: "report a sensor reading as evidence; never an authority (doctrine §9)",
|
||||
refuses: &[RefusalCode::InvalidArgument],
|
||||
example: r#"{"op":"sensor_input","source":"proximity","value":{"near":true}}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "input",
|
||||
mutates: true,
|
||||
summary: "real user input happened; resets the idle budget",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"input","trigger":"touch"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "panel",
|
||||
mutates: true,
|
||||
summary: "the DPMS executor reports the panel's real power state",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"panel","on":false}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "forensic_log",
|
||||
mutates: false,
|
||||
summary: "recent trail entries from the in-memory buffer",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"forensic_log","count":50}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "get_policy",
|
||||
mutates: false,
|
||||
summary: "read the timed policy — the Auto-Lock shaped settings",
|
||||
refuses: &[],
|
||||
example: r#"{"op":"get_policy"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "set_policy",
|
||||
mutates: true,
|
||||
summary: "change the timed policy; persisted, so a setting survives restart",
|
||||
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
|
||||
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
/// The verb table — what this daemon can be asked, which ops mutate, and
|
||||
/// how each can refuse. A caller composing verbs reads this first.
|
||||
Describe,
|
||||
/// Liveness + who currently owns the session lock.
|
||||
Status,
|
||||
/// The shell announces itself. The connection carrying this request
|
||||
|
|
@ -179,6 +345,41 @@ mod tests {
|
|||
assert!(matches!(req, Request::Status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_request_variant_is_in_the_verb_table() {
|
||||
// The table is the vocabulary an agent composes over (doctrine §13).
|
||||
// A verb missing from it is as unreachable as one never written, so
|
||||
// this test is the thing that keeps them from drifting apart.
|
||||
//
|
||||
// Checked by round-tripping each op string through the deserializer:
|
||||
// if `describe` advertises an op the daemon cannot parse, the entry is
|
||||
// a lie, and if a variant is added without a table entry the count
|
||||
// below fails.
|
||||
for v in VERBS {
|
||||
let parsed: Result<Request, _> = serde_json::from_str(v.example);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"describe advertises `{}` with example `{}`, which the daemon cannot parse: {:?}",
|
||||
v.op,
|
||||
v.example,
|
||||
parsed.err()
|
||||
);
|
||||
}
|
||||
|
||||
// And the other direction: every variant must be advertised. Bump this
|
||||
// deliberately when a verb is added, having added its VerbDoc.
|
||||
assert_eq!(VERBS.len(), 12, "a Request variant was added without a VerbDoc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_code_survives_the_wire() {
|
||||
// A chain branches on the code, so it has to arrive intact.
|
||||
let raw = serde_json::to_string(&RefusalCode::RefusedByState).unwrap();
|
||||
assert_eq!(raw, r#""refused_by_state""#);
|
||||
let back: RefusalCode = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(back, RefusalCode::RefusedByState);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_serializes_snake_case() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ use crate::sessiond::idle;
|
|||
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
|
||||
use crate::sessiond::lockhint;
|
||||
use crate::sessiond::protocol::{
|
||||
InputTrigger, Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
|
||||
MAX_REQUEST_BYTES,
|
||||
InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
|
||||
MAX_REQUEST_BYTES, VERBS,
|
||||
};
|
||||
|
||||
/// How often the machine's clock runs. One second is fine: the shortest
|
||||
|
|
@ -412,8 +412,14 @@ fn start_ack_timer(shared: &Arc<Shared>, gen: u64) {
|
|||
});
|
||||
}
|
||||
|
||||
fn refusal(reason: &str) -> serde_json::Value {
|
||||
serde_json::json!({ "ok": false, "reason": reason })
|
||||
/// A refusal a caller can branch on.
|
||||
///
|
||||
/// `code` is the class, `reason` is this particular one. Doctrine §13: an
|
||||
/// agent composing verbs needs to know *which kind* of no it got — not now,
|
||||
/// never, or wrong arguments are three different next moves and a sentence
|
||||
/// does not distinguish them.
|
||||
fn refuse(code: RefusalCode, reason: &str) -> serde_json::Value {
|
||||
serde_json::json!({ "ok": false, "code": code.as_str(), "reason": reason })
|
||||
}
|
||||
|
||||
fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
|
||||
|
|
@ -447,7 +453,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
|||
}
|
||||
}
|
||||
if line.len() as u64 >= MAX_REQUEST_BYTES {
|
||||
let _ = respond(&mut writer, refusal("request exceeds size limit"));
|
||||
let _ = respond(&mut writer, refuse(RefusalCode::InvalidArgument, "request exceeds size limit"));
|
||||
break;
|
||||
}
|
||||
let line = line.trim();
|
||||
|
|
@ -456,7 +462,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
|||
}
|
||||
let response = match serde_json::from_str::<Request>(line) {
|
||||
Ok(req) => handle_request(req, &shared, Some(&writer), &mut heartbeat),
|
||||
Err(e) => refusal(&format!("malformed request: {e}")),
|
||||
Err(e) => refuse(RefusalCode::UnsupportedOp, &format!("malformed request: {e}")),
|
||||
};
|
||||
if respond(&mut writer, response).is_err() {
|
||||
break;
|
||||
|
|
@ -487,6 +493,18 @@ fn handle_request(
|
|||
heartbeat: &mut Option<u64>,
|
||||
) -> serde_json::Value {
|
||||
match req {
|
||||
Request::Describe => {
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"verbs": VERBS.iter().map(|v| serde_json::json!({
|
||||
"op": v.op,
|
||||
"mutates": v.mutates,
|
||||
"summary": v.summary,
|
||||
"refuses": v.refuses.iter().map(|c| c.as_str()).collect::<Vec<_>>(),
|
||||
"example": v.example,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
Request::Status => {
|
||||
let d = shared.lock();
|
||||
serde_json::json!({
|
||||
|
|
@ -505,7 +523,7 @@ fn handle_request(
|
|||
// authority must disconnect first; its connection handler clears
|
||||
// shell_alive before a replacement can register.
|
||||
if d.shell_alive {
|
||||
return refusal("shell authority is already registered");
|
||||
return refuse(RefusalCode::RefusedByState, "shell authority is already registered");
|
||||
}
|
||||
d.heartbeat_gen += 1;
|
||||
d.shell_alive = true;
|
||||
|
|
@ -527,7 +545,7 @@ fn handle_request(
|
|||
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if timed_out.timed_out() {
|
||||
return refusal("lock session did not release in time");
|
||||
return refuse(RefusalCode::Unavailable, "lock session did not release in time");
|
||||
}
|
||||
info!("handoff: lock released to shell (gen {gen})");
|
||||
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
|
||||
|
|
@ -542,7 +560,7 @@ fn handle_request(
|
|||
Request::LockedAck => {
|
||||
let mut d = shared.lock();
|
||||
if heartbeat.is_none() {
|
||||
return refusal("locked_ack from a connection that never sent shell_ready");
|
||||
return refuse(RefusalCode::RefusedByState, "locked_ack from a connection that never sent shell_ready");
|
||||
}
|
||||
d.phase = Phase::Released;
|
||||
info!("shell lock confirmed");
|
||||
|
|
@ -564,14 +582,14 @@ fn handle_request(
|
|||
match d.phase {
|
||||
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
|
||||
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
|
||||
refusal("a live shell owns the session lock; use the shell's lock IPC")
|
||||
refuse(RefusalCode::RefusedByState, "a live shell owns the session lock; use the shell's lock IPC")
|
||||
}
|
||||
_ => {
|
||||
drop(d);
|
||||
if spawn_lock_session(shared) {
|
||||
serde_json::json!({ "ok": true })
|
||||
} else {
|
||||
refusal("could not start a lock session")
|
||||
refuse(RefusalCode::Unavailable, "could not start a lock session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -601,7 +619,7 @@ fn handle_request(
|
|||
evidence.touch_active = *v;
|
||||
}
|
||||
_ => {
|
||||
return refusal("sensor source/value mismatch");
|
||||
return refuse(RefusalCode::InvalidArgument, "sensor source/value mismatch");
|
||||
}
|
||||
}
|
||||
let conf = evidence.confidence();
|
||||
|
|
@ -716,7 +734,7 @@ fn handle_request(
|
|||
if let Some(v) = lock_ack_budget_secs {
|
||||
if v == 0 {
|
||||
drop(d);
|
||||
return refusal("lock_ack_budget_secs must be at least 1");
|
||||
return refuse(RefusalCode::InvalidArgument, "lock_ack_budget_secs must be at least 1");
|
||||
}
|
||||
p.lock_ack_budget = Duration::from_secs(v);
|
||||
}
|
||||
|
|
@ -730,7 +748,7 @@ fn handle_request(
|
|||
if let Some(budget) = p.lock_blank_after {
|
||||
if p.dim_warning && p.dim_grace >= budget {
|
||||
drop(d);
|
||||
return refusal("dim_grace_secs must be shorter than lock_blank_after_secs");
|
||||
return refuse(RefusalCode::InvalidArgument, "dim_grace_secs must be shorter than lock_blank_after_secs");
|
||||
}
|
||||
}
|
||||
let applied = serde_json::json!({
|
||||
|
|
|
|||
Loading…
Reference in a new issue