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}