cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
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
//! Cordance project-level configuration (`cordance.toml`).
//!
//! All fields have sane defaults so an absent file is valid.

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub doctrine: DoctrineConfig,
    #[serde(default)]
    pub axiom: AxiomConfig,
    #[serde(default)]
    pub llm: LlmConfig,
    /// MCP server policy (`cordance serve`). Optional; defaults restrict
    /// target validation to the directory `cordance serve` was launched from.
    #[serde(default)]
    pub mcp: McpConfig,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DoctrineConfig {
    /// Path to engineering-doctrine repo. Relative to the target dir or absolute.
    #[serde(default = "default_doctrine_source")]
    pub source: String,
    #[serde(default = "default_doctrine_fallback")]
    pub fallback_repo: String,
    /// `"auto"` = use HEAD commit; any other value is used verbatim.
    #[serde(default = "default_pin_commit")]
    pub pin_commit: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AxiomConfig {
    /// Path to the pai-axiom repo. Relative to the target dir or absolute.
    #[serde(default = "default_axiom_source")]
    pub source: String,
    /// `"auto"` = read `PAI/Algorithm/LATEST` from `source`; any other value
    /// is used verbatim (e.g. `"v3.1.1-axiom"`).
    #[serde(default = "default_algorithm_latest")]
    pub algorithm_latest: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LlmConfig {
    /// `"none"` | `"ollama"` | `"lm-studio"`
    #[serde(default = "default_llm_provider")]
    pub provider: String,
    /// Ollama adapter configuration (used when `provider == "ollama"`).
    #[serde(default)]
    pub ollama: OllamaConfig,
}

/// `[llm.ollama]` section of `cordance.toml`. See ADR 0015.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OllamaConfig {
    #[serde(default = "default_ollama_base_url")]
    pub base_url: String,
    #[serde(default = "default_ollama_model")]
    pub model: String,
    /// Sampling temperature. 0.0–2.0. Low values are more deterministic.
    #[serde(default = "default_ollama_temperature")]
    pub temperature: f32,
    /// Context window in tokens.
    #[serde(default = "default_ollama_num_ctx")]
    pub num_ctx: u32,
}

fn default_ollama_base_url() -> String {
    "http://localhost:11434".into()
}
fn default_ollama_model() -> String {
    "qwen2.5-coder:14b".into()
}
const fn default_ollama_temperature() -> f32 {
    0.1
}
const fn default_ollama_num_ctx() -> u32 {
    8192
}

impl Default for OllamaConfig {
    fn default() -> Self {
        Self {
            base_url: default_ollama_base_url(),
            model: default_ollama_model(),
            temperature: default_ollama_temperature(),
            num_ctx: default_ollama_num_ctx(),
        }
    }
}

impl cordance_llm::OllamaSettings for OllamaConfig {
    fn base_url(&self) -> &str {
        &self.base_url
    }
    fn model(&self) -> &str {
        &self.model
    }
    fn temperature(&self) -> f32 {
        self.temperature
    }
    fn num_ctx(&self) -> u32 {
        self.num_ctx
    }
}

/// `[mcp]` section of `cordance.toml`.
///
/// Governs the path-canonicalisation guard applied to every tool that takes a
/// `target` parameter (see `mcp::validation::validate_target`). When this
/// section is absent the only acceptable target is the directory `cordance
/// serve` was launched from.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct McpConfig {
    /// Additional roots a `target` argument is permitted to canonicalise into.
    /// Each path is resolved against the server's working directory and is
    /// canonicalised on first use. Entries that fail to canonicalise are
    /// dropped silently (treated as not allowed).
    #[serde(default)]
    pub allowed_roots: Vec<String>,
}

fn default_doctrine_source() -> String {
    "../engineering-doctrine".into()
}
fn default_doctrine_fallback() -> String {
    "https://github.com/0ryant/engineering-doctrine".into()
}
fn default_pin_commit() -> String {
    "auto".into()
}
fn default_axiom_source() -> String {
    "../pai-axiom".into()
}
fn default_algorithm_latest() -> String {
    "auto".into()
}
fn default_llm_provider() -> String {
    "none".into()
}

impl Default for DoctrineConfig {
    fn default() -> Self {
        Self {
            source: default_doctrine_source(),
            fallback_repo: default_doctrine_fallback(),
            pin_commit: default_pin_commit(),
        }
    }
}

impl Default for AxiomConfig {
    fn default() -> Self {
        Self {
            source: default_axiom_source(),
            algorithm_latest: default_algorithm_latest(),
        }
    }
}

