yolop 0.5.0

Yolop — a terminal coding agent built on everruns-runtime
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
// The `approval` capability — yolop's soft-approval (spoken-consent) layer.
//
// "Soft approval" is prompt-engineering, not a hard permission gate. Rather
// than block each tool call behind an interactive yes/no, this capability
// injects guidance into the system prompt that asks the model to:
//
//   * batch safe / read-only work and run it without interruption;
//   * recognize the small set of critical actions (destructive, irreversible,
//     or outward-facing) and, before those, state a brief justification and
//     ask the user for approval in plain language;
//   * treat an affirmative chat reply ("yes", "approved", "go ahead") as the
//     approval — there is no separate UI to click;
//   * record each granted approval with `record_approval`, which lands a
//     `tool.completed` line in the per-session `events.jsonl` audit log.
//
// The paranoia level is central configuration (`approval_mode` in
// settings.toml, see `crate::settings::ApprovalMode`), surfaced in the status
// bar, switchable with `/setup approval <level>` and — because users address
// yolop in natural language ("yolop, be more careful") — with the
// `set_approval_mode` tool.

use crate::capabilities::narration::stable_labeled;
use crate::config_service::ConfigService;
use crate::settings::{ApprovalMode, SettingsStore};
use async_trait::async_trait;
use everruns_core::capabilities::{Capability, CapabilityStatus, SystemPromptContext};
use everruns_core::tool_narration::{ToolNarrationPhase, arg_str, truncate};
use everruns_core::tool_types::{BuiltinTool, DeferrablePolicy, ToolCall, ToolDefinition};
use everruns_core::tools::{Tool, ToolExecutionResult};
use serde_json::{Value, json};
use std::sync::Arc;

pub(crate) const APPROVAL_CAPABILITY_ID: &str = "yolop_approval";

/// Render the `<soft_approval>` system-prompt block for a given level.
/// Pure so the per-mode branch logic is unit-testable without a
/// `SystemPromptContext`. Returns `None` for [`ApprovalMode::Off`], which
/// contributes nothing to the prompt.
fn render_approval_block(mode: ApprovalMode) -> Option<String> {
    let threshold = match mode {
        ApprovalMode::Off => return None,
        ApprovalMode::Protective => {
            "PROTECTIVE — the bar is low. Ask before ANY action that changes \
             state on the host: writing or deleting files, `git` commits/pushes, \
             installing or removing packages, network calls with side effects, or \
             running a `bash` command that is not plainly read-only."
        }
        ApprovalMode::Normal => {
            "NORMAL — ask only before clearly DANGEROUS actions: destructive or \
             irreversible operations (deleting files, `rm -rf`, dropping data, \
             `git reset --hard`, force-push, history rewrites) and outward-facing \
             ones (pushing, publishing, opening PRs, sending mail, deploying). \
             Ordinary edits and local commits proceed without asking."
        }
    };

    Some(format!(
        "<soft_approval>\n\
Soft-approval is active at level {level}.\n\
\n\
{threshold}\n\
\n\
How to operate:\n\
- Plan first, then BATCH the safe steps and run them without pausing. Do NOT \
ask for approval before every tool call — that defeats the purpose. Read-only \
inspection (reading, listing, grepping, status checks) never needs approval.\n\
- When you reach a critical action, STOP before running it. Briefly justify it \
to the user (what you will do, why, and what is at risk — the \"proof\"), then \
ask for approval in one short question and wait.\n\
- A plain affirmative reply (\"yes\", \"approved\", \"go ahead\", \"do it\") is \
the approval; a negative or hesitant reply is not. There is no separate \
approval UI — consent is spoken in chat.\n\
- Immediately after the user approves, call `record_approval` with a concise \
description of exactly what was approved, then carry it out. This writes the \
approval to the session audit log.\n\
- One approval covers the specific action described, not unrelated later \
actions. If the user pre-authorizes a category (\"you don't need to ask for \
git commits\"), honor it for that category without re-asking.\n\
- If the user asks to change how cautious you are (\"be more careful\", \"stop \
asking\", \"yolo mode\"), call `set_approval_mode` to update the level.\n\
</soft_approval>",
        level = mode.as_str(),
    ))
}

pub(crate) struct ApprovalCapability {
    /// Reads the paranoia level through the shared config service each turn.
    pub(crate) config: Arc<dyn ConfigService>,
    /// Concrete store for the `set_approval_mode` write tool.
    pub(crate) settings: Arc<SettingsStore>,
}

#[async_trait]
impl Capability for ApprovalCapability {
    fn id(&self) -> &str {
        APPROVAL_CAPABILITY_ID
    }
    fn name(&self) -> &str {
        "Soft Approval"
    }
    fn description(&self) -> &str {
        "Spoken-consent approval for critical actions, tuned by a central paranoia level."
    }
    fn status(&self) -> CapabilityStatus {
        CapabilityStatus::Available
    }
    fn category(&self) -> Option<&str> {
        Some("Safety")
    }

