axon-lang 4.2.0

AXON — the formal cognitive language: a deterministic, proof-carrying AI runtime. Native Rust lexer/parser/type-checker/IR generator (re-exported from axon-frontend) plus the runtime: typed channels (π-calculus mobility, capability extrusion), algebraic effects via Free Monad CPS handlers, lease kernel + reconcile loop, the Epistemic Security Kernel, Trust Types, Proof-Carrying Code (independently verifiable proof objects), and the closed-catalog extension mechanism. Crate publishes as `axon-lang`; library import is `use axon::*` so existing call sites keep working unchanged.
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
//! AXON CLI — ESK audit commands (v1.3.0 CLI parity).
//!
//! Implements `dossier`, `sbom`, `audit`, `evidence-package`. Outputs
//! byte-identical JSON to the Python reference (section 8.2.h parity contract).

#![allow(dead_code)]

use std::fs;
use std::path::Path;

use serde_json::Value;

use crate::ast::Program;
use crate::esk::attestation::{generate_dossier, generate_sbom};
use crate::esk::audit_engine::{FrameworkId, analyze_all, analyze_gaps};
// v2.81.0 — the evidence packager writes a deterministic `.evidence.zip`,
// so it is the one part of the governance CLI that needs `zip`.
#[cfg(feature = "documents")]
use crate::esk::audit_engine::build_evidence_package;
use crate::ir_generator::IRGenerator;
use crate::ir_nodes::IRProgram;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::version::AXON_VERSION;

/// Two-space-indented, key-sorted JSON emission matching Python's
/// `json.dumps(..., indent=2, sort_keys=True)` with default `ensure_ascii=True`:
/// non-ASCII characters are escaped as `\uXXXX` for byte-identical parity
/// with the Python reference CLI output.
pub fn canonical_json(value: &Value) -> String {
    canonical_json_ensure_ascii(value, true)
}

/// Same as `canonical_json`, but lets the caller opt out of ASCII escaping
/// to match `json.dumps(..., ensure_ascii=False)` (used by `axon compile`
/// and `axon dossier`/`sbom` on the Python side).
pub fn canonical_json_utf8(value: &Value) -> String {
    canonical_json_ensure_ascii(value, false)
}

fn canonical_json_ensure_ascii(value: &Value, ensure_ascii: bool) -> String {
    let sorted = sort_value(value);
    let raw = serde_json::to_string_pretty(&sorted).expect("serialise");
    if ensure_ascii { escape_non_ascii(&raw) } else { raw }
}

/// Replace every non-ASCII scalar in a JSON string with `\uXXXX`,
/// matching Python's `json.dumps(..., ensure_ascii=True)` default.
/// Only runs inside already-quoted string literals (the JSON payload),
/// but since serde_json's pretty output never contains raw non-ASCII
/// outside string literals this is safe to apply to the whole output.
fn escape_non_ascii(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if (c as u32) < 0x80 {
            out.push(c);
        } else {
            let code = c as u32;
            if code <= 0xFFFF {
                out.push_str(&format!("\\u{:04x}", code));
            } else {
                // Surrogate pair encoding for code points > U+FFFF.
                let v = code - 0x10000;
                let hi = 0xD800 + (v >> 10);
                let lo = 0xDC00 + (v & 0x3FF);
                out.push_str(&format!("\\u{:04x}\\u{:04x}", hi, lo));
            }
        }
    }
    out
}

fn sort_value(v: &Value) -> Value {
    match v {
        Value::Object(map) => {
            let mut sorted = serde_json::Map::new();
            let mut keys: Vec<&String> = map.keys().collect();
            keys.sort();
            for k in keys {
                sorted.insert(k.clone(), sort_value(&map[k]));
            }
            Value::Object(sorted)
        }
        Value::Array(arr) => Value::Array(arr.iter().map(sort_value).collect()),
        other => other.clone(),
    }
}

/// Compile a `.axon` source file into an IRProgram. Returns `None` on
/// parse or type-check failure after printing the diagnostics to stderr.
fn compile_file(file: &str) -> Result<IRProgram, i32> {
    let path = Path::new(file);
    if !path.exists() {
        eprintln!("X File not found: {}", file);
        return Err(2);
    }
    let source = match fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("X Cannot read {}: {e}", file);
            return Err(2);
        }
    };
    let tokens = match Lexer::new(&source, file).tokenize() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("X Lex error in {}: {}", file, e.message);
            return Err(1);
        }
    };
    let program: Program = match Parser::new(tokens).parse() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("X Parse error in {}: {}", file, e.message);
            return Err(1);
        }
    };
    use crate::type_checker::TypeChecker;
    let diagnostics = TypeChecker::new(&program).check();
    if !diagnostics.is_empty() {
        eprintln!("X {} has {} type error(s) — run 'axon check' for details.", file, diagnostics.len());
        return Err(1);
    }
    Ok(IRGenerator::new().generate(&program))
}

