roboticus-cli 0.11.3

CLI commands and migration engine for the Roboticus agent runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
fn collect_mechanic_json_security_and_plugin_findings(
    roboticus_dir: &Path,
    repair: bool,
    findings: &mut Vec<MechanicFinding>,
    actions: &mut RepairActionSummary,
) -> Result<(), Box<dyn std::error::Error>> {
    let config_path = std::path::Path::new("roboticus.toml");
    let alt_config = roboticus_dir.join("roboticus.toml");
    let log_snapshot = recent_log_snapshot(&roboticus_dir.join("logs"), 350_000);
    if let Some(snapshot) = log_snapshot.as_deref() {
        let tg_404_count =
            count_occurrences(snapshot, "Telegram API error\",\"status\":\"404 Not Found");
        let tg_poll_err_count = count_occurrences(snapshot, "Telegram poll error, backing off 5s");
        let tg_401_count =
            count_occurrences(snapshot, "Telegram API error\",\"status\":\"401");
        if tg_401_count >= 3 {
            // 401 Unauthorized is a clear token/auth issue — keystore is the right fix
            findings.push(finding(
                "telegram-invalid-token-likely",
                "high",
                0.96,
                "Repeated Telegram 401 Unauthorized errors",
                "Log signatures strongly suggest an invalid or revoked Telegram bot token.",
                "Set a valid token and restart daemon.",
                vec![
                    "roboticus keystore set telegram_bot_token \"<TOKEN>\"".to_string(),
                    "roboticus daemon restart".to_string(),
                ],
                false,
                true,
            ));
        } else if tg_404_count >= 3 || tg_poll_err_count >= 3 {
            // 404 / poll backoff — transport-level issue, not a keystore problem
            findings.push(finding(
                "telegram-transport-degraded",
                "medium",
                0.80,
                format!(
                    "Telegram transport degraded ({tg_404_count} 404 errors, {tg_poll_err_count} poll backoffs)"
                ),
                "Repeated Telegram API 404 errors or poll backoff loops indicate transport-level \
                 failures. This is typically caused by network issues, Telegram API changes, or \
                 bot configuration problems — not an invalid keystore token.",
                "Check Telegram bot configuration and API connectivity with `roboticus channels status`.",
                vec![
                    "roboticus channels status".to_string(),
                    "roboticus daemon restart".to_string(),
                ],
                false,
                false,
            ));
        }
        let unknown_action_count = count_occurrences(snapshot, "unknown action: unknown");
        if unknown_action_count >= 3 {
            findings.push(finding(
                "cron-unknown-action-storm",
                "high",
                0.92,
                "Recurring cron unknown-action failures",
                "Scheduler repeatedly hit legacy/invalid cron action payloads.",
                "Recover paused jobs selectively after validation.",
                vec!["roboticus schedule recover --all --dry-run".to_string()],
                true,
                false,
            ));
        }
    }

    // ── Security configuration findings ─────────────────────────────
    // Try to load the config to analyze security posture.
    let security_config_path = if config_path.exists() {
        Some(config_path.to_path_buf())
    } else if alt_config.exists() {
        Some(alt_config.clone())
    } else {
        None
    };
    if let Some(ref cfg_path) = security_config_path
        && let Ok(raw) = std::fs::read_to_string(cfg_path)
        && let Ok(cfg) = toml::from_str::<roboticus_core::RoboticusConfig>(&raw)
    {
        // Check 1: Missing [security] section (running on defaults)
        if !has_toml_section(&raw, "[security]") {
            findings.push(finding(
                        "security-missing-section",
                        "medium",
                        0.95,
                        "No [security] section in config",
                        "Running on default security settings. Run `roboticus mechanic --repair` for guided setup.",
                        "Add explicit [security] section with deny_on_empty_allowlist, allowlist_authority, etc.",
                        vec!["roboticus mechanic --repair".to_string()],
                        false,
                        true,
                    ));
        }

        // Check 2: No trusted senders + channels enabled
        let has_channels = cfg.channels.telegram.as_ref().is_some_and(|t| t.enabled)
            || cfg.channels.whatsapp.as_ref().is_some_and(|w| w.enabled)
            || cfg.channels.discord.as_ref().is_some_and(|d| d.enabled)
            || cfg.channels.signal.as_ref().is_some_and(|s| s.enabled)
            || cfg.channels.email.enabled;
        if cfg.channels.trusted_sender_ids.is_empty() && has_channels {
            findings.push(finding(
                "security-no-trusted-senders",
                "high",
                0.97,
                "No trusted senders configured",
                "trusted_sender_ids is empty — no user can reach Creator authority. \
                         Caution+ tools (filesystem, scripts, delegation) are inaccessible.",
                "Add sender IDs to trusted_sender_ids in [channels].",
                vec!["roboticus mechanic --repair".to_string()],
                false,
                true,
            ));
        }

        // Per-channel allow-list checks
        let channel_checks: Vec<(&str, bool, bool)> = vec![
            (
                "Telegram",
                cfg.channels.telegram.as_ref().is_some_and(|t| t.enabled),
                cfg.channels
                    .telegram
                    .as_ref()
                    .map(|t| t.allowed_chat_ids.is_empty())
                    .unwrap_or(true),
            ),
            (
                "Discord",
                cfg.channels.discord.as_ref().is_some_and(|d| d.enabled),
                cfg.channels
                    .discord
                    .as_ref()
                    .map(|d| d.allowed_guild_ids.is_empty())
                    .unwrap_or(true),
            ),
            (
                "WhatsApp",
                cfg.channels.whatsapp.as_ref().is_some_and(|w| w.enabled),
                cfg.channels
                    .whatsapp
                    .as_ref()
                    .map(|w| w.allowed_numbers.is_empty())
                    .unwrap_or(true),
            ),
            (
                "Signal",
                cfg.channels.signal.as_ref().is_some_and(|s| s.enabled),
                cfg.channels
                    .signal
                    .as_ref()
                    .map(|s| s.allowed_numbers.is_empty())
                    .unwrap_or(true),
            ),
            (
                "Email",
                cfg.channels.email.enabled,
                cfg.channels.email.allowed_senders.is_empty(),
            ),
        ];

        for (name, enabled, empty_list) in &channel_checks {
            if *enabled && *empty_list {
                if cfg.security.deny_on_empty_allowlist {
                    // Check 3: deny_on_empty=true + empty list → all messages rejected
                    findings.push(finding(
                                "security-no-allowlist",
                                "high",
                                0.98,
                                format!("{name} has no allow-list — all messages will be rejected"),
                                format!(
                                    "{name} is enabled but has no allowed IDs and deny_on_empty_allowlist = true. \
                                     No one can send messages via this channel."
                                ),
                                format!("Add allowed IDs for {name} or disable the channel until an allow-list is configured."),
                                vec!["roboticus mechanic --repair".to_string()],
                                false,
                                true,
                            ));
                } else {
                    // Check 4: deny_on_empty=false + empty list → open to the world
                    findings.push(finding(
                                "security-open-to-world",
                                "critical",
                                0.99,
                                format!("{name} is open to the entire internet"),
                                format!(
                                    "{name} is enabled with an empty allow-list under a deprecated insecure configuration. \
                                     Runtime startup now rejects this state; migrate to an explicit allow-list or disable the channel."
                                ),
                                format!("Add allowed IDs for {name}, then rerun mechanic repair, or disable the channel."),
                                vec!["roboticus mechanic --repair".to_string()],
                                false,
                                true,
                            ));
                }
            }
        }
    }

    // ── Sandbox configuration findings ───────────────────────────────
    if let Some(ref cfg_path) = security_config_path
        && let Ok(raw) = std::fs::read_to_string(cfg_path)
        && let Ok(cfg) = toml::from_str::<roboticus_core::RoboticusConfig>(&raw)
    {
        let sk = &cfg.skills;

        // Sandbox disabled entirely
        if !sk.sandbox_env {
            findings.push(finding(
                "sandbox-disabled",
                "high",
                0.99,
                "Sandbox disabled — skill scripts run with full environment access",
                "sandbox_env = false in [skills]. Scripts inherit the agent's full \
                 environment and filesystem access. This negates all sandbox protections.",
                "Set sandbox_env = true in [skills].",
                vec!["roboticus mechanic --repair".to_string()],
                false,
                true,
            ));
        }

        // Bare interpreter names (PATH hijacking risk)
        let bare_interpreters: Vec<&str> = sk
            .allowed_interpreters
            .iter()
            .filter(|i| !std::path::Path::new(i.as_str()).is_absolute())
            .map(|s| s.as_str())
            .collect();
        if !bare_interpreters.is_empty() {
            findings.push(finding(
                "sandbox-bare-interpreters",
                "medium",
                0.90,
                format!(
                    "{} interpreter(s) use bare names (PATH hijacking risk)",
                    bare_interpreters.len()
                ),
                format!(
                    "allowed_interpreters contains bare names: [{}]. A malicious PATH entry \
                     could shadow a legitimate interpreter. The script runner resolves to \
                     absolute paths at runtime, but config-level absolute paths provide \
                     defense-in-depth.",
                    bare_interpreters.join(", ")
                ),
                "Set absolute paths for allowed_interpreters in [skills] config.",
                vec![],
                false,
                false,
            ));
        }

        // No memory limit
        if sk.script_max_memory_bytes.is_none() {
            findings.push(finding(
                "sandbox-no-memory-limit",
                "medium",
                0.85,
                "No memory ceiling for skill scripts",
                "script_max_memory_bytes is not set — a runaway script could exhaust \
                 system memory. Default is 256 MiB on Linux (RLIMIT_AS).",
                "Set script_max_memory_bytes in [skills] config.",
                vec![],
                false,
                false,
            ));
        }
    }

    // ── Filesystem security policy findings ───────────────────────
    if let Some(ref cfg_path) = security_config_path
        && let Ok(raw) = std::fs::read_to_string(cfg_path)
        && let Ok(cfg) = toml::from_str::<roboticus_core::RoboticusConfig>(&raw)
    {
        let fs = &cfg.security.filesystem;

        // Workspace-only mode disabled
        if !fs.workspace_only {
            findings.push(finding(
                "filesystem-workspace-unrestricted",
                "high",
                0.95,
                "Workspace-only mode disabled — agent tools can access entire filesystem",
                "security.filesystem.workspace_only = false. Agent file tools can read/write \
                 any path the process user has access to, not just the workspace directory.",
                "Set security.filesystem.workspace_only = true in config.",
                vec!["roboticus config set security.filesystem.workspace_only true".to_string()],
                false,
                false,
            ));
        }

        // Script filesystem confinement disabled
        if !fs.script_fs_confinement {
            findings.push(finding(
                "filesystem-script-unconfined",
                "medium",
                0.90,
                "Script filesystem confinement disabled",
                "security.filesystem.script_fs_confinement = false. Sandboxed skill scripts \
                 run without OS-level write isolation (macOS sandbox-exec).",
                "Set security.filesystem.script_fs_confinement = true in config.",
                vec!["roboticus config set security.filesystem.script_fs_confinement true".to_string()],
                false,
                false,
            ));
        }

        // Protected paths list too small
        if fs.protected_paths.len() < 10 {
            findings.push(finding(
                "filesystem-blacklist-minimal",
                "medium",
                0.85,
                format!(
                    "Protected paths list has only {} entries (default is ~25)",
                    fs.protected_paths.len()
                ),
                "The filesystem blacklist may have been trimmed too aggressively, leaving \
                 sensitive paths unprotected.",
                "Review security.filesystem.protected_paths or reset to defaults.",
                vec![],
                false,
                false,
            ));
        }
    }

    // Plugin health checks
    {
        use roboticus_plugin_sdk::manifest::PluginManifest;

        let plugins_dir = roboticus_dir.join("plugins");
        if plugins_dir.exists()
            && let Ok(entries) = std::fs::read_dir(&plugins_dir)
        {
            for entry in entries.flatten() {
                let path = entry.path();
                if !path.is_dir() {
                    continue;
                }
                let dir_name = path
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .to_string();
                let manifest_path = path.join("plugin.toml");

                if !manifest_path.exists() {
                    let mut f = finding(
                        "plugin-orphan-directory",
                        "medium",
                        0.95,
                        format!("Orphan plugin directory: {dir_name}"),
                        "Plugin directory exists but contains no valid plugin.toml. Likely an aborted install.",
                        "Remove orphan plugin directory.",
                        vec![format!("rm -rf \"{}\"", path.display())],
                        true,
                        false,
                    );
                    if repair && let Ok(()) = std::fs::remove_dir_all(&path) {
                        f.auto_repaired = true;
                    }
                    findings.push(f);
                    continue;
                }

                match PluginManifest::from_file(&manifest_path) {
                    Ok(manifest) => {
                        let report = manifest.vet(&path);
                        for e in &report.errors {
                            findings.push(finding(
                                    "plugin-vet-error",
                                    "high",
                                    0.95,
                                    format!("Plugin '{}': {e}", manifest.name),
                                    format!(
                                        "Plugin '{}' v{} has a blocking integrity error.",
                                        manifest.name, manifest.version
                                    ),
                                    "Reinstall the plugin or resolve the missing dependency.",
                                    vec![format!(
                                        "roboticus plugins uninstall {} && roboticus plugins install <source>",
                                        manifest.name
                                    )],
                                    false,
                                    true,
                                ));
                        }
                        for w in &report.warnings {
                            findings.push(finding(
                                "plugin-vet-warning",
                                "low",
                                0.90,
                                format!("Plugin '{}': {w}", manifest.name),
                                format!(
                                    "Plugin '{}' v{} has a non-blocking issue.",
                                    manifest.name, manifest.version
                                ),
                                "Review plugin configuration.",
                                vec![format!("roboticus plugins info {}", manifest.name)],
                                false,
                                false,
                            ));
                        }

                        // Repair: re-deploy missing companion skills
                        if repair {
                            let skills_dir = roboticus_dir.join("skills");
                            for skill_rel in &manifest.companion_skills {
                                let src = path.join(skill_rel);
                                let installed_name = super::plugins::companion_skill_install_name(
                                    &manifest.name,
                                    skill_rel,
                                );
                                let dest = skills_dir.join(&installed_name);
                                if src.exists() && !dest.exists() {
                                    // best-effort: dir creation failure caught by subsequent copy
                                    std::fs::create_dir_all(&skills_dir).ok();
                                    if std::fs::copy(&src, &dest).is_ok() {
                                        findings.push(finding(
                                                "plugin-companion-skill-redeployed",
                                                "info",
                                                1.0,
                                                format!(
                                                    "Re-deployed companion skill: {installed_name}",
                                                ),
                                                format!(
                                                    "Plugin '{}' companion skill was missing from skills directory.",
                                                    manifest.name
                                                ),
                                                "Companion skill re-deployed from plugin bundle.",
                                                vec![],
                                                true,
                                                false,
                                            ));
                                    }
                                }
                            }
                        }
                    }
                    Err(_) => {
                        let mut f = finding(
                            "plugin-corrupt-manifest",
                            "medium",
                            0.95,
                            format!("Corrupt plugin manifest: {dir_name}"),
                            "Plugin directory has a plugin.toml that cannot be parsed.",
                            "Remove corrupt plugin directory.",
                            vec![format!("rm -rf \"{}\"", path.display())],
                            true,
                            false,
                        );
                        if repair && let Ok(()) = std::fs::remove_dir_all(&path) {
                            f.auto_repaired = true;
                        }
                        findings.push(f);
                    }
                }
            }
        }
    }

    // Mark security_configured if no security findings were emitted
    let has_security_findings = findings.iter().any(|f| f.id.starts_with("security-"));
    if !has_security_findings {
        actions.security_configured = true;
    }

    if repair {
        let state_db = roboticus_dir.join("state.db");
        if normalize_schema_safe(&state_db)? {
            actions.schema_normalized = true;
        }
    }
    Ok(())
}