    async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
        // Read the level live each turn through the config service so
        // `/setup approval`, `set_approval_mode`, and `set_config approval_mode`
        // all take effect on the very next turn.
        render_approval_block(self.config.approval_mode())
    }

    fn system_prompt_preview(&self) -> Option<String> {
        // Show the normal-level block; `off` would contribute nothing.
        render_approval_block(ApprovalMode::Normal)
    }

    fn tools(&self) -> Vec<Box<dyn Tool>> {
        vec![
            Box::new(RecordApprovalTool),
            Box::new(SetApprovalModeTool {
                settings: self.settings.clone(),
            }),
        ]
    }
}

// ---------- tools ----------

/// Records that the user verbally approved a specific critical action. The
/// tool itself only echoes the record back; the durable audit entry is the
/// `tool.completed` event this call produces in the per-session `events.jsonl`
/// log, which captures the arguments, output, and timestamp.
struct RecordApprovalTool;

const FALLBACK_APPROVAL_ACTION: &str = "the pending critical action approved in the conversation";

fn non_deferrable_builtin(tool: &dyn Tool) -> ToolDefinition {
    ToolDefinition::Builtin(BuiltinTool {
        name: tool.name().to_string(),
        display_name: tool.display_name().map(str::to_string),
        description: tool.description().to_string(),
        parameters: tool.parameters_schema(),
        policy: tool.policy(),
        category: None,
        deferrable: DeferrablePolicy::Never,
        hints: tool.hints(),
        full_parameters: None,
    })
}

fn non_empty_str(value: Option<&Value>) -> Option<&str> {
    value
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
}

fn approval_action(arguments: &Value) -> &str {
    if let Some(action) = arguments.as_str().map(str::trim).filter(|s| !s.is_empty()) {
        return action;
    }

    let Some(object) = arguments.as_object() else {
        return FALLBACK_APPROVAL_ACTION;
    };

    non_empty_str(object.get("action")).unwrap_or(FALLBACK_APPROVAL_ACTION)
}

#[async_trait]
impl Tool for RecordApprovalTool {
    fn narrate(
        &self,
        tool_call: &ToolCall,
        phase: ToolNarrationPhase,
        locale: Option<&str>,
    ) -> Option<String> {
        let _ = locale;
        let action = arg_str(&tool_call.arguments, &["action"]).map(|value| truncate(value, 48));
        Some(stable_labeled("Record approval", action, phase))
    }

    fn name(&self) -> &str {
        "record_approval"
    }
    fn display_name(&self) -> Option<&str> {
        Some("Record approval")
    }
    fn description(&self) -> &str {
        "Record that the user just gave spoken approval for a critical action, for the audit \
         trail. Call this immediately after the user says yes/approved and before carrying the \
         action out. Pass a concise, specific description of exactly what was approved. These \
         arguments are written to the session log, so do NOT include secrets (API keys, tokens, \
         passwords); describe the action and redact any sensitive values."
    }
    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "description": "The specific action the user approved, e.g. \"force-push branch feature/x to origin\". Do not embed secrets."
                },
                "detail": {
                    "type": "string",
                    "description": "Optional extra context: the command, affected paths, or scope of the approval. Redact any secrets (keys/tokens/passwords) before passing them — this is logged."
                }
            },
            "additionalProperties": false
        })
    }

    fn to_definition(&self) -> ToolDefinition {
        non_deferrable_builtin(self)
    }

    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
        let action = approval_action(&arguments);
        let detail = non_empty_str(arguments.get("detail"));
        ToolExecutionResult::success(json!({
            "ok": true,
            "recorded": true,
            "action": action,
            "detail": detail,
            "message": format!("approval recorded: {action}"),
        }))
    }
}

/// Switches the central soft-approval level. Backs natural-language requests
/// ("yolop, be more careful", "stop asking me") so the user can tune yolop's
/// paranoia without remembering the `/setup` form.
struct SetApprovalModeTool {
    settings: Arc<SettingsStore>,
}

#[async_trait]
impl Tool for SetApprovalModeTool {
    fn narrate(
        &self,
        tool_call: &ToolCall,
        phase: ToolNarrationPhase,
        locale: Option<&str>,
    ) -> Option<String> {
        let _ = locale;
        let mode =
            arg_str(&tool_call.arguments, &["mode", "level"]).map(|value| truncate(value, 24));
        Some(stable_labeled("Set approval level", mode, phase))
    }

