rho-coding-agent 1.25.1

A lightweight agent harness inspired by Pi
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
//! `hooks.toml` parsing and validation.
//!
//! Executable policy is deliberately kept out of `config.toml`: preferences and
//! "programs Rho will run" are different trust questions and belong in different
//! files. Unknown keys are rejected so a typo cannot silently disable a hook.

use std::{
    path::{Path, PathBuf},
    time::Duration,
};

use serde::Deserialize;

use rho_sdk::hooks::HookEventKind;

use super::{
    matcher::{ToolMatcher, ToolMatcherError},
    HookSource,
};

/// The only schema version this build understands.
pub(crate) const HOOKS_SCHEMA_VERSION: u32 = 1;

/// Longest a single hook program may run before it is killed.
pub(crate) const MAX_HOOK_TIMEOUT: Duration = Duration::from_secs(600);

/// Raw file shape. Serde rejects unknown fields so a misspelled key fails loudly.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHooksFile {
    version: u32,
    #[serde(default)]
    hook: Vec<RawHook>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHook {
    id: String,
    on: String,
    #[serde(default)]
    tools: Option<Vec<String>>,
    command: Vec<String>,
    timeout: String,
    #[serde(default)]
    env: Vec<String>,
}

/// One validated hook, resolved against the file that declared it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HookDefinition {
    pub(crate) source: HookSource,
    pub(crate) id: String,
    pub(crate) event: HookEventKind,
    pub(crate) tools: ToolMatcher,
    /// Argv with the program resolved to an absolute path where determinable.
    pub(crate) command: Vec<String>,
    /// Native executable path retained separately from its diagnostic rendering.
    pub(crate) executable: PathBuf,
    pub(crate) timeout: Duration,
    pub(crate) env: Vec<String>,
    pub(crate) working_directory: PathBuf,
}

impl HookDefinition {
    /// Namespaced identity used in diagnostics, denial reasons, and envelopes.
    ///
    /// Namespacing by source is what lets a project ship a hook whose id already
    /// exists in the user's file without either one breaking.
    pub fn qualified_id(&self) -> String {
        format!("{}:{}", self.source.label(), self.id)
    }

    pub fn event(&self) -> HookEventKind {
        self.event
    }

    pub fn command(&self) -> &[String] {
        &self.command
    }

    pub(crate) fn executable(&self) -> &Path {
        &self.executable
    }

    pub fn timeout(&self) -> Duration {
        self.timeout
    }

    pub fn env(&self) -> &[String] {
        &self.env
    }

    pub fn working_directory(&self) -> &Path {
        &self.working_directory
    }

    pub fn tools(&self) -> &ToolMatcher {
        &self.tools
    }
}

/// A `hooks.toml` that could not be used.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("{path}: {location}{message}")]
pub struct HookConfigError {
    pub path: PathBuf,
    pub hook_id: Option<String>,
    pub field: Option<String>,
    pub message: String,
    location: String,
}

impl HookConfigError {
    fn new(
        path: &Path,
        hook_id: Option<&str>,
        field: Option<&str>,
        message: impl Into<String>,
    ) -> Self {
        let location = match (hook_id, field) {
            (Some(id), Some(field)) => format!("hook '{id}': field '{field}': "),
            (Some(id), None) => format!("hook '{id}': "),
            (None, Some(field)) => format!("field '{field}': "),
            (None, None) => String::new(),
        };
        Self {
            path: path.to_path_buf(),
            hook_id: hook_id.map(str::to_owned),
            field: field.map(str::to_owned),
            message: message.into(),
            location,
        }
    }

    pub(crate) fn at_file(path: &Path, message: impl Into<String>) -> Self {
        Self::new(path, None, None, message)
    }

    fn at_field(path: &Path, field: &str, message: impl Into<String>) -> Self {
        Self::new(path, None, Some(field), message)
    }

    fn at_hook(path: &Path, id: &str, field: &str, message: impl Into<String>) -> Self {
        Self::new(path, Some(id), Some(field), message)
    }
}

/// Parses and validates one hooks file.
///
/// `project_root` is `Some` for a project file. Project commands must be given
/// in path form so granting trust to a workspace cannot silently bind whatever
/// `PATH` happens to resolve today.
pub fn parse_hooks_file(
    path: &Path,
    contents: &str,
    source: HookSource,
    project_root: Option<&Path>,
    canonical_tools: &[&str],
) -> Result<Vec<HookDefinition>, HookConfigError> {
    let raw: RawHooksFile = toml::from_str(contents)
        .map_err(|error| HookConfigError::at_file(path, error.message().to_owned()))?;
    if raw.version != HOOKS_SCHEMA_VERSION {
        return Err(HookConfigError::at_field(
            path,
            "version",
            format!(
                "unsupported hooks schema version {}; this build understands {HOOKS_SCHEMA_VERSION}",
                raw.version
            ),
        ));
    }

    let mut definitions: Vec<HookDefinition> = Vec::with_capacity(raw.hook.len());
    for hook in raw.hook {
        if definitions
            .iter()
            .any(|existing| existing.id == hook.id.trim())
        {
            return Err(HookConfigError::at_hook(
                path,
                hook.id.trim(),
                "id",
                "duplicate hook ID in this file",
            ));
        }
        definitions.push(validate(path, hook, source, project_root, canonical_tools)?);
    }
    Ok(definitions)
}