pub fn write_or_print(text: &str, output: Option<&str>, success_msg: &str) -> i32 {
    match output {
        Some(path) => match fs::write(path, text) {
            Ok(()) => {
                println!("OK {} {}", success_msg, path);
                0
            }
            Err(e) => {
                eprintln!("X write {}: {e}", path);
                2
            }
        },
        None => {
            println!("{text}");
            0
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
//  axon dossier
// ═══════════════════════════════════════════════════════════════════

pub fn run_dossier(file: &str, output: Option<&str>) -> i32 {
    let ir = match compile_file(file) {
        Ok(ir) => ir,
        Err(code) => return code,
    };
    let dossier = generate_dossier(&ir, AXON_VERSION);
    let text = canonical_json(&dossier.to_value());
    write_or_print(&text, output, "dossier written to")
}

// ═══════════════════════════════════════════════════════════════════
//  axon sbom
// ═══════════════════════════════════════════════════════════════════

pub fn run_sbom(file: &str, output: Option<&str>) -> i32 {
    let ir = match compile_file(file) {
        Ok(ir) => ir,
        Err(code) => return code,
    };
    let sbom = generate_sbom(&ir, AXON_VERSION);
    let text = canonical_json(&sbom.to_value());
    write_or_print(&text, output, "SBOM written to")
}

// ═══════════════════════════════════════════════════════════════════
//  axon audit
// ═══════════════════════════════════════════════════════════════════

pub fn run_audit(file: &str, framework: &str, output: Option<&str>) -> i32 {
    let ir = match compile_file(file) {
        Ok(ir) => ir,
        Err(code) => return code,
    };
    let payload: Value = match framework {
        "all" => {
            let analyses = analyze_all(&ir);
            let mut frameworks = serde_json::Map::new();
            let mut summary = serde_json::Map::new();
            for (name, a) in &analyses {
                frameworks.insert(name.clone(), a.to_value());
                let mut s = serde_json::Map::new();
                // Parity with Python audit_cmd.py — `readiness_percent` is the
                // full-precision float (no rounding), `ready` is the integer
                // count of ready controls (NOT a boolean predicate).
                s.insert("readiness_percent".into(), a.readiness_percent().into());
                s.insert("ready".into(), (a.ready as i64).into());
                s.insert("total".into(), (a.total_controls as i64).into());
                s.insert("pending_code".into(), (a.pending_code as i64).into());
                s.insert("pending_external".into(), (a.pending_external as i64).into());
                summary.insert(name.clone(), Value::Object(s));
            }
            let mut root = serde_json::Map::new();
            root.insert("schema".into(), "axon.esk.audit_gap_report.v1".into());
            root.insert("program".into(), Path::new(file).file_name()
                .and_then(|n| n.to_str())
                .unwrap_or(file)
                .to_string()
                .into());
            root.insert("frameworks".into(), Value::Object(frameworks));
            root.insert("summary".into(), Value::Object(summary));
            Value::Object(root)
        }
        other => {
            let fw = match other {
                "soc2" => FrameworkId::Soc2TypeII,
                "iso27001" => FrameworkId::Iso27001,
                "fips" => FrameworkId::Fips140_3,
                "cc" => FrameworkId::CcEal4Plus,
                _ => {
                    eprintln!(
                        "X Unknown framework '{other}'. Use one of: soc2, iso27001, fips, cc, all."
                    );
                    return 2;
                }
            };
            let a = analyze_gaps(&ir, fw);
            let mut root = serde_json::Map::new();
            root.insert("schema".into(), "axon.esk.audit_gap_report.v1".into());
            root.insert("program".into(), Path::new(file).file_name()
                .and_then(|n| n.to_str())
                .unwrap_or(file)
                .to_string()
                .into());
            root.insert("analysis".into(), a.to_value());
            Value::Object(root)
        }
    };
    let text = canonical_json(&payload);
    write_or_print(&text, output, "audit report written to")
}

// ═══════════════════════════════════════════════════════════════════
//  axon evidence-package
// ═══════════════════════════════════════════════════════════════════

// v2.81.0 — `axon evidence-package` stays in `--help` under every profile
// and refuses in writing when the feature is absent. The v2.67.0 doctrine applied
// to packaging: the advertised surface stays advertised, and the refusal names
// the exact command that fixes it. A subcommand that quietly disappears from a
// build is the same defect v2.67.0 spent a cycle removing.
#[cfg(not(feature = "documents"))]
pub fn run_evidence_package(_file: &str, _output: Option<&str>, _note: &str) -> i32 {
    eprintln!(
        "X `axon evidence-package` requires the `documents` feature — this build was compiled without it, so the deterministic .evidence.zip packager is absent.
  Reinstall with: cargo install axon-lang --features documents
  (`axon dossier`, `axon sbom` and `axon audit` work in this build — only the ZIP bundle needs the feature.)"
    );
    2
}

#[cfg(feature = "documents")]
pub fn run_evidence_package(file: &str, output: Option<&str>, note: &str) -> i32 {
    let ir = match compile_file(file) {
        Ok(ir) => ir,
        Err(code) => return code,
    };
    let source = match fs::read_to_string(file) {
        Ok(s) => s,
        Err(_) => String::new(),
    };
    let fname = Path::new(file)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(file)
        .to_string();
    let mut sources = std::collections::BTreeMap::new();
    sources.insert(fname, source);

    let pkg = build_evidence_package(&ir, AXON_VERSION, None, None, Some(sources), note);
    let out_path = match output {
        Some(p) => p.to_string(),
        None => {
            // Default <file>.evidence.zip next to the source.
            let p = Path::new(file);
            let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("program");
            let parent = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
            if parent.is_empty() {
                format!("{stem}.evidence.zip")
            } else {
                format!("{parent}/{stem}.evidence.zip")
            }
        }
    };
    let path = pkg.write_zip(&out_path);
    let bytes = pkg.to_zip_bytes();
    println!(
        "OK evidence package written to {} ({} files, {} bytes)",
        path.display(),
        pkg.files.len(),
        bytes.len()
    );

    // ── v4.0.0 — the DETACHED hybrid signature ──────────────────────
    //
    // `Ed25519(H) ‖ ML-DSA-65(H)` over the exact ZIP bytes just written.
    //
    // Detached, not inside the ZIP, because the packager's contract is
    // byte-identical output on equal inputs — and a hedged ML-DSA signature
    // plus per-run keypairs is nondeterministic by nature. Signing the ZIP
    // from outside preserves the determinism auditors diff against, and a
    // detached signature over the exact file bytes is also what an auditor's
    // tooling expects to consume.
    //
    // The keys are generated per package and their PUBLIC halves ship in the
    // sig file: this signature proves the package is intact SINCE PACKAGING,
    // not who packaged it. A durable signer identity needs key custody (v2.48.0)
    // and is named follow-up work in the v4.0.0 plan — claiming identity from
    // an ephemeral key would be the overclaim this cycle exists to remove.
    #[cfg(feature = "csys-native")]
    {
        use crate::esk::hybrid_signer::{HybridSigner, HYBRID_SIGNATURE_BYTES};
        use crate::esk::provenance::Signer as _;

        fn hex(b: &[u8]) -> String {
            b.iter().map(|x| format!("{x:02x}")).collect()
        }

        let Some(signer) = HybridSigner::generate() else {
            // The ZIP exists and is fine; the SIGNATURE could not be made.
            // Failing the command is deliberate: exiting 0 here would let a
            // pipeline treat an unsigned package as a signed one, which is
            // the silent-substitution defect with legal evidence attached.
            eprintln!(
                "X the evidence package was written, but the OS RNG failed and no hybrid \
                 signature could be produced. Refusing to report success for an unsigned \
                 package — re-run to sign."
            );
            return 2;
        };
        let sig = signer.sign(&bytes);
        debug_assert_eq!(sig.len(), HYBRID_SIGNATURE_BYTES);
        let keys = signer.public_keys();

        // Self-check before publishing: a signature file that does not verify
        // against the bytes beside it is worse than none.
        if !HybridSigner::verify_hybrid(&bytes, &sig, &keys) {
            eprintln!("X hybrid signature self-check failed — not writing a signature that does not verify");
            return 2;
        }

        let sig_json = serde_json::json!({
            "schema": "axon.esk.evidence_signature.v1",
            "algorithm": signer.algorithm(),
            "signs": "the ZIP file bytes, exactly as written",
            "zip_sha256": hex(&axon_csys::crypto::sha256(&bytes)),
            "ed25519_public_key": hex(&keys.ed25519),
            "ml_dsa_65_public_key": hex(&keys.ml_dsa_65),
            "signature": hex(&sig),
            "note": "Keys are generated per package; this proves integrity since packaging, not signer identity. Durable identity requires key custody (integration planned).",
        });
        let sig_path = format!("{}.sig.json", path.display());
        if let Err(e) = fs::write(&sig_path, serde_json::to_string_pretty(&sig_json).unwrap_or_default()) {
            eprintln!("X could not write {sig_path}: {e}");
            return 2;
        }
        println!("OK hybrid signature (Ed25519+ML-DSA-65 over SHA-256) written to {sig_path}");
    }
    #[cfg(not(feature = "csys-native"))]
    println!(
        "~ hybrid signature skipped: this build lacks `csys-native` (the C cryptographic \
         boundary). The package is complete and unsigned."
    );

    0
}

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

    #[test]
    fn canonical_json_sorts_and_indents() {
        let v = serde_json::json!({"b": 1, "a": {"z": 2, "y": 3}});
        let s = canonical_json(&v);
        assert!(s.starts_with('{'));
        // Keys sorted alphabetically.
        let a_pos = s.find("\"a\"").unwrap();
        let b_pos = s.find("\"b\"").unwrap();
        assert!(a_pos < b_pos);
        // Nested keys sorted too.
        let y_pos = s.find("\"y\"").unwrap();
        let z_pos = s.find("\"z\"").unwrap();
        assert!(y_pos < z_pos);
    }

    /// v4.0.0 — **the production door of the hybrid signature.**
    ///
    /// The `HybridSigner`'s own unit tests prove the construction; this proves
    /// the WIRING — that `axon evidence-package` actually emits a detached
    /// signature an external party can verify against the ZIP bytes it sits
    /// beside, using only what the sig file itself carries. The whole v2.89.0
    /// line exists because engines pass their own tests while no production
    /// path reaches them; this is the path.
    #[cfg(all(feature = "documents", feature = "csys-native"))]
    #[test]
    fn evidence_package_ships_a_verifiable_detached_hybrid_signature() {
        use crate::esk::hybrid_signer::{HybridPublicKeys, HybridSigner};

        fn unhex(s: &str) -> Vec<u8> {
            (0..s.len())
                .step_by(2)
                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
                .collect()
        }

        let dir = std::env::temp_dir().join(format!("axon_124b_sig_{}", std::process::id()));
        std::fs::create_dir_all(&dir).expect("temp dir");
        let src_path = dir.join("p.axon");
        std::fs::write(&src_path, "flow F() -> Unit { step S { ask: \"go\" } }\n").unwrap();
        let out = dir.join("p.evidence.zip");

        let code = run_evidence_package(
            src_path.to_str().unwrap(),
            Some(out.to_str().unwrap()),
            "firma hibrida, camino de produccion",
        );
        assert_eq!(code, 0, "the packager must succeed end to end");

        let zip = std::fs::read(&out).expect("the ZIP must exist");
        let sig_path = format!("{}.sig.json", out.display());
        let sig_file: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&sig_path).expect(
                "the detached signature must exist beside the ZIP — a signer with no \
                 production caller is the defect this gate exists to close",
            ))
            .expect("valid JSON");

        assert_eq!(sig_file["algorithm"], "Ed25519+ML-DSA-65");

        // Verify EXACTLY as an external auditor would: from the sig file's own
        // fields, against the file bytes on disk, no access to the signer.
        let keys = HybridPublicKeys {
            ed25519: unhex(sig_file["ed25519_public_key"].as_str().unwrap())
                .try_into()
                .expect("32-byte Ed25519 public key"),
            ml_dsa_65: unhex(sig_file["ml_dsa_65_public_key"].as_str().unwrap())
                .try_into()
                .expect("1952-byte ML-DSA-65 public key"),
        };
        let sig = unhex(sig_file["signature"].as_str().unwrap());
        assert!(
            HybridSigner::verify_hybrid(&zip, &sig, &keys),
            "the detached signature must verify against the ZIP bytes using only what the \
             sig file carries"
        );

        // And the property the signature exists for: any altered byte in the
        // package must break it.
        let mut tampered = zip.clone();
        tampered[zip.len() / 2] ^= 1;
        assert!(
            !HybridSigner::verify_hybrid(&tampered, &sig, &keys),
            "a tampered package must not verify"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }
}