    fn name(&self) -> &str {
        "set_approval_mode"
    }
    fn display_name(&self) -> Option<&str> {
        Some("Set approval level")
    }
    fn description(&self) -> &str {
        "Set yolop's soft-approval paranoia level. Use when the user asks you to be more or less \
         cautious about confirming actions. `protective` asks before any state change, `normal` \
         asks only before destructive or outward-facing actions, `off` never asks."
    }
    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                // Kept as a free string (not an `enum`) so the lenient
                // `ApprovalMode::parse` aliases — the same ones `/setup
                // approval` and settings.toml accept — are reachable here too;
                // a hard enum would silently shadow them. Unknown values are
                // rejected in `execute`.
                "mode": {
                    "type": "string",
                    "description": "The new approval level: 'protective', 'normal', or 'off' (common synonyms like 'paranoid' or 'yolo' are also accepted)."
                }
            },
            "required": ["mode"],
            "additionalProperties": false
        })
    }

    fn to_definition(&self) -> ToolDefinition {
        non_deferrable_builtin(self)
    }

    async fn execute(&self, arguments: Value) -> ToolExecutionResult {
        let raw = match arguments.get("mode").and_then(Value::as_str) {
            Some(m) => m,
            None => return ToolExecutionResult::tool_error("'mode' is required"),
        };
        let mode = match ApprovalMode::parse(raw) {
            Some(mode) => mode,
            None => {
                return ToolExecutionResult::tool_error(format!(
                    "unknown approval level '{raw}'; expected protective, normal, or off"
                ));
            }
        };
        match self.settings.set_approval_mode(mode) {
            Ok(()) => ToolExecutionResult::success(json!({
                "ok": true,
                "mode": mode.as_str(),
                "message": format!("approval level set to {mode}"),
            })),
            Err(e) => {
                ToolExecutionResult::tool_error(format!("could not save approval level: {e}"))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn store_in_tmp() -> (tempfile::TempDir, Arc<SettingsStore>) {
        let tmp = tempfile::tempdir().expect("tmp");
        let store = Arc::new(SettingsStore::open(tmp.path().join("settings.toml")));
        (tmp, store)
    }

    #[test]
    fn off_contributes_no_prompt() {
        assert!(render_approval_block(ApprovalMode::Off).is_none());
    }

    #[test]
    fn normal_and_protective_render_expected_guidance() {
        let normal = render_approval_block(ApprovalMode::Normal).expect("normal block");
        assert!(normal.starts_with("<soft_approval>"));
        assert!(normal.contains("level normal"));
        assert!(normal.contains("NORMAL"));
        // The core behaviors must be spelled out.
        assert!(normal.contains("BATCH"));
        assert!(normal.contains("record_approval"));
        assert!(normal.ends_with("</soft_approval>"));

        let protective = render_approval_block(ApprovalMode::Protective).expect("protective block");
        assert!(protective.contains("level protective"));
        assert!(protective.contains("PROTECTIVE"));
    }

    #[test]
    fn capability_exposes_both_tools() {
        let (_tmp, settings) = store_in_tmp();
        let cap = ApprovalCapability {
            config: settings.clone(),
            settings,
        };
        let tools = cap.tools();
        let names: Vec<String> = tools.iter().map(|t| t.name().to_string()).collect();
        assert_eq!(names, vec!["record_approval", "set_approval_mode"]);
        assert!(
            tools.iter().all(|tool| {
                matches!(tool.to_definition().deferrable(), DeferrablePolicy::Never)
            })
        );
        assert!(cap.commands().is_empty());
    }

    #[tokio::test]
    async fn contribution_follows_settings() {
        let (_tmp, settings) = store_in_tmp();
        let cap = ApprovalCapability {
            config: settings.clone(),
            settings: settings.clone(),
        };
        let ctx =
            SystemPromptContext::without_file_store(everruns_core::typed_id::SessionId::new());

        // Default (normal) contributes a block.
        assert!(cap.system_prompt_contribution(&ctx).await.is_some());

        // Off suppresses it.
        settings
            .set_approval_mode(ApprovalMode::Off)
            .expect("set off");
        assert!(cap.system_prompt_contribution(&ctx).await.is_none());
    }

    #[tokio::test]
    async fn record_approval_echoes_action() {
        let tool = RecordApprovalTool;

        let res = tool
            .execute(json!({ "action": "force-push feature/x", "detail": "git push -f" }))
            .await;
        let ToolExecutionResult::Success(value) = res else {
            panic!("expected success");
        };
        assert_eq!(value["action"], "force-push feature/x");
        assert_eq!(value["detail"], "git push -f");
    }

    #[tokio::test]
    async fn record_approval_accepts_empty_arguments_after_spoken_consent() {
        let tool = RecordApprovalTool;
        let res = tool.execute(json!({})).await;
        let ToolExecutionResult::Success(value) = res else {
            panic!("expected success");
        };
        assert_eq!(value["action"], FALLBACK_APPROVAL_ACTION);
        assert!(value["detail"].is_null());
    }

    #[tokio::test]
    async fn set_approval_mode_updates_settings_and_rejects_garbage() {
        let (_tmp, settings) = store_in_tmp();
        let tool = SetApprovalModeTool {
            settings: settings.clone(),
        };

        let res = tool.execute(json!({ "mode": "off" })).await;
        assert!(res.is_success());
        assert_eq!(settings.snapshot().approval_mode(), ApprovalMode::Off);

        // Aliases accepted by `ApprovalMode::parse` reach the tool too, since
        // the schema is a lenient string rather than a canonical-only enum.
        let res = tool.execute(json!({ "mode": "paranoid" })).await;
        assert!(res.is_success());
        assert_eq!(
            settings.snapshot().approval_mode(),
            ApprovalMode::Protective
        );

        assert!(tool.execute(json!({ "mode": "whenever" })).await.is_error());
        // Unchanged after a rejected value.
        assert_eq!(
            settings.snapshot().approval_mode(),
            ApprovalMode::Protective
        );
    }
}