impl Default for LlmConfig {
    fn default() -> Self {
        Self {
            provider: default_llm_provider(),
            ollama: OllamaConfig::default(),
        }
    }
}

/// Errors returned by [`Config::load_strict`].
///
/// Every call site is strict: a malformed `cordance.toml` aborts the run with
/// the file path attached. Callers that want graceful degradation should
/// `.unwrap_or_default()` at the call site so the choice is visible.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("io error reading {path}: {source}")]
    Io {
        path: Utf8PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("toml parse error in {path}: {source}")]
    Parse {
        path: Utf8PathBuf,
        // `toml::de::Error` is ~128 bytes on its own; box it to keep the
        // `ConfigError` enum small (clippy::result_large_err).
        #[source]
        source: Box<toml::de::Error>,
    },
    #[error("invalid URL in cordance.toml: {0}")]
    InvalidUrl(String),
}

impl Config {
    /// Load from `{target}/cordance.toml`, returning a typed error on failure.
    ///
    /// In addition to surfacing parse errors, this validates remote-host
    /// constraints on `[llm.ollama].base_url`: any non-loopback host is reset
    /// to the default unless the environment variable
    /// `CORDANCE_ALLOW_REMOTE_LLM=1` is set. See the round-1 redteam review.
    ///
    /// # Errors
    ///
    /// - [`ConfigError::Io`] when the file exists but cannot be read.
    /// - [`ConfigError::Parse`] when the TOML cannot be deserialised.
    /// - [`ConfigError::InvalidUrl`] when `base_url` cannot be parsed as a URL.
    pub fn load_strict(target: &Utf8PathBuf) -> Result<Self, ConfigError> {
        let path = target.join("cordance.toml");
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
            path: path.clone(),
            source,
        })?;
        let mut cfg: Self = toml::from_str(&content).map_err(|source| ConfigError::Parse {
            path: path.clone(),
            source: Box::new(source),
        })?;
        validate_llm_endpoints(&mut cfg)?;
        Ok(cfg)
    }

    /// Resolve the doctrine root path (relative to `target`, or absolute).
    pub fn doctrine_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
        resolve_path(&self.doctrine.source, target)
    }

    /// Resolve the axiom source path (relative to `target`, or absolute).
    pub fn axiom_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
        resolve_path(&self.axiom.source, target)
    }

    /// Return the axiom algorithm version string.
    ///
    /// When `algorithm_latest == "auto"`, reads the `LATEST` file from the
    /// configured axiom repo. Falls back to a hard-coded default.
    pub fn axiom_version(&self, target: &Utf8PathBuf) -> String {
        if self.axiom.algorithm_latest != "auto" {
            return self.axiom.algorithm_latest.clone();
        }
        let axiom = self.axiom_root(target);
        // Compatibility path (pre-rename layout).
        let candidates = [
            axiom.join("PAI/Algorithm/LATEST"),
            axiom.join("axiom/Algorithm/LATEST"),
        ];
        for candidate in &candidates {
            if let Ok(content) = std::fs::read_to_string(candidate) {
                let v = content.trim().to_string();
                if !v.is_empty() {
                    return v;
                }
            }
        }
        "v3.1.1-axiom".into()
    }
}

fn resolve_path(s: &str, base: &Utf8PathBuf) -> Utf8PathBuf {
    let p = Utf8PathBuf::from(s);
    if p.is_absolute() {
        p
    } else {
        base.join(s)
    }
}

/// Process-global toggle: when `CORDANCE_ALLOW_REMOTE_LLM=1` is set, the
/// loopback-only check on `[llm.ollama].base_url` is bypassed.
fn allow_remote_llm_from_env() -> bool {
    std::env::var("CORDANCE_ALLOW_REMOTE_LLM").as_deref() == Ok("1")
}

/// Reject non-loopback `[llm.ollama].base_url` entries to defeat a target-
/// controlled `cordance.toml` redirecting prompts to an attacker-run Ollama
/// instance.
///
/// Reads [`allow_remote_llm_from_env`] to decide whether to enforce the
/// loopback rule.
fn validate_llm_endpoints(cfg: &mut Config) -> Result<(), ConfigError> {
    validate_llm_endpoints_with(cfg, allow_remote_llm_from_env())
}

