Skip to main content

zeroclaw_config/
presets.rs

1//! Quickstart preset tables and submission shape.
2//!
3//! Two preset tables — [`RISK_PRESETS`] and [`RUNTIME_PRESETS`] — give
4//! the Quickstart UI a fixed-shape menu of named, opinionated profile
5//! defaults the user can pick from. Each preset carries:
6//!
7//! - `preset_name`  — the alias key written to config when picked
8//!   (`risk-profiles.<preset_name>` / `runtime-profiles.<preset_name>`).
9//!   Never `default`. The preset is canonical: picking it again
10//!   overwrites the alias of the same name with the preset's struct
11//!   values.
12//! - `label` / `help` — the strings the UI renders.
13//! - `values` — a struct literal of [`RiskProfileConfig`] /
14//!   [`RuntimeProfileConfig`] field values. The Quickstart writes
15//!   these verbatim into the corresponding config table on apply.
16//!
17//! Adding or removing a preset is one row in the `risk_presets!` /
18//! `runtime_presets!` table below; every consumer dispatches off
19//! `&'static [RiskPreset]` / `&'static [RuntimePreset]` so drift is
20//! impossible.
21//!
22//! [`BuilderSubmission`] is the single payload shape both surfaces
23//! (web gateway HTTP route, zerocode RPC route) and the CLI build and
24//! hand to `zeroclaw-runtime`'s apply path. The runtime validates and
25//! writes atomically. There is exactly one type, one validator, one
26//! apply function — surface code never assembles config directly.
27
28use serde::{Deserialize, Serialize};
29
30use crate::autonomy::AutonomyLevel;
31use crate::autonomy::DelegationPolicy;
32use crate::policy::{default_allowed_commands, default_forbidden_paths};
33use crate::schema::{RiskProfileConfig, RuntimeProfileConfig};
34
35// ─────────────────────────────────────────────────────────────────────
36// Risk presets
37// ─────────────────────────────────────────────────────────────────────
38
39/// One row in the Risk preset table. The Quickstart UI renders the
40/// `label`, the runtime writes `values` to
41/// `risk-profiles.<preset_name>` on apply.
42#[derive(Debug, Clone, Copy, serde::Serialize)]
43pub struct RiskPreset {
44    /// Alias key written to `risk-profiles.<preset_name>`. Doubles as
45    /// the stable wire identifier (`BuilderSubmission.risk_preset`).
46    pub preset_name: &'static str,
47    /// Short label rendered in the picker UI.
48    pub label: &'static str,
49    /// One-line help blurb rendered next to the label.
50    pub help: &'static str,
51    /// Factory that produces the [`RiskProfileConfig`] this preset
52    /// installs. A function (not a const value) because
53    /// `RiskProfileConfig` has owned `Vec<String>` fields that cannot
54    /// live in a `const`.
55    #[serde(skip)]
56    pub values: fn() -> RiskProfileConfig,
57}
58
59/// Canonical Risk preset table. Order is the order the picker
60/// renders. Add or remove a preset by editing one row here; every
61/// consumer reads from the slice so nothing else has to change.
62pub const RISK_PRESETS: &[RiskPreset] = &[
63    RiskPreset {
64        preset_name: "locked_down",
65        label: "Locked Down",
66        help: "Tightest defaults. Workspace-only filesystem access, approval \
67               required for medium and high risk, no shell environment passthrough.",
68        values: locked_down_risk,
69    },
70    RiskPreset {
71        preset_name: "balanced",
72        label: "Balanced",
73        help: "Sensible day-to-day defaults. Workspace-scoped with approval \
74               gates on risky operations. Recommended for most users.",
75        values: balanced_risk,
76    },
77    RiskPreset {
78        preset_name: "yolo",
79        label: "YOLO",
80        help: "Full autonomy. No approval gates, no command denylist, no \
81               workspace scoping. Only pick this if you know what you're \
82               doing on a machine you don't mind breaking.",
83        values: yolo_risk,
84    },
85];
86
87/// Look up a Risk preset by its `preset_name`. Returns `None` for
88/// unknown keys.
89#[must_use]
90pub fn risk_preset(preset_name: &str) -> Option<&'static RiskPreset> {
91    RISK_PRESETS.iter().find(|p| p.preset_name == preset_name)
92}
93
94fn locked_down_risk() -> RiskProfileConfig {
95    RiskProfileConfig {
96        level: AutonomyLevel::Supervised,
97        workspace_only: true,
98        allowed_commands: default_allowed_commands(),
99        forbidden_paths: default_forbidden_paths(),
100        require_approval_for_medium_risk: true,
101        block_high_risk_commands: true,
102        shell_env_passthrough: vec![],
103        auto_approve: vec![],
104        always_ask: vec![],
105        allowed_roots: vec![],
106        delegation_policy: DelegationPolicy::default(),
107        allowed_tools: vec![],
108        excluded_tools: vec![],
109        sandbox_enabled: Some(true),
110        sandbox_backend: None,
111        firejail_args: vec![],
112    }
113}
114
115fn balanced_risk() -> RiskProfileConfig {
116    // Schema default is already the Balanced shape (Supervised,
117    // workspace_only=true, medium-risk approval). Use it directly so
118    // the preset can't drift away from the schema default by accident.
119    RiskProfileConfig::default()
120}
121
122fn yolo_risk() -> RiskProfileConfig {
123    RiskProfileConfig {
124        level: AutonomyLevel::Full,
125        workspace_only: false,
126        // YOLO means "no command denylist" — but an EMPTY allowlist is
127        // deny-by-default (`is_command_allowed` rejects any command not
128        // matched by an entry), so `vec![]` blocks every shell command.
129        // The `*` wildcard + `block_high_risk_commands: false` is what
130        // actually grants unrestricted execution (the trusted-env path in
131        // `is_command_allowed`).
132        allowed_commands: vec!["*".to_string()],
133        forbidden_paths: vec![],
134        require_approval_for_medium_risk: false,
135        block_high_risk_commands: false,
136        shell_env_passthrough: vec![],
137        auto_approve: vec![],
138        always_ask: vec![],
139        allowed_roots: vec![],
140        delegation_policy: DelegationPolicy::default(),
141        allowed_tools: vec![],
142        excluded_tools: vec![],
143        sandbox_enabled: Some(false),
144        sandbox_backend: None,
145        firejail_args: vec![],
146    }
147}
148
149// ─────────────────────────────────────────────────────────────────────
150// Runtime presets
151// ─────────────────────────────────────────────────────────────────────
152
153/// One row in the Runtime preset table. Same shape and contract as
154/// [`RiskPreset`] — see its docs for the per-field semantics.
155#[derive(Debug, Clone, Copy, serde::Serialize)]
156pub struct RuntimePreset {
157    /// Alias key written to `runtime-profiles.<preset_name>`. Doubles
158    /// as the stable wire identifier (`BuilderSubmission.runtime_preset`).
159    pub preset_name: &'static str,
160    /// Short label rendered in the picker UI.
161    pub label: &'static str,
162    /// One-line help blurb rendered next to the label.
163    pub help: &'static str,
164    /// Factory that produces the [`RuntimeProfileConfig`] this preset
165    /// installs.
166    #[serde(skip)]
167    pub values: fn() -> RuntimeProfileConfig,
168}
169
170/// Canonical Runtime preset table. See [`RISK_PRESETS`] for the
171/// ordering / consumer contract.
172pub const RUNTIME_PRESETS: &[RuntimePreset] = &[
173    RuntimePreset {
174        preset_name: "tight",
175        label: "Tight",
176        help: "Small budgets and short timeouts. Good for cheap models, \
177               metered API keys, or tight feedback loops where you want \
178               the agent to stop and ask early rather than burn budget.",
179        values: tight_runtime,
180    },
181    RuntimePreset {
182        preset_name: "balanced",
183        label: "Balanced",
184        help: "Middle-of-the-road operational defaults. Suits most users \
185               most of the time.",
186        values: balanced_runtime,
187    },
188    RuntimePreset {
189        preset_name: "unbounded",
190        label: "Unbounded",
191        help: "Wide-open budgets and long timeouts. Pick this when you're \
192               actively driving the agent through a hard task and don't \
193               want it to throttle.",
194        values: unbounded_runtime,
195    },
196];
197
198/// Look up a Runtime preset by its `preset_name`. Returns `None` for
199/// unknown keys.
200#[must_use]
201pub fn runtime_preset(preset_name: &str) -> Option<&'static RuntimePreset> {
202    RUNTIME_PRESETS
203        .iter()
204        .find(|p| p.preset_name == preset_name)
205}
206
207fn tight_runtime() -> RuntimeProfileConfig {
208    RuntimeProfileConfig {
209        agentic: false,
210        max_tool_iterations: 10,
211        max_actions_per_hour: 10,
212        max_cost_per_day_cents: 100,
213        shell_timeout_secs: 30,
214        max_delegation_depth: 1,
215        delegation_timeout_secs: Some(60),
216        agentic_timeout_secs: Some(120),
217        max_history_messages: Some(20),
218        max_context_tokens: Some(8_000),
219        compact_context: Some(true),
220        parallel_tools: Some(false),
221        tool_dispatcher: None,
222        tool_call_dedup_exempt: vec![],
223        max_system_prompt_chars: Some(4_000),
224        context_aware_tools: Some(true),
225        max_tool_result_chars: Some(8_000),
226        keep_tool_context_turns: Some(2),
227        memory_recall_limit: Some(3),
228        ..RuntimeProfileConfig::default()
229    }
230}
231
232fn balanced_runtime() -> RuntimeProfileConfig {
233    // Schema default is already the Balanced shape. Use it directly so
234    // the preset can't drift from the schema default.
235    RuntimeProfileConfig::default()
236}
237
238fn unbounded_runtime() -> RuntimeProfileConfig {
239    RuntimeProfileConfig {
240        agentic: true,
241        max_tool_iterations: 100,
242        // `0` is NOT "unlimited" for these budgets — the per-sender rate
243        // tracker treats a max of 0 as *exhausted* (see
244        // `PerSenderTracker::is_exhausted` / `rate_limit_zero_blocks_everything`),
245        // so an `unbounded` agent set to 0 has every action rejected. Use the
246        // type max for an effectively-unlimited budget instead.
247        max_actions_per_hour: u32::MAX,
248        max_cost_per_day_cents: u32::MAX,
249        shell_timeout_secs: 600,
250        max_delegation_depth: 8,
251        delegation_timeout_secs: Some(900),
252        agentic_timeout_secs: Some(1_800),
253        max_history_messages: Some(200),
254        max_context_tokens: Some(128_000),
255        compact_context: Some(false),
256        parallel_tools: Some(true),
257        tool_dispatcher: None,
258        tool_call_dedup_exempt: vec![],
259        max_system_prompt_chars: Some(64_000),
260        context_aware_tools: Some(true),
261        max_tool_result_chars: Some(64_000),
262        keep_tool_context_turns: Some(8),
263        memory_recall_limit: Some(10),
264        ..RuntimeProfileConfig::default()
265    }
266}
267
268// ─────────────────────────────────────────────────────────────────────
269// BuilderSubmission and dependent choice types
270// ─────────────────────────────────────────────────────────────────────
271
272/// Choice for the Memory step. Re-exports the schema's canonical
273/// `MemoryBackendKind` so Quickstart never re-defines the list of
274/// memory backends — adding a backend to
275/// `zeroclaw_config::multi_agent::MemoryBackendKind` lights up in
276/// every Quickstart surface automatically.
277pub use crate::multi_agent::MemoryBackendKind as MemoryChoice;
278
279/// Model provider widget submission. The Quickstart UI surfaces only
280/// the "greatest hits" fields an agent literally cannot start
281/// without; everything else (retry policy, rate limits, custom
282/// headers) lives in the post-Quickstart config editor.
283///
284/// `provider_type` is the type key written to
285/// `providers.models.<provider_type>.<alias>`. The exact set of
286/// recognised type strings tracks the existing
287/// `providers::ProviderKind`; Quickstart validates the chosen value
288/// at apply time via the runtime entry point.
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
291pub struct ModelProviderChoice {
292    /// Provider type identifier (`anthropic`, `openai`, `openrouter`,
293    /// `ollama`, etc.). Used as the type segment in the TOML path.
294    pub provider_type: String,
295    /// User-named alias. Defaults to `"default"` in the UI; users
296    /// override when stacking multiple aliases of the same provider
297    /// type (e.g. `anthropic-work`, `anthropic-personal`).
298    pub alias: String,
299    /// Model id written to `providers.models.<type>.<alias>.model` at
300    /// apply time.
301    pub model: String,
302    /// Round-trip of every field the daemon described in
303    /// `quickstart/fields`. Surfaces echo back exactly what was
304    /// emitted; the daemon writes each entry under `<prefix>.<key>`
305    /// using its own schema knowledge.
306    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
307    pub fields: std::collections::HashMap<String, String>,
308}
309
310/// Single-channel entry submitted by the Channels widget. The
311/// Channels selector renders a `Vec<ChannelQuickStart>`; Quickstart
312/// writes one `channels.<channel_type>.<alias>` block per entry.
313///
314/// Channels are optional: an empty `Vec` is a valid Quickstart
315/// submission (the agent will only be reachable via
316/// `zeroclaw agent <alias>` from the CLI).
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
318#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
319pub struct ChannelQuickStart {
320    /// Channel type identifier (`cli`, `telegram`, `discord`, `web`
321    /// in the FTUE-supported set).
322    pub channel_type: String,
323    /// User-named alias for this channel entry. Defaults to
324    /// `channel_type` in the UI; users override when stacking
325    /// multiple aliases of the same channel type.
326    pub alias: String,
327    /// Bot token / shared secret if the channel needs one
328    /// (Telegram, Discord). `None` for channels that don't.
329    pub token: Option<String>,
330}
331
332/// Agent identity payload from the Agent step. Personality file
333/// authoring is handled by the existing `PersonalityEditor` widget;
334/// Quickstart passes only the chosen `personality_file` path here.
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
336#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
337pub struct AgentIdentity {
338    /// Agent alias — also the key written to `agents.<name>`. Must
339    /// not collide with an existing agent alias; runtime validation
340    /// rejects collisions before apply.
341    pub name: String,
342    /// System prompt text. Sourced from the personality template
343    /// picker in the UI (`default` or `blank`); Quickstart does not
344    /// pre-fill this field itself.
345    pub system_prompt: String,
346    /// Optional personality file path written to
347    /// `agents.<name>.personality_file`. `None` ships the agent with
348    /// no personality file (the existing optional pattern).
349    pub personality_file: Option<String>,
350    /// Staged personality file contents to write into the agent's
351    /// workspace during the atomic apply. Empty list = no files
352    /// written. Surfaces validate the filename against the canonical
353    /// `EDITABLE_PERSONALITY_FILES` list before staging.
354    #[serde(default)]
355    pub personality_files: Vec<QuickstartPersonalityFile>,
356}
357
358/// One personality file staged for write during Quickstart apply.
359/// The runtime writes `<workspace>/<filename>` with `content`,
360/// overwriting if the path already exists.
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
363pub struct QuickstartPersonalityFile {
364    /// Filename from `EDITABLE_PERSONALITY_FILES`.
365    pub filename: String,
366    /// File body. Subject to `MAX_FILE_CHARS` at apply time.
367    pub content: String,
368}
369
370/// The complete Quickstart submission both surfaces hand to
371/// `zeroclaw-runtime::quickstart::apply` (and pre-validate via
372/// `validate_only`). Single source of truth; assembling config
373/// outside this type is a layering bug.
374///
375/// Every field's `*_preset` / choice value is the user's resolved
376/// selection — the runtime translates preset keys into struct
377/// values via [`risk_preset`] / [`runtime_preset`] and looks up
378/// existing aliases against the live config when the UI submitted
379/// "use existing" rather than a fresh choice.
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
381#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
382pub struct BuilderSubmission {
383    /// Model provider step submission. Always a `Create new` shape
384    /// in this initial cut — `Use existing` is represented by
385    /// [`SelectorChoice::Existing`] in the wrapper enum below.
386    pub model_provider: SelectorChoice<ModelProviderChoice>,
387    /// Risk profile preset key from [`RISK_PRESETS`], or the alias
388    /// of an existing `risk-profiles.<alias>`.
389    pub risk_profile: SelectorChoice<String>,
390    /// Runtime profile preset key from [`RUNTIME_PRESETS`], or the
391    /// alias of an existing `runtime-profiles.<alias>`.
392    pub runtime_profile: SelectorChoice<String>,
393    /// Memory step. Either a fresh [`MemoryChoice`] or the alias of
394    /// an existing `storage.<type>.<alias>` entry.
395    pub memory: SelectorChoice<MemoryChoice>,
396    /// Channels step. 0..N entries. Each is either a freshly-built
397    /// [`ChannelQuickStart`] or the alias of an existing channel.
398    /// The agent's `channels` field is auto-bound to every entry in
399    /// this vec at apply time.
400    pub channels: Vec<SelectorChoice<ChannelQuickStart>>,
401    /// Peer groups to materialize. Each entry can reference either a
402    /// staged channel from `channels` (above) or an already-configured
403    /// channel ref. Empty list = no peer-group rows written.
404    #[serde(default)]
405    pub peer_groups: Vec<QuickstartPeerGroup>,
406    /// Agent identity (always create-new — there's no reuse path).
407    pub agent: AgentIdentity,
408}
409
410/// Peer-group entry staged in the Quickstart. Maps 1:1 to a
411/// `[peer-groups.<name>]` table written at apply time. The `channel`
412/// field carries a `<type>.<alias>` ref pointing at either a staged
413/// channel from the same submission or a pre-existing one.
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
416pub struct QuickstartPeerGroup {
417    /// Map key written to `peer-groups.<name>`. Synthesized by surfaces
418    /// from the channel ref so no `match` table is involved.
419    pub name: String,
420    /// Channel ref (`<type>.<alias>`) the peer group authorizes.
421    pub channel: String,
422    /// External (non-agent) peer usernames the channel should accept.
423    #[serde(default)]
424    pub external_peers: Vec<String>,
425    /// Per-group blocklist applied to the resolved peer set.
426    #[serde(default)]
427    pub ignore: Vec<String>,
428}
429
430/// Dual-mode selector outcome. Every Quickstart selector lets the
431/// user either pick an existing configured alias or create a fresh
432/// one; this enum carries which path was taken so the runtime apply
433/// path can branch on `Existing` (record an alias ref only, no
434/// writes to that section) vs `Fresh` (write a new entry).
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
437#[serde(rename_all = "snake_case", tag = "mode", content = "value")]
438pub enum SelectorChoice<T> {
439    /// Use an already-configured alias under the corresponding
440    /// section. Carries only the alias key — the runtime resolves
441    /// against the live config at apply time.
442    Existing(String),
443    /// Create a new entry from the carried value.
444    Fresh(T),
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    /// Every preset's `preset_name` must be unique within its table —
452    /// the alias is also the lookup key, so duplicates would shadow
453    /// each other silently.
454    #[test]
455    fn risk_preset_names_are_unique() {
456        let mut seen = std::collections::BTreeSet::new();
457        for p in RISK_PRESETS {
458            assert!(
459                seen.insert(p.preset_name),
460                "duplicate risk preset_name: {}",
461                p.preset_name
462            );
463        }
464    }
465
466    #[test]
467    fn runtime_preset_names_are_unique() {
468        let mut seen = std::collections::BTreeSet::new();
469        for p in RUNTIME_PRESETS {
470            assert!(
471                seen.insert(p.preset_name),
472                "duplicate runtime preset_name: {}",
473                p.preset_name
474            );
475        }
476    }
477
478    /// `risk_preset` / `runtime_preset` lookup round-trip — picking
479    /// by `preset_name` must find the same row that's in the slice.
480    #[test]
481    fn risk_preset_lookup_round_trips() {
482        for p in RISK_PRESETS {
483            let found = risk_preset(p.preset_name).expect("preset present");
484            assert_eq!(found.preset_name, p.preset_name);
485            assert_eq!(found.label, p.label);
486        }
487        assert!(risk_preset("not-a-real-preset").is_none());
488    }
489
490    #[test]
491    fn runtime_preset_lookup_round_trips() {
492        for p in RUNTIME_PRESETS {
493            let found = runtime_preset(p.preset_name).expect("preset present");
494            assert_eq!(found.preset_name, p.preset_name);
495            assert_eq!(found.label, p.label);
496        }
497        assert!(runtime_preset("not-a-real-preset").is_none());
498    }
499
500    /// No preset is allowed to use `default` as its alias.
501    #[test]
502    fn no_preset_uses_default_alias() {
503        for p in RISK_PRESETS {
504            assert_ne!(
505                p.preset_name, "default",
506                "risk preset alias must never be `default`",
507            );
508        }
509        for p in RUNTIME_PRESETS {
510            assert_ne!(
511                p.preset_name, "default",
512                "runtime preset alias must never be `default`",
513            );
514        }
515    }
516
517    #[test]
518    fn preset_names_are_valid_alias_keys() {
519        for p in RISK_PRESETS {
520            crate::helpers::validate_alias_key(p.preset_name).unwrap_or_else(|e| {
521                panic!(
522                    "risk preset_name `{}` is not a valid alias key: {e}",
523                    p.preset_name
524                )
525            });
526        }
527        for p in RUNTIME_PRESETS {
528            crate::helpers::validate_alias_key(p.preset_name).unwrap_or_else(|e| {
529                panic!(
530                    "runtime preset_name `{}` is not a valid alias key: {e}",
531                    p.preset_name
532                )
533            });
534        }
535    }
536
537    /// The `Balanced` preset must equal the schema's `Default::default()` —
538    /// that's the contract that lets us call `RiskProfileConfig::default()`
539    /// / `RuntimeProfileConfig::default()` for the Balanced factory
540    /// instead of duplicating field literals.
541    #[test]
542    fn balanced_risk_matches_schema_default() {
543        let preset = risk_preset("balanced").unwrap();
544        let preset_values = (preset.values)();
545        let schema_default = RiskProfileConfig::default();
546        // Compare via Debug since RiskProfileConfig doesn't derive PartialEq.
547        assert_eq!(format!("{preset_values:?}"), format!("{schema_default:?}"),);
548    }
549
550    #[test]
551    fn balanced_runtime_matches_schema_default() {
552        let preset = runtime_preset("balanced").unwrap();
553        let preset_values = (preset.values)();
554        let schema_default = RuntimeProfileConfig::default();
555        assert_eq!(format!("{preset_values:?}"), format!("{schema_default:?}"),);
556    }
557
558    /// Regression: the `unbounded` preset must NOT zero out the action
559    /// budget. A `max_actions_per_hour` of 0 is a hard zero budget (the
560    /// per-sender tracker treats 0 as always exhausted), so an agent on
561    /// the `unbounded` profile previously had every tool call rejected
562    /// with "max 0 actions per hour". Assert the budget is non-zero and
563    /// that a policy carrying it actually permits an action.
564    #[test]
565    fn unbounded_runtime_does_not_block_all_actions() {
566        let preset = runtime_preset("unbounded").unwrap();
567        let values = (preset.values)();
568        assert_ne!(
569            values.max_actions_per_hour, 0,
570            "unbounded must not use 0 — 0 means a hard zero action budget, not unlimited",
571        );
572        let policy = crate::policy::SecurityPolicy {
573            max_actions_per_hour: values.max_actions_per_hour,
574            ..crate::policy::SecurityPolicy::default()
575        };
576        assert!(
577            policy.record_action(),
578            "an unbounded-profile agent must be allowed to take actions",
579        );
580    }
581
582    /// Regression: the `yolo` risk preset must actually permit shell
583    /// commands. `allowed_commands` is deny-by-default — an empty list
584    /// matches nothing, so a `yolo` agent (whose whole point is "no
585    /// command denylist, full autonomy") previously had every shell
586    /// command rejected with "Command not allowed by security policy".
587    /// The preset must carry the `*` wildcard so unrestricted execution
588    /// is actually granted.
589    #[test]
590    fn yolo_risk_allows_shell_commands() {
591        let preset = risk_preset("yolo").unwrap();
592        let values = (preset.values)();
593        let policy = crate::policy::SecurityPolicy {
594            autonomy: values.level,
595            allowed_commands: values.allowed_commands.clone(),
596            block_high_risk_commands: values.block_high_risk_commands,
597            ..crate::policy::SecurityPolicy::default()
598        };
599        for cmd in ["ls", "pwd", "cat README.md", "rm -rf node_modules"] {
600            assert!(
601                policy.is_command_allowed(cmd),
602                "yolo profile must allow `{cmd}` — it grants full autonomy with no denylist",
603            );
604        }
605    }
606
607    /// `BuilderSubmission` and its dependent types must round-trip
608    /// through serde — both surfaces serialize the same shape, and
609    /// the drift test in commit 4 will rely on this.
610    #[test]
611    fn builder_submission_round_trips_through_json() {
612        let submission = BuilderSubmission {
613            model_provider: SelectorChoice::Fresh(ModelProviderChoice {
614                provider_type: "anthropic".into(),
615                alias: "anthropic".into(),
616                model: "claude-sonnet-4-5".into(),
617                fields: std::collections::HashMap::from([(
618                    "api-key".to_string(),
619                    "sk-test".to_string(),
620                )]),
621            }),
622            risk_profile: SelectorChoice::Fresh("balanced".into()),
623            runtime_profile: SelectorChoice::Fresh("balanced".into()),
624            memory: SelectorChoice::Fresh(MemoryChoice::Sqlite),
625            channels: vec![SelectorChoice::Fresh(ChannelQuickStart {
626                channel_type: "cli".into(),
627                alias: "cli".into(),
628                token: None,
629            })],
630            peer_groups: vec![],
631            agent: AgentIdentity {
632                name: "my-bot".into(),
633                system_prompt: "You are a helpful assistant.".into(),
634                personality_file: None,
635                personality_files: vec![],
636            },
637        };
638        let json = serde_json::to_string(&submission).expect("serialize");
639        let parsed: BuilderSubmission = serde_json::from_str(&json).expect("deserialize");
640        assert_eq!(parsed, submission);
641    }
642}