kaish_types/plan.rs
1//! The statement-plan vocabulary: pure data plus serde, no behavior.
2//!
3//! A [`Plan`] is what `plan_program` produces for one statement — the source
4//! rendered back **unexpanded**, every [`PlannedCommand`] it would run, and
5//! the variables it reads and writes. An embedder reads a plan to decide
6//! whether to run a statement; nothing here decides anything itself.
7//!
8//! [`PlannedValue`] is the one place redaction appears. The kernel redacts
9//! exactly one thing — the `--confirm=<key>` flag spelling, kaish's own
10//! convention for a confirmation credential — and a redacted value keeps a
11//! *kind*, never the credential. kaish ships no secret detector, because a
12//! shell cannot define what a secret is; an embedder that wants more redacts
13//! the plans it holds.
14
15use serde::{Deserialize, Serialize};
16
17/// A content identity for a plan — a digest over its rendered text with any
18/// presented credential stripped, so `rm x` and `rm --confirm=<key> x`
19/// digest the same. The embedder computes it (e.g. SHA-256 over the
20/// kernel's `strip_confirm_tokens(rendered)`); this type only carries the
21/// value, so `kaish-types` stays dependency-light.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct PlanDigest(String);
25
26impl PlanDigest {
27 /// Wrap an already-computed digest.
28 pub fn new(hex: impl Into<String>) -> Self {
29 Self(hex.into())
30 }
31
32 /// The digest's text form.
33 pub fn as_str(&self) -> &str {
34 &self.0
35 }
36}
37
38// ───────────────────────── Redaction ─────────────────────────
39
40/// One value inside a rendered plan. A sink serializes `PlannedValue`, never
41/// a bare `String`, so a value reaches a sink only after something decided
42/// whether it was a secret.
43///
44/// The kernel builds every `PlannedValue` at one normalization point
45/// (`kaish-kernel`'s `ast::plan::plan_statement`), before the plan reaches
46/// any consumer. A consumer added later reads the same already-decided
47/// values instead of re-deriving its own redaction.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PlannedValue {
52 /// Not judged secret. Holds the literal text, exactly as it would render
53 /// on the command line.
54 Plain(String),
55 /// Judged secret — today only by the kernel's own confirm-key check;
56 /// the original text never reaches this variant or anything built from
57 /// it. The variant is the vocabulary an embedder-side redaction pass can
58 /// also produce over plans it holds.
59 Redacted {
60 /// What kind of secret — `"confirm-key"` for the kernel's one
61 /// built-in redaction. The kernel does not interpret this string.
62 kind: String,
63 /// Stable salted digest prefix, when the producer supplied one, so an
64 /// auditor can ask "the same credential as last time?" without
65 /// holding it.
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 fingerprint: Option<String>,
68 },
69}
70
71impl PlannedValue {
72 /// Build a value the kernel judged secret.
73 pub fn redacted(kind: impl Into<String>, fingerprint: Option<String>) -> Self {
74 Self::Redacted {
75 kind: kind.into(),
76 fingerprint,
77 }
78 }
79
80 /// The text a sink should show: the literal for `Plain`, or `<kind>` for
81 /// `Redacted` — never the redacted content itself.
82 pub fn display(&self) -> String {
83 match self {
84 Self::Plain(s) => s.clone(),
85 Self::Redacted { kind, .. } => format!("<{kind}>"),
86 }
87 }
88
89 /// Whether this value was judged secret.
90 pub fn is_redacted(&self) -> bool {
91 matches!(self, Self::Redacted { .. })
92 }
93}
94
95// ───────────────────────── The statement plan ─────────────────────────
96
97/// What one top-level statement was asked to run (spec §C.6).
98///
99/// Built from the AST after validation and **before** execution, so it is
100/// parse information and never execution information: no substitution has
101/// run, no redirect has been opened, no loop has taken its first iteration.
102/// Nested statements — loop bodies, `if` branches, user-tool bodies — belong
103/// to their enclosing top-level statement's plan and are never planned
104/// separately.
105#[non_exhaustive]
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Plan {
108 /// The statement rendered back to shell text, **unexpanded**: `${HOME}`
109 /// and `$(...)` appear as written, because a classifier judges what was
110 /// asked, not what it resolved to. Truncated at
111 /// [`PLAN_RENDER_LIMIT`] bytes with a marker naming the limit.
112 pub rendered: String,
113 /// The statement's kind: `"command"`, `"pipeline"`, `"for"`,
114 /// `"and_chain"`, …
115 pub statement_kind: String,
116 /// Every command the statement contains, control-structure bodies
117 /// included.
118 pub commands: Vec<PlannedCommand>,
119 /// Session variables the statement reads and does not itself lexically
120 /// bind — sorted, deduplicated root names. Complete against the
121 /// statement's **lexical** surface — kaish has no `eval` and no indirect
122 /// expansion, so every read is visible in the source. It does not cover
123 /// names bound at runtime by a builtin that takes them as arguments:
124 /// `read`, `export`, `unset`, and `push` write session variables that
125 /// argv-level analysis cannot see, so `read TOKEN && echo $TOKEN`
126 /// reports `TOKEN` here, and the value an embedder peeks with
127 /// `Kernel::get_var` is the one from before the `read`. Special forms
128 /// (`$1`, `$?`, `$$`, `$@`, `$#`) are not session variables and are not
129 /// listed.
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub free_variables: Vec<String>,
132 /// Names the statement itself binds **lexically** — an assignment
133 /// target, a `for` variable, an env-prefix name, a tool-def parameter.
134 /// Peeking session state for these is misleading (the statement supplies
135 /// its own value), so a name that is both read and lexically bound lands
136 /// here, never in `free_variables` — the safe direction. A name written
137 /// only through a runtime binder (`read`, `export`, `unset`, `push`) is
138 /// a plain argument, not a lexical bind: it lands in `free_variables`
139 /// when the statement also reads it, and in neither set otherwise.
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub bound_variables: Vec<String>,
142}
143
144/// The byte limit [`Plan::rendered`] is truncated at: 8 KiB. A statement
145/// longer than this is a generated program, and a classifier that needs more
146/// than 8 KiB of it is reading the wrong field — [`Plan::commands`] carries
147/// the structure.
148pub const PLAN_RENDER_LIMIT: usize = 8 * 1024;
149
150impl Plan {
151 /// Assemble a plan. The only constructor for this `#[non_exhaustive]`
152 /// type — `rendered` is stored verbatim, so a producer truncates before
153 /// calling.
154 pub fn new(
155 rendered: impl Into<String>,
156 statement_kind: impl Into<String>,
157 commands: Vec<PlannedCommand>,
158 ) -> Self {
159 Self {
160 rendered: rendered.into(),
161 statement_kind: statement_kind.into(),
162 commands,
163 free_variables: Vec::new(),
164 bound_variables: Vec::new(),
165 }
166 }
167
168 /// Attach the statement's variable analysis (sorted, deduplicated).
169 pub fn with_variables(mut self, free: Vec<String>, bound: Vec<String>) -> Self {
170 self.free_variables = free;
171 self.bound_variables = bound;
172 self
173 }
174}
175
176/// One command inside a [`Plan`], as written (spec §C.6).
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct PlannedCommand {
180 /// argv0 as written — never resolved through aliases, `PATH`, or the
181 /// tool registry. Never a [`PlannedValue`]: a command name is structural,
182 /// never a credential.
183 pub name: String,
184 /// The arguments, rendered unexpanded — a presented confirm key reads as
185 /// `PlannedValue::Redacted` here rather than as its literal text
186 /// (spec §A.8).
187 pub args: Vec<PlannedValue>,
188 /// The redirections this command declares.
189 pub redirects: Vec<PlannedRedirect>,
190 /// Whether the enclosing pipeline was backgrounded with `&`.
191 pub background: bool,
192 /// The heredocs this command reads on stdin, in source order. Empty for
193 /// every command that declares none.
194 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub heredocs: Vec<PlannedHeredoc>,
196}
197
198impl PlannedCommand {
199 /// Name one planned command. The only constructor for this
200 /// `#[non_exhaustive]` type.
201 pub fn new(
202 name: impl Into<String>,
203 args: Vec<PlannedValue>,
204 redirects: Vec<PlannedRedirect>,
205 background: bool,
206 ) -> Self {
207 Self {
208 name: name.into(),
209 args,
210 redirects,
211 background,
212 heredocs: Vec::new(),
213 }
214 }
215
216 /// Attach the heredocs this command reads on stdin.
217 pub fn with_heredocs(mut self, heredocs: Vec<PlannedHeredoc>) -> Self {
218 self.heredocs = heredocs;
219 self
220 }
221}
222
223/// One heredoc a [`PlannedCommand`] reads on stdin — the body a command is
224/// fed, published as data.
225///
226/// Agents hand whole programs to interpreters this way (`python3 <<'PY'`,
227/// `sqlite3 <<SQL`), and the shell framing is the part that has to come off
228/// before anything can look at the program. It comes off here: the command
229/// name is on the [`PlannedCommand`], the language hint is
230/// [`delimiter`](Self::delimiter), and the program is [`body`](Self::body)
231/// with no quoting or escaping applied.
232///
233/// [`literal`](Self::literal) decides what the body is worth. A quoted
234/// delimiter (`<<'PY'`) means the body reaches the command exactly as
235/// published; an unquoted one means the shell expands `${…}` and `$(…)`
236/// first, so the published text is what was *asked for* and not what runs.
237#[non_exhaustive]
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct PlannedHeredoc {
240 /// The heredoc's position among every heredoc in its statement, counted
241 /// in source order across every command the statement contains. This is
242 /// the `heredoc` half of a [`FragmentAddr`].
243 pub index: usize,
244 /// The delimiter word as written, quotes removed: `PY` for both `<<PY`
245 /// and `<<'PY'`. Authors often pick it for the language they are about to
246 /// write, but roughly half the time it says nothing — a bare `EOF`
247 /// outnumbers every self-describing word combined in real agent traffic.
248 /// A weak hint worth keeping, never a classification.
249 pub delimiter: String,
250 /// Whether the delimiter was quoted (`<<'PY'`, `<<"PY"`). True means no
251 /// expansion runs and [`body`](Self::body) is exactly what the command
252 /// reads.
253 pub literal: bool,
254 /// Whether the `<<-` form was used. True means leading tabs come off each
255 /// body line before the command sees it; the published body keeps them.
256 pub strip_tabs: bool,
257 /// The body as written, verbatim: no tab stripping, no expansion, no
258 /// quoting, no kernel-internal rewriting. A generated program arrives
259 /// whole and unescaped, ready to hand to whatever reads that language.
260 pub body: PlannedValue,
261 /// Byte offset of the body's first character in the source that was
262 /// planned, for a caller attributing a finding back to a location.
263 pub body_offset: usize,
264 /// Session variables this body reads — sorted, deduplicated root names,
265 /// and always empty when [`literal`](Self::literal) is true. These are
266 /// the values that plug into the body, and the ones an expansion needs
267 /// supplied.
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub free_variables: Vec<String>,
270}
271
272impl PlannedHeredoc {
273 /// Assemble one planned heredoc. The only constructor for this
274 /// `#[non_exhaustive]` type.
275 pub fn new(
276 index: usize,
277 delimiter: impl Into<String>,
278 literal: bool,
279 strip_tabs: bool,
280 body: PlannedValue,
281 body_offset: usize,
282 ) -> Self {
283 Self {
284 index,
285 delimiter: delimiter.into(),
286 literal,
287 strip_tabs,
288 body,
289 body_offset,
290 free_variables: Vec::new(),
291 }
292 }
293
294 /// Attach the body's variable analysis (sorted, deduplicated).
295 pub fn with_free_variables(mut self, free: Vec<String>) -> Self {
296 self.free_variables = free;
297 self
298 }
299}
300
301// ───────────────────────── Fragment expansion ─────────────────────────
302
303/// Where one heredoc sits in a planned program: which statement, and which
304/// heredoc within it.
305///
306/// The heredoc index is flat across the whole statement — the same
307/// [`PlannedHeredoc::index`] the plan publishes — so a heredoc inside a loop
308/// body or an `if` branch is addressable without walking the structure.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
310pub struct FragmentAddr {
311 /// The statement's position in the parsed program.
312 pub statement: usize,
313 /// The heredoc's position within that statement.
314 pub heredoc: usize,
315}
316
317impl FragmentAddr {
318 /// Name one fragment.
319 pub fn new(statement: usize, heredoc: usize) -> Self {
320 Self { statement, heredoc }
321 }
322}
323
324/// What expanding a fragment produced.
325///
326/// There are two outcomes and no third: either the text is complete, or it is
327/// blocked and no text comes back at all. Half-expanded source reads as
328/// ground truth to whatever parses it next and is not, so this type cannot
329/// represent it.
330///
331/// Deliberately **not** `#[non_exhaustive]`, unlike the record types around
332/// it. A caller must handle both arms, and that is the guarantee — a wildcard
333/// arm written today to satisfy the attribute is exactly where a third
334/// outcome would land unnoticed tomorrow. A new variant here would be a
335/// change every embedder must see, so it should break their build.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum Expansion {
339 /// Every expansion resolved. This is exactly the text the command reads
340 /// on stdin, given the scope that was supplied.
341 Complete(String),
342 /// A `$(…)` stands between the body and its final text. Running it is a
343 /// decision with a clock and a blast radius, so the kernel returns the
344 /// question instead of answering it.
345 Blocked {
346 /// Every substitution the body contains, in source order.
347 holes: Vec<Hole>,
348 },
349}
350
351/// One `$(…)` inside a fragment: what it would run, as a plan.
352///
353/// A caller that decides the substitution is safe runs it in a kernel of its
354/// own construction — its own capabilities, its own timeout, its own
355/// cancellation — and expands again with the answer in the scope.
356#[non_exhaustive]
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct Hole {
359 /// The substitution rendered back to shell text, unexpanded: `$(date +%s)`.
360 pub source: String,
361 /// One plan per statement in the substitution's body — the same
362 /// vocabulary the enclosing statement's plan uses, so a caller judging a
363 /// hole reads it the way it reads everything else.
364 pub plans: Vec<Plan>,
365}
366
367impl Hole {
368 /// Name one substitution. The only constructor for this
369 /// `#[non_exhaustive]` type.
370 pub fn new(source: impl Into<String>, plans: Vec<Plan>) -> Self {
371 Self {
372 source: source.into(),
373 plans,
374 }
375 }
376}
377
378/// One redirection inside a [`PlannedCommand`] (spec §C.6).
379#[non_exhaustive]
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct PlannedRedirect {
382 /// The operator as written: `">"`, `">>"`, `"2>"`, `"<"`, `"<<<"`, …
383 pub kind: String,
384 /// The target, rendered unexpanded — `> ${LOG}` keeps `${LOG}` — and
385 /// through the same redaction seam every argument passes (spec §A.8).
386 pub target: PlannedValue,
387}
388
389impl PlannedRedirect {
390 /// Name one planned redirection. The only constructor for this
391 /// `#[non_exhaustive]` type.
392 pub fn new(kind: impl Into<String>, target: PlannedValue) -> Self {
393 Self {
394 kind: kind.into(),
395 target,
396 }
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn sample_plan() -> Plan {
405 Plan::new(
406 "cargo build > ${LOG}",
407 "command",
408 vec![PlannedCommand::new(
409 "cargo",
410 vec![PlannedValue::Plain("build".to_string())],
411 vec![PlannedRedirect::new(">", PlannedValue::Plain("${LOG}".to_string()))],
412 false,
413 )],
414 )
415 }
416
417 #[test]
418 fn a_plan_round_trips_with_every_planned_field() {
419 let plan = sample_plan();
420 let json = serde_json::to_value(&plan).expect("serialize");
421 let back: Plan = serde_json::from_value(json).expect("deserialize");
422 assert_eq!(plan, back);
423 assert_eq!(back.commands[0].redirects[0].kind, ">");
424 // Unexpanded: the target keeps `${LOG}` as written, because an
425 // embedder judges what was asked, not what it resolved to.
426 assert_eq!(
427 back.commands[0].redirects[0].target,
428 PlannedValue::Plain("${LOG}".to_string())
429 );
430 }
431
432 #[test]
433 fn variables_default_to_empty_and_survive_a_round_trip() {
434 let bare = sample_plan();
435 assert!(bare.free_variables.is_empty());
436 assert!(bare.bound_variables.is_empty());
437
438 let plan = sample_plan()
439 .with_variables(vec!["LOG".to_string()], vec!["OUT".to_string()]);
440 let json = serde_json::to_value(&plan).expect("serialize");
441 let back: Plan = serde_json::from_value(json).expect("deserialize");
442 assert_eq!(back.free_variables, vec!["LOG".to_string()]);
443 assert_eq!(back.bound_variables, vec!["OUT".to_string()]);
444 }
445
446 #[test]
447 fn a_redacted_value_keeps_no_text() {
448 // The kernel redacts its own confirm key and nothing else; the
449 // variant carries a kind, never the credential it replaced.
450 let json = serde_json::to_value(PlannedValue::redacted("confirm-key", None))
451 .expect("serialize");
452 assert!(
453 !json.to_string().contains("secret"),
454 "a redacted value must not carry text: {json}"
455 );
456 }
457
458 #[test]
459 fn a_plan_digest_round_trips() {
460 let digest = PlanDigest::new("abc123");
461 let json = serde_json::to_string(&digest).expect("serialize");
462 let back: PlanDigest = serde_json::from_str(&json).expect("deserialize");
463 assert_eq!(digest, back);
464 }
465}