fn validate(
    path: &Path,
    raw: RawHook,
    source: HookSource,
    project_root: Option<&Path>,
    canonical_tools: &[&str],
) -> Result<HookDefinition, HookConfigError> {
    let id = raw.id.trim().to_owned();
    if id.is_empty() {
        return Err(HookConfigError::at_field(path, "id", "hook ID is empty"));
    }
    if !id
        .chars()
        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
    {
        return Err(HookConfigError::at_hook(
            path,
            &id,
            "id",
            "hook ID may contain only ASCII letters, digits, '-', '_', and '.'",
        ));
    }

    let event = HookEventKind::from_wire_name(&raw.on).ok_or_else(|| {
        HookConfigError::at_hook(
            path,
            &id,
            "on",
            format!(
                "unknown event '{}'; known events are {}",
                raw.on,
                known_events()
            ),
        )
    })?;

    let tools = build_matcher(path, &id, event, raw.tools, canonical_tools)?;
    let (executable, command) = resolve_command(path, &id, raw.command, source, project_root)?;
    let timeout = parse_timeout(path, &id, &raw.timeout)?;
    let env = validate_env(path, &id, raw.env)?;

    Ok(HookDefinition {
        source,
        id,
        event,
        tools,
        command,
        executable,
        timeout,
        env,
        working_directory: project_root
            .map(Path::to_path_buf)
            .unwrap_or_else(|| path.parent().unwrap_or(Path::new(".")).to_path_buf()),
    })
}

fn build_matcher(
    path: &Path,
    id: &str,
    event: HookEventKind,
    tools: Option<Vec<String>>,
    canonical_tools: &[&str],
) -> Result<ToolMatcher, HookConfigError> {
    let Some(patterns) = tools else {
        return Ok(ToolMatcher::any());
    };
    if !matches!(
        event,
        HookEventKind::BeforeToolUse | HookEventKind::AfterToolUse
    ) {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "tools",
            format!("event '{event}' has no tool to match"),
        ));
    }
    ToolMatcher::new(patterns, canonical_tools).map_err(|error| match error {
        ToolMatcherError::Empty => {
            HookConfigError::at_hook(path, id, "tools", "tool list is empty")
        }
        ToolMatcherError::BlankPattern => {
            HookConfigError::at_hook(path, id, "tools", "tool pattern is blank")
        }
        ToolMatcherError::UnsupportedGlob { pattern } => HookConfigError::at_hook(
            path,
            id,
            "tools",
            format!("tool pattern '{pattern}' is unsupported; use a name or a single trailing '*'"),
        ),
        ToolMatcherError::UnknownTool { pattern } => HookConfigError::at_hook(
            path,
            id,
            "tools",
            format!(
                "tool '{pattern}' is not in the canonical tool list: {}",
                canonical_tools.join(", ")
            ),
        ),
    })
}

fn resolve_command(
    path: &Path,
    id: &str,
    command: Vec<String>,
    source: HookSource,
    project_root: Option<&Path>,
) -> Result<(PathBuf, Vec<String>), HookConfigError> {
    let Some(program) = command.first() else {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "command",
            "command argv is empty",
        ));
    };
    if program.trim().is_empty() {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "command",
            "command program is blank",
        ));
    }

    let program_path = PathBuf::from(program);
    let is_path_form =
        program_path.is_absolute() || program.contains('/') || program.contains('\\');
    if source == HookSource::Project && !is_path_form {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "command",
            format!(
                "project hooks must name a program by path, not a bare PATH name like '{program}'; \
                 use './relative/path' or an absolute path"
            ),
        ));
    }

    let executable =
        if let Some(root) = project_root.filter(|_| !program_path.is_absolute() && is_path_form) {
            // Resolve once at load so diagnostics and the trust prompt show the exact
            // file that will be executed, not a path relative to whatever cwd applies.
            join_relative(root, &program_path)
        } else {
            program_path
        };

    if executable.is_absolute() && !executable.exists() {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "command",
            format!(
                "hook program '{}' does not exist",
                crate::paths::display(&executable)
            ),
        ));
    }
    let mut display_command = command;
    display_command[0] = crate::paths::display(&executable);
    Ok((executable, display_command))
}

/// Joins `relative` onto `root`, dropping `./` segments so the rendered path a
/// user is asked to trust reads like the file it points at.
fn join_relative(root: &Path, relative: &Path) -> PathBuf {
    let mut joined = root.to_path_buf();
    for component in relative.components() {
        match component {
            std::path::Component::CurDir => {}
            other => joined.push(other),
        }
    }
    joined
}

fn parse_timeout(path: &Path, id: &str, raw: &str) -> Result<Duration, HookConfigError> {
    let timeout = humantime::parse_duration(raw.trim()).map_err(|error| {
        HookConfigError::at_hook(path, id, "timeout", format!("invalid duration: {error}"))
    })?;
    if timeout.is_zero() {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "timeout",
            "timeout must be greater than zero",
        ));
    }
    if timeout > MAX_HOOK_TIMEOUT {
        return Err(HookConfigError::at_hook(
            path,
            id,
            "timeout",
            format!(
                "timeout {raw} exceeds the {} second maximum",
                MAX_HOOK_TIMEOUT.as_secs()
            ),
        ));
    }
    Ok(timeout)
}

fn validate_env(
    path: &Path,
    id: &str,
    mut names: Vec<String>,
) -> Result<Vec<String>, HookConfigError> {
    for name in &names {
        if name.trim().is_empty() {
            return Err(HookConfigError::at_hook(
                path,
                id,
                "env",
                "environment variable name is blank",
            ));
        }
        if name.contains('=') {
            return Err(HookConfigError::at_hook(
                path,
                id,
                "env",
                format!("'{name}' is a name/value pair; list only variable names"),
            ));
        }
    }
    names.sort_unstable();
    names.dedup();
    Ok(names)
}

fn known_events() -> String {
    HookEventKind::ALL
        .iter()
        .map(|event| event.wire_name())
        .collect::<Vec<_>>()
        .join(", ")
}

#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;