/// Test-friendly inner implementation: takes the `allow_remote` flag as a
/// parameter so tests don't have to mutate process-global env state (which is
/// `unsafe` in modern Rust and forbidden by this crate's lint policy).
fn validate_llm_endpoints_with(cfg: &mut Config, allow_remote: bool) -> Result<(), ConfigError> {
    if allow_remote {
        return Ok(());
    }

    let url_str = &cfg.llm.ollama.base_url;
    let parsed = url::Url::parse(url_str)
        .map_err(|e| ConfigError::InvalidUrl(format!("[llm.ollama].base_url: {e}")))?;

    match parsed.host_str() {
        Some("localhost" | "127.0.0.1" | "::1" | "[::1]") => Ok(()),
        other => {
            tracing::warn!(
                host = ?other,
                "Refusing non-loopback Ollama base_url from cordance.toml; \
                 set CORDANCE_ALLOW_REMOTE_LLM=1 to override"
            );
            cfg.llm.ollama.base_url = default_ollama_base_url();
            Ok(())
        }
    }
}

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

    /// Write `cordance.toml` into a fresh temp directory and return the path
    /// (as `Utf8PathBuf`) that callers can pass to `Config::load_strict`.
    fn write_cfg(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
        let dir = tempfile::tempdir().expect("create tempdir");
        let path = dir.path().join("cordance.toml");
        std::fs::write(&path, content).expect("write cordance.toml");
        let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("tempdir is utf8");
        (dir, utf8)
    }

    fn parse_cfg(content: &str) -> Config {
        toml::from_str(content).expect("parse test cordance.toml")
    }

    // ── pure validator-level tests (no env mutation) ────────────────────────

    #[test]
    fn non_loopback_ollama_url_resets_to_default() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(
            cfg.llm.ollama.base_url, "http://localhost:11434",
            "non-loopback host must be reset to the default loopback URL"
        );
    }

    #[test]
    fn loopback_ollama_url_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://127.0.0.1:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://127.0.0.1:11434");
    }

    #[test]
    fn localhost_hostname_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://localhost:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn ipv6_loopback_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://[::1]:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://[::1]:11434");
    }

    #[test]
    fn env_var_overrides_loopback_check() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        // Simulate `CORDANCE_ALLOW_REMOTE_LLM=1` being set without touching
        // the process-global env (which would require `unsafe`).
        validate_llm_endpoints_with(&mut cfg, true).expect("validate ok");
        assert_eq!(
            cfg.llm.ollama.base_url, "https://evil.com",
            "env override must preserve the configured URL"
        );
    }

    #[test]
    fn malformed_url_returns_invalid_url_err() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "not a url"
"#,
        );
        let err =
            validate_llm_endpoints_with(&mut cfg, false).expect_err("expect InvalidUrl error");
        assert!(
            matches!(err, ConfigError::InvalidUrl(_)),
            "expected ConfigError::InvalidUrl, got {err:?}"
        );
    }

    // ── Config::load_strict smoke tests ─────────────────────────────────────

    #[test]
    fn toml_parse_error_returns_err() {
        // `=` without a value, deliberately malformed.
        let (_dir, target) = write_cfg("not = = valid");
        let err = Config::load_strict(&target).expect_err("expect Parse error");
        assert!(
            matches!(err, ConfigError::Parse { .. }),
            "expected ConfigError::Parse, got {err:?}"
        );
    }

    #[test]
    fn missing_config_returns_default() {
        let dir = tempfile::tempdir().expect("tempdir");
        let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf8 tempdir");
        let cfg = Config::load_strict(&utf8).expect("default on missing file");
        assert_eq!(cfg.llm.provider, "none");
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn load_strict_unwrap_or_default_recovers_from_parse_error() {
        // Replacement for the removed legacy `Config::load` test. Callers that
        // want graceful degradation must opt in explicitly at the call site,
        // and `.unwrap_or_default()` is the canonical recipe.
        let (_dir, target) = write_cfg("not = = valid");
        let cfg = Config::load_strict(&target).unwrap_or_default();
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn load_strict_resets_remote_url_unless_env_set() {
        // Without CORDANCE_ALLOW_REMOTE_LLM in the test environment, the
        // remote URL must be reset to the loopback default. We deliberately
        // do not mutate the env here; if the test process happens to inherit
        // the variable, the assertion below is skipped.
        if allow_remote_llm_from_env() {
            return;
        }

        let (_dir, target) = write_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        let cfg = Config::load_strict(&target).expect("load_strict ok");
        assert_eq!(
            cfg.llm.ollama.base_url, "http://localhost:11434",
            "remote URL must be reset to loopback default"
        );
    }
}