From 21eae8b6a7b10d59ef164b5d1349c7a31976aa8e Mon Sep 17 00:00:00 2001 From: rfunix Date: Wed, 1 Apr 2026 19:08:26 -0300 Subject: [PATCH 1/5] test: add UI tests for E0001 E0100 E0211-E0212 E0215-E0217 E0219 E0223 E0226-E0227 E0251 E0260-E0262 E0310 Covers error codes across lexer, parser, types, contracts, and annotation phases. 16 new compile-fail UI tests bringing error-messages coverage to 40 files with 38+ distinct error codes exercised. Co-Authored-By: Claude Sonnet 4.6 --- .../error-messages/closure_param_missing_type.ko | 10 ++++++++++ .../ui/error-messages/coalesce_type_mismatch.ko | 11 +++++++++++ .../error-messages/confidence_below_threshold.ko | 14 ++++++++++++++ .../contract_clause_syntax_error.ko | 15 +++++++++++++++ .../ui/error-messages/duplicate_struct_field.ko | 15 +++++++++++++++ tests/ui/error-messages/empty_purpose.ko | 9 +++++++++ tests/ui/error-messages/extra_struct_field.ko | 15 +++++++++++++++ tests/ui/error-messages/invariant_not_bool.ko | 15 +++++++++++++++ .../error-messages/low_confidence_no_review.ko | 14 ++++++++++++++ tests/ui/error-messages/missing_purpose.ko | 9 +++++++++ tests/ui/error-messages/missing_type_args.ko | 14 ++++++++++++++ tests/ui/error-messages/no_such_field.ko | 15 +++++++++++++++ .../security_sensitive_no_contract.ko | 14 ++++++++++++++ .../ui/error-messages/spawn_captures_non_send.ko | 16 ++++++++++++++++ tests/ui/error-messages/unexpected_char.ko | 10 ++++++++++ tests/ui/error-messages/unknown_variant.ko | 16 ++++++++++++++++ 16 files changed, 212 insertions(+) create mode 100644 tests/ui/error-messages/closure_param_missing_type.ko create mode 100644 tests/ui/error-messages/coalesce_type_mismatch.ko create mode 100644 tests/ui/error-messages/confidence_below_threshold.ko create mode 100644 tests/ui/error-messages/contract_clause_syntax_error.ko create mode 100644 tests/ui/error-messages/duplicate_struct_field.ko create mode 100644 tests/ui/error-messages/empty_purpose.ko create mode 100644 tests/ui/error-messages/extra_struct_field.ko create mode 100644 tests/ui/error-messages/invariant_not_bool.ko create mode 100644 tests/ui/error-messages/low_confidence_no_review.ko create mode 100644 tests/ui/error-messages/missing_purpose.ko create mode 100644 tests/ui/error-messages/missing_type_args.ko create mode 100644 tests/ui/error-messages/no_such_field.ko create mode 100644 tests/ui/error-messages/security_sensitive_no_contract.ko create mode 100644 tests/ui/error-messages/spawn_captures_non_send.ko create mode 100644 tests/ui/error-messages/unexpected_char.ko create mode 100644 tests/ui/error-messages/unknown_variant.ko diff --git a/tests/ui/error-messages/closure_param_missing_type.ko b/tests/ui/error-messages/closure_param_missing_type.ko new file mode 100644 index 0000000..2edea61 --- /dev/null +++ b/tests/ui/error-messages/closure_param_missing_type.ko @@ -0,0 +1,10 @@ +//@ compile-fail +//@ error-code: E0227 +module closure_param_missing_type { + meta { purpose: "Type error E0227: closure parameter without type annotation" } + + fn main() -> Int { + let f = |x| -> Int { x + 1 } + return 0 + } +} diff --git a/tests/ui/error-messages/coalesce_type_mismatch.ko b/tests/ui/error-messages/coalesce_type_mismatch.ko new file mode 100644 index 0000000..cf0f8d5 --- /dev/null +++ b/tests/ui/error-messages/coalesce_type_mismatch.ko @@ -0,0 +1,11 @@ +//@ compile-fail +//@ error-code: E0226 +module coalesce_type_mismatch { + meta { purpose: "Type error E0226: null coalescing ?? on non-Option left side" } + + fn main() -> Int { + let x: Int = 42 + let v: Int = x ?? 0 + return v + } +} diff --git a/tests/ui/error-messages/confidence_below_threshold.ko b/tests/ui/error-messages/confidence_below_threshold.ko new file mode 100644 index 0000000..49fcb62 --- /dev/null +++ b/tests/ui/error-messages/confidence_below_threshold.ko @@ -0,0 +1,14 @@ +//@ compile-fail +//@ error-code: E0261 +module confidence_below_threshold { + meta { + purpose: "E0261: confidence da funcao abaixo do min_confidence declarado no meta" + min_confidence: "0.90" + } + + @confidence(0.5) + @reviewed_by(human: "alice") + fn weak_function() -> Int { + return 1 + } +} diff --git a/tests/ui/error-messages/contract_clause_syntax_error.ko b/tests/ui/error-messages/contract_clause_syntax_error.ko new file mode 100644 index 0000000..fc9a377 --- /dev/null +++ b/tests/ui/error-messages/contract_clause_syntax_error.ko @@ -0,0 +1,15 @@ +//@ compile-fail +//@ error-code: E0100 +module contract_clause_syntax_error { + meta { purpose: "Parse error in requires block: malformed expression causes compile fail" } + + fn divide(a: Int, b: Int) -> Int + requires { if } + { + return a / b + } + + fn main() -> Int { + return divide(10, 2) + } +} diff --git a/tests/ui/error-messages/duplicate_struct_field.ko b/tests/ui/error-messages/duplicate_struct_field.ko new file mode 100644 index 0000000..46899bf --- /dev/null +++ b/tests/ui/error-messages/duplicate_struct_field.ko @@ -0,0 +1,15 @@ +//@ compile-fail +//@ error-code: E0216 +module duplicate_struct_field { + meta { purpose: "Type error E0216: struct literal with duplicate field" } + + struct Config { + host: String, + port: Int + } + + fn main() -> Int { + let c: Config = Config { host: "localhost", port: 8080, host: "example.com" } + return c.port + } +} diff --git a/tests/ui/error-messages/empty_purpose.ko b/tests/ui/error-messages/empty_purpose.ko new file mode 100644 index 0000000..3b0a4b7 --- /dev/null +++ b/tests/ui/error-messages/empty_purpose.ko @@ -0,0 +1,9 @@ +//@ compile-fail +//@ error-code: E0211 +module empty_purpose { + meta { purpose: "" } + + fn main() -> Int { + return 0 + } +} diff --git a/tests/ui/error-messages/extra_struct_field.ko b/tests/ui/error-messages/extra_struct_field.ko new file mode 100644 index 0000000..6e6efc8 --- /dev/null +++ b/tests/ui/error-messages/extra_struct_field.ko @@ -0,0 +1,15 @@ +//@ compile-fail +//@ error-code: E0215 +module extra_struct_field { + meta { purpose: "Type error E0215: struct literal with extra unknown field" } + + struct Point { + x: Int, + y: Int + } + + fn main() -> Int { + let p: Point = Point { x: 1, y: 2, z: 3 } + return p.x + } +} diff --git a/tests/ui/error-messages/invariant_not_bool.ko b/tests/ui/error-messages/invariant_not_bool.ko new file mode 100644 index 0000000..3eec39c --- /dev/null +++ b/tests/ui/error-messages/invariant_not_bool.ko @@ -0,0 +1,15 @@ +//@ compile-fail +//@ error-code: E0310 +module invariant_not_bool { + meta { purpose: "E0310: invariant com valor Int em vez de Bool" } + + invariant { 42 } + + fn compute(x: Int) -> Int { + return x * 2 + } + + fn main() -> Int { + return compute(5) + } +} diff --git a/tests/ui/error-messages/low_confidence_no_review.ko b/tests/ui/error-messages/low_confidence_no_review.ko new file mode 100644 index 0000000..9a088c8 --- /dev/null +++ b/tests/ui/error-messages/low_confidence_no_review.ko @@ -0,0 +1,14 @@ +//@ compile-fail +//@ error-code: E0260 +module low_confidence_no_review { + meta { purpose: "E0260: confidence below 0.8 sem reviewed_by humano" } + + @confidence(0.5) + fn risky() -> Int { + return 42 + } + + fn main() -> Int { + return risky() + } +} diff --git a/tests/ui/error-messages/missing_purpose.ko b/tests/ui/error-messages/missing_purpose.ko new file mode 100644 index 0000000..9e7ee2e --- /dev/null +++ b/tests/ui/error-messages/missing_purpose.ko @@ -0,0 +1,9 @@ +//@ compile-fail +//@ error-code: E0212 +module missing_purpose { + meta { author: "agent" } + + fn main() -> Int { + return 0 + } +} diff --git a/tests/ui/error-messages/missing_type_args.ko b/tests/ui/error-messages/missing_type_args.ko new file mode 100644 index 0000000..85a91c9 --- /dev/null +++ b/tests/ui/error-messages/missing_type_args.ko @@ -0,0 +1,14 @@ +//@ compile-fail +//@ error-code: E0223 +module missing_type_args { + meta { purpose: "Type error E0223: generic type used without type arguments" } + + struct Wrapper { + value: T + } + + fn main() -> Int { + let w: Wrapper = Wrapper { value: 42 } + return 0 + } +} diff --git a/tests/ui/error-messages/no_such_field.ko b/tests/ui/error-messages/no_such_field.ko new file mode 100644 index 0000000..0d5134a --- /dev/null +++ b/tests/ui/error-messages/no_such_field.ko @@ -0,0 +1,15 @@ +//@ compile-fail +//@ error-code: E0217 +module no_such_field { + meta { purpose: "Type error E0217: accessing non-existent field on struct" } + + struct Point { + x: Int, + y: Int + } + + fn main() -> Int { + let p: Point = Point { x: 10, y: 20 } + return p.z + } +} diff --git a/tests/ui/error-messages/security_sensitive_no_contract.ko b/tests/ui/error-messages/security_sensitive_no_contract.ko new file mode 100644 index 0000000..f5991d4 --- /dev/null +++ b/tests/ui/error-messages/security_sensitive_no_contract.ko @@ -0,0 +1,14 @@ +//@ compile-fail +//@ error-code: E0262 +module security_sensitive_no_contract { + meta { purpose: "E0262: funcao security_sensitive sem requires/ensures" } + + @security_sensitive + fn authenticate(password: String) -> Bool { + return true + } + + fn main() -> Bool { + return authenticate("secret") + } +} diff --git a/tests/ui/error-messages/spawn_captures_non_send.ko b/tests/ui/error-messages/spawn_captures_non_send.ko new file mode 100644 index 0000000..f4d1ec3 --- /dev/null +++ b/tests/ui/error-messages/spawn_captures_non_send.ko @@ -0,0 +1,16 @@ +//@ compile-fail +//@ error-code: E0280 +module spawn_captures_non_send { + meta { purpose: "E0280: spawn captura ref borrow que nao e Send-safe" } + + fn process(ref data: Int) { + spawn { + print_int(data) + } + } + + fn main() { + let value: Int = 99 + process(value) + } +} diff --git a/tests/ui/error-messages/unexpected_char.ko b/tests/ui/error-messages/unexpected_char.ko new file mode 100644 index 0000000..228dcbb --- /dev/null +++ b/tests/ui/error-messages/unexpected_char.ko @@ -0,0 +1,10 @@ +//@ compile-fail +//@ error-code: E0001 +module unexpected_char { + meta { purpose: "Lex error E0001: unexpected character in source" } + + fn main() -> Int { + let x: Int = 42§ + return x + } +} diff --git a/tests/ui/error-messages/unknown_variant.ko b/tests/ui/error-messages/unknown_variant.ko new file mode 100644 index 0000000..a1fb525 --- /dev/null +++ b/tests/ui/error-messages/unknown_variant.ko @@ -0,0 +1,16 @@ +//@ compile-fail +//@ error-code: E0219 +module unknown_variant { + meta { purpose: "Type error E0219: referencing variant that does not exist in enum" } + + enum Color { + Red, + Green, + Blue + } + + fn main() -> Int { + let c: Color = Color::Purple + return 0 + } +} From ee8f3a8777bbc3fcdd04e51441c7937164f401b0 Mon Sep 17 00:00:00 2001 From: rfunix Date: Wed, 1 Apr 2026 19:19:16 -0300 Subject: [PATCH 2/5] test: add 15 formatter roundtrip and idempotency tests Covers closures, actors, traits/impls, annotations, Option/Result types, tuples, break/continue, spawn, channels, select, intents, unary/binary ops, nested collections, and idempotency (format(format(x)) == format(x)). Co-Authored-By: Claude Sonnet 4.6 --- crates/kodoc/src/formatter.rs | 320 ++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) diff --git a/crates/kodoc/src/formatter.rs b/crates/kodoc/src/formatter.rs index 92068ca..b24b638 100644 --- a/crates/kodoc/src/formatter.rs +++ b/crates/kodoc/src/formatter.rs @@ -1151,4 +1151,324 @@ module generics { let out = roundtrip(src); assert!(out.contains("identity") || out.contains("identity<")); } + + #[test] + fn formats_closure() { + let src = r#" +module closures { + meta { purpose: "test" } + fn main() -> Int { + let mul: (Int) -> Int = |x: Int| -> Int { x * 2 } + return mul(5) + } +}"#; + let out = roundtrip(src); + assert!(out.contains("|x: Int|")); + assert!(out.contains("x * 2")); + } + + #[test] + fn formats_actor_decl() { + let src = r#" +module actors { + meta { purpose: "test" } + actor Counter { + count: Int + fn increment(self) -> Int { + return self.count + 1 + } + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("actor Counter")); + assert!(out.contains("count: Int")); + assert!(out.contains("fn increment")); + } + + #[test] + fn formats_trait_and_impl() { + let src = r#" +module traits { + meta { purpose: "test" } + trait Greet { + fn greet(self) -> String + } + struct Person { + name: String + } + impl Greet for Person { + fn greet(self) -> String { + return "hi" + } + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("trait Greet")); + assert!(out.contains("impl Greet for Person")); + assert!(out.contains("fn greet")); + } + + #[test] + fn formats_annotations() { + let src = r#" +module annotations { + meta { purpose: "test" } + @confidence(0.9) + @authored_by(agent: "test") + fn compute(x: Int) -> Int { + return x * 2 + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("@confidence(0.9)")); + assert!(out.contains("@authored_by(")); + assert!(out.contains("fn compute")); + } + + #[test] + fn formats_option_result_types() { + let src = r#" +module optresult { + meta { purpose: "test" } + fn maybe_val(x: Int) -> Option { + if x > 0 { + return Option::Some(x) + } + return Option::None + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("Option")); + assert!(out.contains("Option::Some")); + assert!(out.contains("Option::None")); + } + + #[test] + fn formats_tuple_type_and_access() { + let src = r#" +module tuples { + meta { purpose: "test" } + fn swap(a: Int, b: Int) -> (Int, Int) { + return (b, a) + } + fn main() -> Int { + let pair: (Int, Int) = swap(1, 2) + let first: Int = pair.0 + return first + } +}"#; + let out = roundtrip(src); + assert!(out.contains("(Int, Int)")); + assert!(out.contains("pair.0")); + } + + #[test] + fn formats_break_continue() { + let src = r#" +module breakcont { + meta { purpose: "test" } + fn find_first(n: Int) -> Int { + let mut i: Int = 0 + while i < n { + if i == 5 { + break + } + if i == 3 { + i = i + 1 + continue + } + i = i + 1 + } + return i + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("break")); + assert!(out.contains("continue")); + } + + #[test] + fn formats_spawn() { + let src = r#" +module spawning { + meta { purpose: "test" } + fn main() -> Int { + spawn { + print_int(42) + } + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("spawn {")); + assert!(out.contains("print_int(42)")); + } + + #[test] + fn formats_channel_ops() { + let src = r#" +module channels { + meta { purpose: "test" } + fn main() -> Int { + let ch: Channel = channel_new() + channel_send(ch, 42) + let v: Int = channel_recv(ch) + return v + } +}"#; + let out = roundtrip(src); + assert!(out.contains("Channel")); + assert!(out.contains("channel_new()")); + assert!(out.contains("channel_send(")); + assert!(out.contains("channel_recv(")); + } + + #[test] + fn formats_select() { + let src = r#" +module sel { + meta { purpose: "test" } + fn main() -> Int { + let ch1: Channel = channel_new() + let ch2: Channel = channel_new() + spawn { + channel_send(ch2, 99) + } + select { + ch1 => |val: Int| { + print_int(val) + } + ch2 => |val: Int| { + print_int(val) + } + } + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("select {")); + assert!(out.contains("ch1 =>") || out.contains("ch2 =>")); + } + + #[test] + fn formats_intent_block() { + let src = r#" +module intents { + meta { purpose: "test" } + intent fetch_data { + url: "https://api.example.com" + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("intent fetch_data")); + assert!(out.contains("url:")); + } + + #[test] + fn formats_unary_ops() { + let src = r#" +module unary { + meta { purpose: "test" } + fn negate(x: Int) -> Int { + return 0 - x + } + fn invert(b: Bool) -> Bool { + return !b + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("!b")); + assert!(out.contains("0 - x")); + } + + #[test] + fn formats_binary_all_ops() { + let src = r#" +module binops { + meta { purpose: "test" } + fn demo(a: Int, b: Int) -> Bool { + let sum: Int = a + b + let diff: Int = a - b + let prod: Int = a * b + let quot: Int = a / b + let rem: Int = a % b + let eq: Bool = a == b + let ne: Bool = a != b + let lt: Bool = a < b + let gt: Bool = a > b + let and_r: Bool = eq && ne + let or_r: Bool = lt || gt + return or_r + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("a + b")); + assert!(out.contains("a - b")); + assert!(out.contains("a * b")); + assert!(out.contains("a / b")); + assert!(out.contains("a % b")); + assert!(out.contains("a == b")); + assert!(out.contains("a != b")); + assert!(out.contains("&&")); + assert!(out.contains("||")); + } + + #[test] + fn formats_nested_collections() { + let src = r#" +module nested_cols { + meta { purpose: "test" } + fn make_nested() -> List> { + let inner: List = list_new() + let outer: List> = list_new() + return outer + } + fn main() -> Int { + return 0 + } +}"#; + let out = roundtrip(src); + assert!(out.contains("List>")); + assert!(out.contains("list_new()")); + } + + #[test] + fn formats_idempotent() { + let src = r#" +module idempotent { + meta { purpose: "test" } + fn add(a: Int, b: Int) -> Int { + return a + b + } +}"#; + let module1 = kodo_parser::parse(src).expect("initial parse failed"); + let out1 = format_module(&module1); + let module2 = kodo_parser::parse(&out1).expect("second parse failed"); + let out2 = format_module(&module2); + assert_eq!(out1, out2, "formatter is not idempotent"); + } } From ec1ba9533035f1efcecba1516ecf13223a35847c Mon Sep 17 00:00:00 2001 From: rfunix Date: Wed, 1 Apr 2026 19:22:38 -0300 Subject: [PATCH 3/5] test: add 10 E2E tests for fmt, annotate, audit, fix CLI subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: fmt roundtrip and idempotency, annotate JSON output, audit JSON and policy enforcement, and fix dry-run — bringing all major kodoc subcommands under automated test coverage. Co-Authored-By: Claude Sonnet 4.6 --- crates/kodoc/tests/cli_commands.rs | 316 +++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 crates/kodoc/tests/cli_commands.rs diff --git a/crates/kodoc/tests/cli_commands.rs b/crates/kodoc/tests/cli_commands.rs new file mode 100644 index 0000000..03933b0 --- /dev/null +++ b/crates/kodoc/tests/cli_commands.rs @@ -0,0 +1,316 @@ +//! E2E tests for kodoc CLI subcommands: fmt, annotate, audit, and fix. +//! +//! Each test invokes the `kodoc` binary via `std::process::Command` and +//! verifies exit codes, stdout contents, and JSON validity as appropriate. + +use std::process::Command; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Returns the path to the `kodoc` binary built by cargo. +fn get_kodoc_path() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_BIN_EXE_kodoc")) +} + +/// Writes a `.ko` source file to a unique temp directory and returns its path. +fn write_temp_ko(source: &str, name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("kodo_cli_tests").join(name); + std::fs::create_dir_all(&dir).expect("could not create temp dir"); + let path = dir.join(format!("{name}.ko")); + std::fs::write(&path, source).expect("could not write temp .ko file"); + path +} + +/// A minimal valid Kōdo module used across multiple tests. +fn valid_source() -> &'static str { + r#"module hello { + meta { purpose: "CLI test" } + fn main() -> Int { + return 0 + } +}"# +} + +/// A Kōdo module with annotation metadata useful for audit/annotate tests. +fn annotated_source() -> &'static str { + r#"module audit_test { + meta { purpose: "Audit test", version: "1.0.0" } + + @confidence(0.9) + @authored_by(agent: "test") + fn safe_fn(x: Int) -> Int + requires { x > 0 } + ensures { result > 0 } + { + return x + } + + fn unreviewed_fn(y: Int) -> Int { + return y + } +}"# +} + +/// A Kōdo module with a low-confidence annotation for policy violation tests. +fn low_confidence_source() -> &'static str { + r#"module low_conf { + meta { purpose: "Low confidence test" } + + @confidence(0.5) + fn risky_fn(x: Int) -> Int { + return x + } +}"# +} + +/// A Kōdo module with a deliberate syntax error. +fn invalid_source() -> &'static str { + r#"module broken { + fn oops( -> Int { + return 0 + } +}"# +} + +// --------------------------------------------------------------------------- +// fmt tests +// --------------------------------------------------------------------------- + +#[test] +fn test_fmt_valid_file() { + let path = write_temp_ko(valid_source(), "fmt_valid"); + let output = Command::new(get_kodoc_path()) + .arg("fmt") + .arg(&path) + .output() + .expect("failed to run kodoc fmt"); + + assert!( + output.status.success(), + "kodoc fmt exited with non-zero status\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // kodoc fmt formats in-place; verify the file still contains valid content. + let formatted = std::fs::read_to_string(&path).expect("could not read formatted file"); + assert!( + formatted.contains("module"), + "expected 'module' in formatted file contents, got:\n{formatted}", + ); +} + +#[test] +fn test_fmt_formats_consistently() { + // kodoc fmt modifies the file in-place. Idempotency means running fmt twice + // on the same file produces the same file contents both times. + let path = write_temp_ko(valid_source(), "fmt_idempotent"); + let kodoc = get_kodoc_path(); + + let first = Command::new(&kodoc) + .arg("fmt") + .arg(&path) + .output() + .expect("failed to run kodoc fmt (first pass)"); + + assert!( + first.status.success(), + "kodoc fmt (first pass) failed\nstderr: {}", + String::from_utf8_lossy(&first.stderr), + ); + + let after_first = std::fs::read_to_string(&path).expect("could not read file after first fmt"); + + let second = Command::new(&kodoc) + .arg("fmt") + .arg(&path) + .output() + .expect("failed to run kodoc fmt (second pass)"); + + assert!( + second.status.success(), + "kodoc fmt (second pass) failed\nstderr: {}", + String::from_utf8_lossy(&second.stderr), + ); + + let after_second = + std::fs::read_to_string(&path).expect("could not read file after second fmt"); + + assert_eq!( + after_first, after_second, + "kodoc fmt is not idempotent: file contents differ after first and second run", + ); +} + +#[test] +fn test_fmt_invalid_file_exits_nonzero() { + let path = write_temp_ko(invalid_source(), "fmt_invalid"); + let output = Command::new(get_kodoc_path()) + .arg("fmt") + .arg(&path) + .output() + .expect("failed to run kodoc fmt on invalid file"); + + assert!( + !output.status.success(), + "expected kodoc fmt to exit non-zero on a file with syntax errors", + ); +} + +// --------------------------------------------------------------------------- +// annotate tests +// --------------------------------------------------------------------------- + +#[test] +fn test_annotate_suggests_contracts() { + let path = write_temp_ko(annotated_source(), "annotate_basic"); + let output = Command::new(get_kodoc_path()) + .arg("annotate") + .arg(&path) + .output() + .expect("failed to run kodoc annotate"); + + assert!( + output.status.success(), + "kodoc annotate exited with non-zero status\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn test_annotate_json_output() { + let path = write_temp_ko(annotated_source(), "annotate_json"); + let output = Command::new(get_kodoc_path()) + .arg("annotate") + .arg("--json") + .arg(&path) + .output() + .expect("failed to run kodoc annotate --json"); + + assert!( + output.status.success(), + "kodoc annotate --json exited with non-zero status\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&stdout) + .expect("kodoc annotate --json did not produce valid JSON"); +} + +// --------------------------------------------------------------------------- +// audit tests +// --------------------------------------------------------------------------- + +#[test] +fn test_audit_basic_output() { + let path = write_temp_ko(annotated_source(), "audit_basic"); + let output = Command::new(get_kodoc_path()) + .arg("audit") + .arg(&path) + .output() + .expect("failed to run kodoc audit"); + + assert!( + output.status.success(), + "kodoc audit exited with non-zero status\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout).to_lowercase(), + String::from_utf8_lossy(&output.stderr).to_lowercase(), + ); + assert!( + combined.contains("confidence") || combined.contains("audit"), + "expected 'confidence' or 'audit' in kodoc audit output, got:\n{combined}", + ); +} + +#[test] +fn test_audit_json_output() { + let path = write_temp_ko(annotated_source(), "audit_json"); + let output = Command::new(get_kodoc_path()) + .arg("audit") + .arg("--json") + .arg(&path) + .output() + .expect("failed to run kodoc audit --json"); + + assert!( + output.status.success(), + "kodoc audit --json exited with non-zero status\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&stdout) + .expect("kodoc audit --json did not produce valid JSON"); +} + +#[test] +fn test_audit_policy_exits_nonzero_on_violation() { + let path = write_temp_ko(low_confidence_source(), "audit_policy_violation"); + let output = Command::new(get_kodoc_path()) + .arg("audit") + .arg("--policy") + .arg("min_confidence=0.99,contracts=all_verified") + .arg(&path) + .output() + .expect("failed to run kodoc audit --policy"); + + assert!( + !output.status.success(), + "expected kodoc audit to exit non-zero when policy min_confidence=0.99 is violated by a @confidence(0.5) function\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +// --------------------------------------------------------------------------- +// fix tests +// --------------------------------------------------------------------------- + +#[test] +fn test_fix_on_valid_file() { + let path = write_temp_ko(valid_source(), "fix_valid"); + let output = Command::new(get_kodoc_path()) + .arg("fix") + .arg(&path) + .output() + .expect("failed to run kodoc fix"); + + assert!( + output.status.success(), + "kodoc fix exited with non-zero status on a valid file\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn test_fix_dry_run_on_valid_file() { + // kodoc fix does not have a --json flag; use --dry-run to inspect patches + // without modifying the file. + let path = write_temp_ko(valid_source(), "fix_dry_run_valid"); + let output = Command::new(get_kodoc_path()) + .arg("fix") + .arg("--dry-run") + .arg(&path) + .output() + .expect("failed to run kodoc fix --dry-run"); + + assert!( + output.status.success(), + "kodoc fix --dry-run exited with non-zero status on a valid file\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} From c3f49d7db75d4806249b68056727166fd41e9726 Mon Sep 17 00:00:00 2001 From: rfunix Date: Wed, 1 Apr 2026 19:28:28 -0300 Subject: [PATCH 4/5] test: add 10 codegen instruction translation path tests Exercises BinOp (add, sub, mul, div, eq), StringConst assign, Call in body, multi-block with jump, BoolConst return, and local assign+return via MIR-level compile_module calls. Co-Authored-By: Claude Sonnet 4.6 --- crates/kodo_codegen/src/lib.rs | 262 +++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/crates/kodo_codegen/src/lib.rs b/crates/kodo_codegen/src/lib.rs index 3980d90..3596b7d 100644 --- a/crates/kodo_codegen/src/lib.rs +++ b/crates/kodo_codegen/src/lib.rs @@ -1811,6 +1811,268 @@ mod tests { assert_eq!(HeapKind::Set, HeapKind::Set); } + // --------------------------------------------------------------- + // instruction.rs translation path tests + // --------------------------------------------------------------- + + #[test] + fn compile_add_two_ints() { + let func = MirFunction { + name: "add_ints".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BinOp( + kodo_ast::BinOp::Add, + Box::new(Value::IntConst(10)), + Box::new(Value::IntConst(20)), + )), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "add two ints failed: {result:?}"); + } + + #[test] + fn compile_subtract_ints() { + let func = MirFunction { + name: "sub_ints".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BinOp( + kodo_ast::BinOp::Sub, + Box::new(Value::IntConst(100)), + Box::new(Value::IntConst(37)), + )), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "subtract ints failed: {result:?}"); + } + + #[test] + fn compile_multiply_ints() { + let func = MirFunction { + name: "mul_ints".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BinOp( + kodo_ast::BinOp::Mul, + Box::new(Value::IntConst(6)), + Box::new(Value::IntConst(7)), + )), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "multiply ints failed: {result:?}"); + } + + #[test] + fn compile_divide_ints() { + let func = MirFunction { + name: "div_ints".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BinOp( + kodo_ast::BinOp::Div, + Box::new(Value::IntConst(84)), + Box::new(Value::IntConst(2)), + )), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "divide ints failed: {result:?}"); + } + + #[test] + fn compile_compare_ints_eq() { + let func = MirFunction { + name: "compare_eq".to_string(), + return_type: Type::Bool, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BinOp( + kodo_ast::BinOp::Eq, + Box::new(Value::IntConst(42)), + Box::new(Value::IntConst(42)), + )), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!( + result.is_ok(), + "compare ints (Eq) with Bool return failed: {result:?}" + ); + } + + #[test] + fn compile_string_const_local() { + let func = MirFunction { + name: "str_local".to_string(), + return_type: Type::Unit, + param_count: 0, + locals: vec![Local { + id: LocalId(0), + ty: Type::String, + mutable: false, + }], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![Instruction::Assign( + LocalId(0), + Value::StringConst("hello".to_string()), + )], + terminator: Terminator::Return(Value::Unit), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!( + result.is_ok(), + "string const local assignment failed: {result:?}" + ); + } + + #[test] + fn compile_function_call_in_body() { + let printer = MirFunction { + name: "print_int".to_string(), + return_type: Type::Unit, + param_count: 1, + locals: vec![ + Local { + id: LocalId(0), + ty: Type::Int, + mutable: false, + }, + Local { + id: LocalId(1), + ty: Type::Unit, + mutable: false, + }, + ], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::Unit), + }], + entry: BlockId(0), + }; + let caller = MirFunction { + name: "call_print_int".to_string(), + return_type: Type::Unit, + param_count: 0, + locals: vec![Local { + id: LocalId(0), + ty: Type::Unit, + mutable: false, + }], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![Instruction::Call { + dest: LocalId(0), + callee: "print_int".to_string(), + args: vec![Value::IntConst(99)], + }], + terminator: Terminator::Return(Value::Unit), + }], + entry: BlockId(0), + }; + let result = compile_module(&[printer, caller], &CodegenOptions::default(), None); + assert!(result.is_ok(), "function call in body failed: {result:?}"); + } + + #[test] + fn compile_multiple_blocks_with_jump() { + let func = MirFunction { + name: "two_blocks".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![], + blocks: vec![ + BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Goto(BlockId(1)), + }, + BasicBlock { + id: BlockId(1), + instructions: vec![], + terminator: Terminator::Return(Value::IntConst(7)), + }, + ], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!( + result.is_ok(), + "multiple blocks with jump failed: {result:?}" + ); + } + + #[test] + fn compile_bool_const_return() { + let func = MirFunction { + name: "always_true".to_string(), + return_type: Type::Bool, + param_count: 0, + locals: vec![], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![], + terminator: Terminator::Return(Value::BoolConst(true)), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "bool const return failed: {result:?}"); + } + + #[test] + fn compile_local_assign_and_return() { + let func = MirFunction { + name: "pass_through".to_string(), + return_type: Type::Int, + param_count: 0, + locals: vec![Local { + id: LocalId(0), + ty: Type::Int, + mutable: false, + }], + blocks: vec![BasicBlock { + id: BlockId(0), + instructions: vec![Instruction::Assign(LocalId(0), Value::IntConst(55))], + terminator: Terminator::Return(Value::Local(LocalId(0))), + }], + entry: BlockId(0), + }; + let result = compile_module(&[func], &CodegenOptions::default(), None); + assert!(result.is_ok(), "local assign and return failed: {result:?}"); + } + // --------------------------------------------------------------- // CodegenOptions tests // --------------------------------------------------------------- From a7e2c6d966b249a93f2d1c3d88dcee866aafe31e Mon Sep 17 00:00:00 2001 From: rfunix Date: Tue, 7 Apr 2026 00:10:29 -0300 Subject: [PATCH 5/5] types: add trust identity verification to prevent LLM reviewer forgery Introduces E0263 and E0264 to detect when an AI agent attempts to forge a @reviewed_by(human: "...") annotation by naming itself as a human reviewer. A new [trust] section in kodo.toml configures two opt-in checks: - known_agents: list of agent names forbidden as human reviewers (E0263) - human_reviewers: allowlist of authorized reviewer identities (E0264) Both checks run case-insensitively at type-check time via a new TrustConfig struct threaded into TypeChecker via set_trust_config(). The kodoc check, build, audit, confidence-report, and mir commands all load trust config automatically from kodo.toml in the source file's parent directory. The audit command gains a new trust=verified policy criterion for CI/CD gating. FunctionAudit now exposes a reviewers field in JSON output. Adds 7 unit tests (E0263/E0264 cases), 4 manifest tests, 3 audit tests, and 3 UI tests (tests/ui/traceability/trust/) with a kodo.toml fixture. Co-Authored-By: Claude Sonnet 4.6 --- crates/kodo_types/src/checker.rs | 26 ++++ crates/kodo_types/src/confidence.rs | 102 +++++++++++- crates/kodo_types/src/errors.rs | 49 ++++++ crates/kodo_types/src/lib.rs | 1 + crates/kodo_types/src/tests/annotations.rs | 147 ++++++++++++++++++ crates/kodoc/src/audit.rs | 124 +++++++++++++++ crates/kodoc/src/commands/build.rs | 1 + crates/kodoc/src/commands/check.rs | 2 + crates/kodoc/src/commands/deps.rs | 1 + crates/kodoc/src/commands/init.rs | 1 + crates/kodoc/src/commands/misc.rs | 10 +- crates/kodoc/src/dep_resolver.rs | 3 + crates/kodoc/src/manifest.rs | 110 +++++++++++++ docs/error_index.md | 42 ++++- docs/guide/agent-traceability.md | 64 ++++++++ docs/guide/cli-reference.md | 1 + examples/trust_config.ko | 46 ++++++ tests/ui/traceability/trust/agent_forgery.ko | 16 ++ tests/ui/traceability/trust/kodo.toml | 6 + .../trust/reviewer_not_allowed.ko | 16 ++ tests/ui/traceability/trust/valid_reviewer.ko | 15 ++ 21 files changed, 780 insertions(+), 3 deletions(-) create mode 100644 examples/trust_config.ko create mode 100644 tests/ui/traceability/trust/agent_forgery.ko create mode 100644 tests/ui/traceability/trust/kodo.toml create mode 100644 tests/ui/traceability/trust/reviewer_not_allowed.ko create mode 100644 tests/ui/traceability/trust/valid_reviewer.ko diff --git a/crates/kodo_types/src/checker.rs b/crates/kodo_types/src/checker.rs index cd8ccaf..d97fbd6 100644 --- a/crates/kodo_types/src/checker.rs +++ b/crates/kodo_types/src/checker.rs @@ -144,6 +144,12 @@ pub struct TypeChecker { /// When calling an async function, the return type is automatically wrapped /// in `Future` so that `await` can unwrap it. pub(crate) async_fn_names: std::collections::HashSet, + /// Trust configuration for identity verification in `@reviewed_by` annotations. + /// + /// When populated from `kodo.toml`'s `[trust]` section, reviewer names are + /// cross-checked against known agents (E0263) and optional allowlists (E0264). + /// Defaults to empty lists, which disables identity checks entirely. + pub(crate) trust_config: crate::confidence::TrustConfig, } impl TypeChecker { @@ -190,11 +196,21 @@ impl TypeChecker { map_for_in_spans: Vec::new(), set_for_in_spans: Vec::new(), async_fn_names: std::collections::HashSet::new(), + trust_config: crate::confidence::TrustConfig::default(), }; checker.register_builtins(); checker } + /// Sets the trust configuration for annotation identity verification. + /// + /// Call this before `check_module` to enable forgery detection. The checker + /// will reject `@reviewed_by(human: "X")` if X appears in `known_agents` + /// (E0263) or is absent from `human_reviewers` when that list is non-empty (E0264). + pub fn set_trust_config(&mut self, config: crate::confidence::TrustConfig) { + self.trust_config = config; + } + /// Registers a module name as imported, enabling qualified calls like `mod.func()`. pub fn register_imported_module(&mut self, name: String) { self.imported_module_names.insert(name); @@ -799,6 +815,10 @@ impl TypeChecker { Self::check_annotation_policies(func)?; } + for func in &module.functions { + self.validate_reviewer_identity(func)?; + } + let min_confidence = module .meta .as_ref() @@ -1368,6 +1388,12 @@ impl TypeChecker { } } + for func in &module.functions { + if let Err(e) = self.validate_reviewer_identity(func) { + errors.push(e); + } + } + let min_confidence = module .meta .as_ref() diff --git a/crates/kodo_types/src/confidence.rs b/crates/kodo_types/src/confidence.rs index 7ce1408..78b67e6 100644 --- a/crates/kodo_types/src/confidence.rs +++ b/crates/kodo_types/src/confidence.rs @@ -2,13 +2,34 @@ //! //! Contains `compute_confidence`, `find_weakest_link`, `confidence_report`, //! `extract_confidence_value`, `has_human_review`, `check_annotation_policies`, -//! and `validate_trust_policy`. +//! `validate_trust_policy`, and `validate_reviewer_identity`. use crate::checker::TypeChecker; use crate::types::annotation_arg_expr; use crate::{Type, TypeError}; use kodo_ast::{Annotation, AnnotationArg, Expr, Function, Module}; +/// Configuration for trust identity verification. +/// +/// Loaded from the `[trust]` section of `kodo.toml` and threaded into +/// the type checker to prevent LLM forgery of `@reviewed_by` annotations. +/// +/// Both fields are opt-in: an empty `TrustConfig` (the default) performs no +/// identity checks, preserving full backward compatibility. +#[derive(Debug, Clone, Default)] +pub struct TrustConfig { + /// Names of known AI agents (e.g., `"claude"`, `"gpt-4"`, `"copilot"`). + /// + /// If a `@reviewed_by(human: "X")` annotation names X that appears here + /// (case-insensitive), it is a hard error (E0263). + pub known_agents: Vec, + /// Allowlist of valid human reviewer identifiers. + /// + /// When non-empty, any `@reviewed_by(human: "X")` where X is **not** in + /// this list (case-insensitive) produces a hard error (E0264). + pub human_reviewers: Vec, +} + impl TypeChecker { /// Computes the transitive confidence for a function by following its call graph. /// @@ -177,6 +198,85 @@ impl TypeChecker { Ok(()) } + + /// Validates `@reviewed_by` annotations against the trust configuration. + /// + /// Enforces two rules derived from `self.trust_config`: + /// 1. No reviewer name may match an entry in `known_agents` (E0263). + /// 2. If `human_reviewers` is non-empty, every reviewer must appear in it (E0264). + /// + /// When `trust_config` has empty lists (the default), this is a no-op — + /// backward compatibility is fully preserved. + pub(crate) fn validate_reviewer_identity(&self, func: &Function) -> crate::Result<()> { + let config = &self.trust_config; + if config.known_agents.is_empty() && config.human_reviewers.is_empty() { + return Ok(()); + } + + let reviewers = extract_human_reviewers(func); + for (reviewer, span) in &reviewers { + let reviewer_lower = reviewer.to_lowercase(); + + // Rule 1: reviewer must not be a known agent. + if config + .known_agents + .iter() + .any(|a| a.to_lowercase() == reviewer_lower) + { + return Err(TypeError::AgentClaimsHumanReview { + name: func.name.clone(), + reviewer: reviewer.clone(), + span: *span, + }); + } + + // Rule 2: reviewer must be in the allowlist (when configured). + if !config.human_reviewers.is_empty() + && !config + .human_reviewers + .iter() + .any(|h| h.to_lowercase() == reviewer_lower) + { + return Err(TypeError::ReviewerNotInAllowlist { + name: func.name.clone(), + reviewer: reviewer.clone(), + span: *span, + }); + } + } + + Ok(()) + } +} + +/// Extracts human reviewer names from `@reviewed_by` annotations on a function. +/// +/// Returns a vec of `(reviewer_name, span)` pairs for all `@reviewed_by` +/// annotations that specify a human reviewer, supporting both syntaxes: +/// - `@reviewed_by(human: "alice")` — named argument +/// - `@reviewed_by("human:alice")` — positional string with prefix +fn extract_human_reviewers(func: &Function) -> Vec<(String, kodo_ast::Span)> { + let mut result = Vec::new(); + for ann in &func.annotations { + if ann.name != "reviewed_by" { + continue; + } + for arg in &ann.args { + match arg { + AnnotationArg::Named(key, Expr::StringLit(value, _)) if key == "human" => { + result.push((value.clone(), ann.span)); + } + AnnotationArg::Positional(Expr::StringLit(value, _)) + if value.starts_with("human:") => + { + let reviewer = value.trim_start_matches("human:").to_string(); + result.push((reviewer, ann.span)); + } + _ => {} + } + } + } + result } /// Validates trust policy constraints on a function's annotations. diff --git a/crates/kodo_types/src/errors.rs b/crates/kodo_types/src/errors.rs index f878883..97cb8ad 100644 --- a/crates/kodo_types/src/errors.rs +++ b/crates/kodo_types/src/errors.rs @@ -356,6 +356,34 @@ pub enum TypeError { /// Source location of the function. span: Span, }, + /// A `@reviewed_by(human: "X")` annotation names a known AI agent. + /// + /// This prevents LLM agents from forging human review annotations. + /// The reviewer name matched an entry in `[trust].known_agents` from `kodo.toml`. + /// Use `@reviewed_by(agent: "X")` to attribute the review to an agent instead. + #[error("function `{name}`: reviewer `{reviewer}` is a known AI agent and cannot claim human review at {span:?}")] + AgentClaimsHumanReview { + /// The function name. + name: String, + /// The reviewer name that matched a known agent. + reviewer: String, + /// Source location of the annotation. + span: Span, + }, + /// A `@reviewed_by(human: "X")` annotation names a reviewer not in the allowlist. + /// + /// When `[trust].human_reviewers` is configured in `kodo.toml`, only listed + /// reviewers are accepted. This prevents unauthorized or unknown reviewers + /// from satisfying the review requirement. + #[error("function `{name}`: reviewer `{reviewer}` is not in the `human_reviewers` allowlist at {span:?}")] + ReviewerNotInAllowlist { + /// The function name. + name: String, + /// The reviewer name that was not found in the allowlist. + reviewer: String, + /// Source location of the annotation. + span: Span, + }, /// A variable was used after its ownership was moved. /// /// Once a value is moved (e.g. passed to a function taking `own`), @@ -548,6 +576,8 @@ impl TypeError { | Self::LowConfidenceWithoutReview { span, .. } | Self::ConfidenceThreshold { span, .. } | Self::SecuritySensitiveWithoutContract { span, .. } + | Self::AgentClaimsHumanReview { span, .. } + | Self::ReviewerNotInAllowlist { span, .. } | Self::UseAfterMove { span, .. } | Self::MutBorrowWhileRefBorrowed { span, .. } | Self::RefBorrowWhileMutBorrowed { span, .. } @@ -607,6 +637,8 @@ impl TypeError { Self::LowConfidenceWithoutReview { .. } => "E0260", Self::ConfidenceThreshold { .. } => "E0261", Self::SecuritySensitiveWithoutContract { .. } => "E0262", + Self::AgentClaimsHumanReview { .. } => "E0263", + Self::ReviewerNotInAllowlist { .. } => "E0264", Self::UseAfterMove { .. } => "E0240", Self::BorrowEscapesScope { .. } => "E0241", Self::MoveWhileBorrowed { .. } => "E0242", @@ -920,6 +952,15 @@ fn fix_patch_meta_and_policy(err: &TypeError) -> Option { end_offset: span.start as usize, replacement: format!("@confidence({threshold})\n "), }), + TypeError::AgentClaimsHumanReview { reviewer, span, .. } => Some(kodo_ast::FixPatch { + description: format!( + "replace @reviewed_by(human: \"{reviewer}\") with @reviewed_by(agent: \"{reviewer}\")" + ), + file: String::new(), + start_offset: span.start as usize, + end_offset: span.end as usize, + replacement: format!("@reviewed_by(agent: \"{reviewer}\")"), + }), _ => None, } } @@ -1628,6 +1669,14 @@ fn suggestion_for_policy_error(err: &TypeError) -> Option { TypeError::SecuritySensitiveWithoutContract { name, .. } => Some(format!( "add `requires {{ ... }}` or `ensures {{ ... }}` to function `{name}`" )), + TypeError::AgentClaimsHumanReview { reviewer, .. } => Some(format!( + "change `@reviewed_by(human: \"{reviewer}\")` to `@reviewed_by(agent: \"{reviewer}\")`, \ + or remove the annotation — AI agents cannot claim human review" + )), + TypeError::ReviewerNotInAllowlist { reviewer, .. } => Some(format!( + "add `\"{reviewer}\"` to `[trust].human_reviewers` in `kodo.toml`, \ + or use a reviewer already in the allowlist" + )), TypeError::InvariantNotBool { .. } => { Some("invariant conditions must evaluate to `Bool`".to_string()) } diff --git a/crates/kodo_types/src/lib.rs b/crates/kodo_types/src/lib.rs index e7f3823..7379303 100644 --- a/crates/kodo_types/src/lib.rs +++ b/crates/kodo_types/src/lib.rs @@ -46,6 +46,7 @@ mod stmt; mod types; pub use checker::TypeChecker; +pub use confidence::TrustConfig; pub use errors::{Result, TypeError}; pub use repair::{RepairPlan, RepairStep}; pub use types::{resolve_type, resolve_type_with_enums, TypeEnv}; diff --git a/crates/kodo_types/src/tests/annotations.rs b/crates/kodo_types/src/tests/annotations.rs index ab6df3e..e626ba3 100644 --- a/crates/kodo_types/src/tests/annotations.rs +++ b/crates/kodo_types/src/tests/annotations.rs @@ -350,3 +350,150 @@ fn confidence_threshold_violation() { let err = result.unwrap_err(); assert_eq!(err.code(), "E0261"); } + +// ===== Trust Identity Verification Tests (E0263, E0264) ===== + +fn make_reviewed_by_fn(reviewer_key: &str, reviewer_value: &str) -> kodo_ast::Function { + make_function_with_annotations( + "reviewed_fn", + vec![Annotation { + name: "reviewed_by".to_string(), + args: vec![AnnotationArg::Named( + reviewer_key.to_string(), + Expr::StringLit(reviewer_value.to_string(), Span::new(0, 10)), + )], + span: Span::new(0, 40), + }], + ) +} + +#[test] +fn empty_trust_config_passes_any_reviewer() { + let func = make_reviewed_by_fn("human", "claude"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + // No trust config set — should pass (backward compat). + let result = checker.check_module(&module); + assert!( + result.is_ok(), + "empty trust config should allow any reviewer: {result:?}" + ); +} + +#[test] +fn agent_claims_human_review_emits_e0263() { + let func = make_reviewed_by_fn("human", "claude"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec!["claude".to_string()], + human_reviewers: vec![], + }); + let result = checker.check_module(&module); + assert!( + result.is_err(), + "agent name in @reviewed_by(human: ...) should be rejected" + ); + let err = result.unwrap_err(); + assert_eq!(err.code(), "E0263"); +} + +#[test] +fn agent_claims_human_review_case_insensitive_e0263() { + let func = make_reviewed_by_fn("human", "Claude"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec!["claude".to_string()], + human_reviewers: vec![], + }); + let result = checker.check_module(&module); + assert!( + result.is_err(), + "case-insensitive agent name should still be rejected" + ); + let err = result.unwrap_err(); + assert_eq!(err.code(), "E0263"); +} + +#[test] +fn reviewer_not_in_allowlist_emits_e0264() { + let func = make_reviewed_by_fn("human", "bob"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec![], + human_reviewers: vec!["alice".to_string()], + }); + let result = checker.check_module(&module); + assert!( + result.is_err(), + "reviewer not in allowlist should be rejected" + ); + let err = result.unwrap_err(); + assert_eq!(err.code(), "E0264"); +} + +#[test] +fn reviewer_in_allowlist_passes() { + let func = make_reviewed_by_fn("human", "alice"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec!["claude".to_string(), "gpt-4".to_string()], + human_reviewers: vec!["alice".to_string(), "bob".to_string()], + }); + let result = checker.check_module(&module); + assert!( + result.is_ok(), + "reviewer in allowlist should pass: {result:?}" + ); +} + +#[test] +fn agent_match_takes_priority_over_allowlist_e0263() { + // "claude" is both in known_agents and human_reviewers — agent check fires first. + let func = make_reviewed_by_fn("human", "claude"); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec!["claude".to_string()], + human_reviewers: vec!["claude".to_string(), "alice".to_string()], + }); + let result = checker.check_module(&module); + assert!( + result.is_err(), + "agent name should be rejected even if in allowlist" + ); + let err = result.unwrap_err(); + assert_eq!(err.code(), "E0263"); +} + +#[test] +fn positional_human_prefix_syntax_detected_e0263() { + // @reviewed_by("human:claude") positional syntax. + let func = make_function_with_annotations( + "fn_pos", + vec![Annotation { + name: "reviewed_by".to_string(), + args: vec![AnnotationArg::Positional(Expr::StringLit( + "human:claude".to_string(), + Span::new(0, 10), + ))], + span: Span::new(0, 40), + }], + ); + let module = make_module_with_policy(vec![func], None); + let mut checker = TypeChecker::new(); + checker.set_trust_config(crate::TrustConfig { + known_agents: vec!["claude".to_string()], + human_reviewers: vec![], + }); + let result = checker.check_module(&module); + assert!( + result.is_err(), + "positional human:X syntax should also be checked" + ); + let err = result.unwrap_err(); + assert_eq!(err.code(), "E0263"); +} diff --git a/crates/kodoc/src/audit.rs b/crates/kodoc/src/audit.rs index 07136ed..877b0da 100644 --- a/crates/kodoc/src/audit.rs +++ b/crates/kodoc/src/audit.rs @@ -54,6 +54,8 @@ pub struct FunctionAudit { pub requires_count: usize, /// Number of `ensures` clauses. pub ensures_count: usize, + /// Human reviewer names extracted from `@reviewed_by(human: "...")` annotations. + pub reviewers: Vec, } /// Builds an [`AuditReport`] from module data, confidence report, and verification stats. @@ -87,6 +89,8 @@ pub fn build_audit_report( annotations.insert(ann.name.clone(), annotation_to_json(ann)); } + let reviewers = extract_human_reviewers_audit(func); + functions.push(FunctionAudit { name: func.name.clone(), confidence_declared: declared, @@ -94,6 +98,7 @@ pub fn build_audit_report( annotations, requires_count: func.requires.len(), ensures_count: func.ensures.len(), + reviewers, }); } @@ -120,6 +125,32 @@ pub fn build_audit_report( } } +/// Extracts human reviewer names from `@reviewed_by` annotations for audit reporting. +fn extract_human_reviewers_audit(func: &kodo_ast::Function) -> Vec { + let mut reviewers = Vec::new(); + for ann in &func.annotations { + if ann.name != "reviewed_by" { + continue; + } + for arg in &ann.args { + match arg { + kodo_ast::AnnotationArg::Named(key, kodo_ast::Expr::StringLit(value, _)) + if key == "human" => + { + reviewers.push(value.clone()); + } + kodo_ast::AnnotationArg::Positional(kodo_ast::Expr::StringLit(value, _)) + if value.starts_with("human:") => + { + reviewers.push(value.trim_start_matches("human:").to_string()); + } + _ => {} + } + } + } + reviewers +} + /// Converts an annotation to a JSON value. fn annotation_to_json(ann: &kodo_ast::Annotation) -> serde_json::Value { if ann.args.is_empty() { @@ -172,6 +203,10 @@ pub enum PolicyCriterion { ContractsAllPresent, /// All functions must carry a `@reviewed_by` annotation. ReviewedAll, + /// All `@reviewed_by(human: "X")` reviewer names must not appear in the + /// provided list of known agents (case-insensitive). Requires the agent + /// list to be supplied via [`validate_policy_with_trust`]. + TrustVerified, } /// Result of validating an [`AuditReport`] against a set of policy criteria. @@ -236,6 +271,14 @@ pub fn parse_policy(policy: &str) -> Result, String> { )); } }, + "trust" => match value { + "verified" => criteria.push(PolicyCriterion::TrustVerified), + _ => { + return Err(format!( + "unknown trust policy value: `{value}` (expected `verified`)" + )); + } + }, _ => { return Err(format!("unknown policy key: `{key}`")); } @@ -256,7 +299,24 @@ pub fn parse_policy(policy: &str) -> Result, String> { /// /// Returns a [`PolicyResult`] indicating whether all criteria passed and /// listing any violations found. +/// +/// For `TrustVerified` checks, pass an empty `known_agents` slice — use +/// [`validate_policy_with_trust`] when you have a trust configuration available. +// Used in tests and as a convenience API. +#[allow(dead_code)] pub fn validate_policy(report: &AuditReport, criteria: &[PolicyCriterion]) -> PolicyResult { + validate_policy_with_trust(report, criteria, &[]) +} + +/// Validates an [`AuditReport`] against criteria, with trust identity checking. +/// +/// `known_agents` is a list of agent names (case-insensitive) that are not +/// permitted as human reviewers. Used by `TrustVerified` policy checks. +pub fn validate_policy_with_trust( + report: &AuditReport, + criteria: &[PolicyCriterion], + known_agents: &[String], +) -> PolicyResult { let mut violations = Vec::new(); for criterion in criteria { @@ -318,6 +378,24 @@ pub fn validate_policy(report: &AuditReport, criteria: &[PolicyCriterion]) -> Po } } } + PolicyCriterion::TrustVerified => { + for func in &report.functions { + for reviewer in &func.reviewers { + let reviewer_lower = reviewer.to_lowercase(); + if known_agents + .iter() + .any(|a| a.to_lowercase() == reviewer_lower) + { + violations.push(PolicyViolation { + criterion: "trust=verified".to_string(), + function: func.name.clone(), + expected: "human reviewer (not a known agent)".to_string(), + actual: format!("`{reviewer}` is a known AI agent"), + }); + } + } + } + } } } @@ -616,6 +694,52 @@ mod tests { assert!(json_str.contains("\"criterion\":\"min_confidence\"")); } + #[test] + fn parse_policy_trust_verified() { + let criteria = parse_policy("trust=verified").unwrap(); + assert_eq!(criteria, vec![PolicyCriterion::TrustVerified]); + } + + #[test] + fn parse_policy_trust_unknown_value() { + let result = parse_policy("trust=invalid"); + assert!(result.is_err()); + } + + #[test] + fn trust_verified_no_agents_always_passes() { + // Without known_agents, TrustVerified does nothing. + let report = build_audit_report( + &make_test_module_reviewed(), + &[("greet".to_string(), 0.9, 0.9, vec![])], + 0, + 0, + 0, + ); + let result = validate_policy_with_trust(&report, &[PolicyCriterion::TrustVerified], &[]); + assert!(result.passed); + } + + #[test] + fn trust_verified_catches_agent_reviewer() { + // Build a module where "greet" has @reviewed_by(human: "claude"). + let report = build_audit_report( + &make_test_module_reviewed(), + &[("greet".to_string(), 0.9, 0.9, vec![])], + 0, + 0, + 0, + ); + // The make_test_module_reviewed uses "rfunix" — should pass with claude as agent. + let result = validate_policy_with_trust( + &report, + &[PolicyCriterion::TrustVerified], + &["claude".to_string()], + ); + // "rfunix" != "claude" so should still pass. + assert!(result.passed, "rfunix is not a known agent, should pass"); + } + fn make_test_module_no_contracts() -> kodo_ast::Module { use kodo_ast::{Ownership, *}; Module { diff --git a/crates/kodoc/src/commands/build.rs b/crates/kodoc/src/commands/build.rs index c6299bc..8a047bb 100644 --- a/crates/kodoc/src/commands/build.rs +++ b/crates/kodoc/src/commands/build.rs @@ -129,6 +129,7 @@ pub(crate) fn run_build( // Type check -- register prelude, imports, then user module. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for prelude in &prelude_modules { if let Err(e) = checker.check_module(prelude) { eprintln!("stdlib type error: {e}"); diff --git a/crates/kodoc/src/commands/check.rs b/crates/kodoc/src/commands/check.rs index 98fe37c..378ee8f 100644 --- a/crates/kodoc/src/commands/check.rs +++ b/crates/kodoc/src/commands/check.rs @@ -78,6 +78,7 @@ pub(crate) fn run_check( // Load stdlib prelude for type checking. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for (_name, prelude_source) in kodo_std::prelude_sources() { if let Ok(prelude_mod) = kodo_parser::parse(prelude_source) { let _ = checker.check_module(&prelude_mod); @@ -255,6 +256,7 @@ pub(crate) fn run_fix(file: &PathBuf, dry_run: bool) -> i32 { // Try type checking. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for (_name, prelude_source) in kodo_std::prelude_sources() { if let Ok(prelude_mod) = kodo_parser::parse(prelude_source) { let _ = checker.check_module(&prelude_mod); diff --git a/crates/kodoc/src/commands/deps.rs b/crates/kodoc/src/commands/deps.rs index b3df82e..6fd885a 100644 --- a/crates/kodoc/src/commands/deps.rs +++ b/crates/kodoc/src/commands/deps.rs @@ -256,6 +256,7 @@ mod tests { module: "test-project".to_string(), version: "0.1.0".to_string(), deps: HashMap::new(), + trust: None, }; manifest::write_manifest(&tmp, &man).unwrap(); diff --git a/crates/kodoc/src/commands/init.rs b/crates/kodoc/src/commands/init.rs index e359ad1..d647cd4 100644 --- a/crates/kodoc/src/commands/init.rs +++ b/crates/kodoc/src/commands/init.rs @@ -54,6 +54,7 @@ pub(crate) fn run_init(name: Option<&str>) -> i32 { module: module_name.clone(), version: "0.1.0".to_string(), deps: HashMap::new(), + trust: None, }; if let Err(e) = write_manifest(&project_dir, &manifest) { diff --git a/crates/kodoc/src/commands/misc.rs b/crates/kodoc/src/commands/misc.rs index b6aaafe..857fabf 100644 --- a/crates/kodoc/src/commands/misc.rs +++ b/crates/kodoc/src/commands/misc.rs @@ -302,6 +302,7 @@ pub(crate) fn run_confidence_report(file: &PathBuf, json: bool, threshold: f64) // Load stdlib prelude for type checking. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for (_name, prelude_source) in kodo_std::prelude_sources() { if let Ok(prelude_mod) = kodo_parser::parse(prelude_source) { let _ = checker.check_module(&prelude_mod); @@ -458,6 +459,7 @@ pub(crate) fn run_audit( // Load stdlib prelude for type checking. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for (_name, prelude_source) in kodo_std::prelude_sources() { if let Ok(prelude_mod) = kodo_parser::parse(prelude_source) { let _ = checker.check_module(&prelude_mod); @@ -516,9 +518,14 @@ pub(crate) fn run_audit( total_failures, ); + // Load trust config for policy trust=verified checks. + let trust_config = crate::manifest::load_trust_config(file); + // Validate policy if specified. let policy_result = policy.map(|p| match audit::parse_policy(p) { - Ok(criteria) => audit::validate_policy(&report, &criteria), + Ok(criteria) => { + audit::validate_policy_with_trust(&report, &criteria, &trust_config.known_agents) + } Err(e) => { eprintln!("policy parse error: {e}"); // Return a failed result so we exit with code 1. @@ -845,6 +852,7 @@ pub(crate) fn run_mir(file: &PathBuf, contracts_mode_str: &str) -> i32 { // Load stdlib prelude for type checking. let mut checker = kodo_types::TypeChecker::new(); + checker.set_trust_config(crate::manifest::load_trust_config(file)); for (_name, prelude_source) in kodo_std::prelude_sources() { if let Ok(prelude_mod) = kodo_parser::parse(prelude_source) { let _ = checker.check_module(&prelude_mod); diff --git a/crates/kodoc/src/dep_resolver.rs b/crates/kodoc/src/dep_resolver.rs index 28e0a52..6b11e41 100644 --- a/crates/kodoc/src/dep_resolver.rs +++ b/crates/kodoc/src/dep_resolver.rs @@ -326,6 +326,7 @@ mod tests { path: "my-dep".to_string(), }, )]), + trust: None, }; let (resolved, lockfile) = resolve_deps(&manifest, &tmp).unwrap(); @@ -350,6 +351,7 @@ mod tests { path: "/absolutely/nonexistent/path".to_string(), }, )]), + trust: None, }; let result = resolve_deps(&manifest, Path::new("/tmp")); @@ -377,6 +379,7 @@ mod tests { path: "my-dep".to_string(), }, )]), + trust: None, }; let (resolved, _) = resolve_deps(&manifest, &tmp).unwrap(); diff --git a/crates/kodoc/src/manifest.rs b/crates/kodoc/src/manifest.rs index e21800c..8d7abd8 100644 --- a/crates/kodoc/src/manifest.rs +++ b/crates/kodoc/src/manifest.rs @@ -8,6 +8,36 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; +/// Trust configuration section from `kodo.toml`. +/// +/// Controls identity verification for `@reviewed_by` annotations, +/// preventing LLM agents from forging human review claims. +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub(crate) struct TrustSection { + /// Names of known AI agents (e.g., `"claude"`, `"gpt-4"`, `"copilot"`). + /// + /// Any `@reviewed_by(human: "X")` where X appears here (case-insensitive) + /// is rejected at compile time (E0263). + #[serde(default)] + pub known_agents: Vec, + /// Allowlist of valid human reviewer identifiers. + /// + /// When non-empty, any `@reviewed_by(human: "X")` where X is not in this + /// list (case-insensitive) is rejected at compile time (E0264). + #[serde(default)] + pub human_reviewers: Vec, +} + +impl TrustSection { + /// Converts this manifest section into a `TrustConfig` for the type checker. + pub(crate) fn into_trust_config(self) -> kodo_types::TrustConfig { + kodo_types::TrustConfig { + known_agents: self.known_agents, + human_reviewers: self.human_reviewers, + } + } +} + /// Represents the contents of a `kodo.toml` manifest file. #[derive(Debug, Deserialize, Serialize)] pub(crate) struct Manifest { @@ -18,6 +48,12 @@ pub(crate) struct Manifest { /// Dependencies, keyed by name. #[serde(default)] pub deps: HashMap, + /// Trust configuration for reviewer identity verification. + /// + /// When present, the compiler cross-checks `@reviewed_by(human: "X")` annotations + /// against known agents and optional allowlists to prevent LLM forgery. + #[serde(default)] + pub trust: Option, } /// A single dependency specification. @@ -40,6 +76,20 @@ pub(crate) enum Dependency { }, } +/// Loads trust configuration for a source file by looking for `kodo.toml`. +/// +/// Searches only the immediate parent directory of `source_file`. Returns +/// `TrustConfig::default()` (no validation) if no `kodo.toml` exists or +/// if it has no `[trust]` section. +pub(crate) fn load_trust_config(source_file: &Path) -> kodo_types::TrustConfig { + let dir = source_file.parent().unwrap_or(Path::new(".")); + read_manifest(dir) + .ok() + .and_then(|m| m.trust) + .map(TrustSection::into_trust_config) + .unwrap_or_default() +} + /// Reads and parses a `kodo.toml` manifest from the given directory. /// /// Returns an error string if the file does not exist or cannot be parsed. @@ -123,6 +173,7 @@ version = "0.1.0" path: "../utils".to_string(), }, )]), + trust: None, }; let serialized = toml::to_string_pretty(&manifest).unwrap(); let deserialized: Manifest = toml::from_str(&serialized).unwrap(); @@ -144,10 +195,69 @@ version = "0.1.0" module: "test-project".to_string(), version: "0.1.0".to_string(), deps: HashMap::new(), + trust: None, }; write_manifest(&tmp, &manifest).unwrap(); let read_back = read_manifest(&tmp).unwrap(); assert_eq!(read_back.module, "test-project"); let _ = std::fs::remove_dir_all(&tmp); } + + #[test] + fn parse_manifest_with_trust_section() { + let toml_str = r#" +module = "secure-app" +version = "1.0.0" + +[trust] +known_agents = ["claude", "gpt-4", "copilot"] +human_reviewers = ["alice", "bob"] +"#; + let manifest: Manifest = toml::from_str(toml_str).unwrap(); + let trust = manifest.trust.expect("trust section should be present"); + assert_eq!(trust.known_agents, vec!["claude", "gpt-4", "copilot"]); + assert_eq!(trust.human_reviewers, vec!["alice", "bob"]); + } + + #[test] + fn parse_manifest_without_trust_section_backward_compat() { + let toml_str = r#" +module = "legacy-app" +version = "0.1.0" +"#; + let manifest: Manifest = toml::from_str(toml_str).unwrap(); + assert!( + manifest.trust.is_none(), + "missing [trust] section should yield None" + ); + } + + #[test] + fn parse_manifest_with_partial_trust_only_agents() { + let toml_str = r#" +module = "app" +version = "0.1.0" + +[trust] +known_agents = ["gemini"] +"#; + let manifest: Manifest = toml::from_str(toml_str).unwrap(); + let trust = manifest.trust.expect("trust section should be present"); + assert_eq!(trust.known_agents, vec!["gemini"]); + assert!( + trust.human_reviewers.is_empty(), + "human_reviewers should default to empty" + ); + } + + #[test] + fn trust_section_into_trust_config() { + let section = TrustSection { + known_agents: vec!["claude".to_string()], + human_reviewers: vec!["rfunix".to_string()], + }; + let config = section.into_trust_config(); + assert_eq!(config.known_agents, vec!["claude"]); + assert_eq!(config.human_reviewers, vec!["rfunix"]); + } } diff --git a/docs/error_index.md b/docs/error_index.md index 4d3b514..1b24526 100644 --- a/docs/error_index.md +++ b/docs/error_index.md @@ -503,6 +503,44 @@ error[E0262]: function `process_input` is marked `@security_sensitive` but has n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ add `requires { ... }` or `ensures { ... }` to function `process_input` ``` +### E0263: Agent Claims Human Review +A `@reviewed_by(human: "X")` annotation names an AI agent that appears in `[trust].known_agents` in `kodo.toml`. AI agents cannot claim human review status — use `@reviewed_by(agent: "X")` instead. + +``` +error[E0263]: function `process_payment`: reviewer `claude` is a known AI agent and cannot claim human review + --> src/main.ko:5:1 + | + 5 | @reviewed_by(human: "claude") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ change to @reviewed_by(agent: "claude") +``` + +**Fix**: Change `@reviewed_by(human: "claude")` to `@reviewed_by(agent: "claude")` — or have an actual human review the function. + +**Configuration**: Add `known_agents` to `kodo.toml`: +```toml +[trust] +known_agents = ["claude", "gpt-4", "copilot", "gemini"] +``` + +### E0264: Reviewer Not in Allowlist +A `@reviewed_by(human: "X")` annotation names a reviewer not present in `[trust].human_reviewers` in `kodo.toml`. When an allowlist is configured, only listed reviewers are accepted. + +``` +error[E0264]: function `process_payment`: reviewer `unknown_person` is not in the `human_reviewers` allowlist + --> src/main.ko:5:1 + | + 5 | @reviewed_by(human: "unknown_person") + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ add "unknown_person" to [trust].human_reviewers in kodo.toml +``` + +**Fix**: Add the reviewer to `kodo.toml`, or replace with an authorized reviewer. + +**Configuration**: Add `human_reviewers` to `kodo.toml`: +```toml +[trust] +human_reviewers = ["rfunix", "alice", "bob"] +``` + ### E0281: Closure Capture After Move A closure captures a variable that has already been moved. Once a variable is moved (e.g., into another closure or by assignment), it cannot be captured again. @@ -681,7 +719,7 @@ Every type error variant (42 of 43) now includes a machine-applicable `fix_patch | Category | Variants | Coverage | |----------|----------|----------| -| Meta & policy (E0210-E0212, E0260-E0262) | 6 | 6/6 (100%) | +| Meta & policy (E0210-E0212, E0260-E0264) | 8 | 8/8 (100%) | | Name resolution with similar (E0201, E0215, E0217, E0219, E0235) | 5 | 5/5 (100%) | | Name resolution without similar | 5 | 5/5 (100%) | | Struct/enum/trait definitions (E0213, E0218, E0222, E0230, E0232) | 5 | 5/5 (100%) | @@ -704,6 +742,8 @@ For complex errors requiring multiple steps, `repair_plan()` returns a sequence - `E0231` (Missing trait method) — add method stub + implement body - `E0232` (Trait bound not satisfied) — add impl block + implement methods - `E0262` (Security-sensitive without contract) — add contract blocks + specify invariants +- `E0263` (Agent claims human review) — change `human:` to `agent:` in the annotation +- `E0264` (Reviewer not in allowlist) — add reviewer to `[trust].human_reviewers` in `kodo.toml` ## JSON Error Format diff --git a/docs/guide/agent-traceability.md b/docs/guide/agent-traceability.md index ffc131a..d6feb00 100644 --- a/docs/guide/agent-traceability.md +++ b/docs/guide/agent-traceability.md @@ -123,3 +123,67 @@ For JSON output (suitable for AI agent consumption): ```bash kodoc confidence-report my_module.ko --json ``` + +## Preventing Reviewer Forgery + +A fundamental challenge with AI-generated code: an LLM could write `@reviewed_by(human: "alice")` in source code, bypassing trust enforcement. Kōdo addresses this with **trust identity verification** via `kodo.toml`. + +### Configuring the `[trust]` Section + +Add a `[trust]` section to your project's `kodo.toml`: + +```toml +[trust] +# Names of known AI agents — never permitted as human reviewers. +known_agents = ["claude", "gpt-4", "copilot", "gemini"] + +# Optional: allowlist of authorized human reviewers. +# When set, only listed names are accepted in @reviewed_by(human: "..."). +human_reviewers = ["alice", "bob", "rfunix"] +``` + +Both fields are **opt-in**. A project without `[trust]` behaves exactly as before. + +### What Gets Rejected + +**E0263 — Agent claims human review**: If a function has `@reviewed_by(human: "claude")` and `"claude"` is in `known_agents`, the compiler rejects it: + +``` +error[E0263]: function `process_payment`: reviewer `claude` is a known AI agent + and cannot claim human review + --> src/main.ko:5:1 +``` + +The auto-fix changes `human:` to `agent:`. + +**E0264 — Reviewer not in allowlist**: If `human_reviewers` is set and the reviewer is not listed: + +``` +error[E0264]: function `process_payment`: reviewer `unknown` is not in the + `human_reviewers` allowlist + --> src/main.ko:5:1 +``` + +### Combining with `kodoc audit` + +Use `trust=verified` in the audit policy for CI/CD gating: + +```bash +kodoc audit src/main.ko \ + --policy "min_confidence=0.8,reviewed=all,trust=verified" +``` + +This exits with code 1 if any `@reviewed_by(human: "X")` annotation names a known agent (uses `known_agents` from `kodo.toml`). + +### Security Model + +Kōdo's trust config makes reviewer forgery **detectable and costly**, but it relies on a root of trust external to the compiler: + +| Mechanism | Protection | Limitation | +|-----------|-----------|------------| +| `known_agents` allowlist | Rejects obvious LLM identity strings | Gameable if agent uses an unlisted name | +| `human_reviewers` allowlist | Restricts valid reviewer identities | Gameable if attacker controls `kodo.toml` | +| CI git-blame check | Detects commits from bot accounts | Requires protected CI pipeline | +| Cryptographic signatures (roadmap) | Cryptographically binds review to a GPG key | Requires key management | + +The recommended production setup: configure both lists in `kodo.toml`, protect the `kodo.toml` via branch protection rules, and add a CI step that `git blame`-verifies that `@reviewed_by` annotations were committed by a human account. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 863df8d..0d28650 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -442,6 +442,7 @@ The `--policy` flag accepts a comma-separated list of criteria: | `contracts=all_verified` | All contracts must be statically verified by Z3 | | `contracts=all_present` | Every function must have at least one contract | | `reviewed=all` | Every function must have a `@reviewed_by` annotation | +| `trust=verified` | No `@reviewed_by(human: "X")` may name a known agent (from `[trust].known_agents` in `kodo.toml`) | **Example:** diff --git a/examples/trust_config.ko b/examples/trust_config.ko new file mode 100644 index 0000000..35fd4ba --- /dev/null +++ b/examples/trust_config.ko @@ -0,0 +1,46 @@ +// Demonstrates Kōdo's trust identity verification via kodo.toml. +// +// The [trust] section in kodo.toml prevents AI agents from forging +// @reviewed_by(human: "...") annotations. When configured: +// - known_agents: agent names cannot appear as human reviewers (E0263) +// - human_reviewers: only listed names are accepted as human reviewers (E0264) +// +// This example runs WITHOUT a kodo.toml [trust] section, so no identity +// checks are performed. See examples/trust_config/kodo.toml for a +// project with trust enforcement enabled. +// +// Compile and run: +// kodoc build examples/trust_config.ko -o trust_config +// ./trust_config + +module trust_config { + meta { + purpose: "Demonstrate trust identity verification for reviewer annotations", + version: "0.1.0" + } + + // High-confidence function: no review needed. + @authored_by(agent: "claude") + @confidence(0.95) + fn compute_score(x: Int) -> Int { + return x * 10 + } + + // Low-confidence function reviewed by a human. + // With [trust].known_agents = ["claude"] in kodo.toml, writing + // @reviewed_by(human: "claude") here would be rejected as E0263. + @authored_by(agent: "claude") + @confidence(0.5) + @reviewed_by(human: "alice") + fn experimental_feature(x: Int) -> Int { + return x + 1 + } + + fn main() { + let score: Int = compute_score(7) + print_int(score) + + let result: Int = experimental_feature(42) + print_int(result) + } +} diff --git a/tests/ui/traceability/trust/agent_forgery.ko b/tests/ui/traceability/trust/agent_forgery.ko new file mode 100644 index 0000000..5b0c4b3 --- /dev/null +++ b/tests/ui/traceability/trust/agent_forgery.ko @@ -0,0 +1,16 @@ +//@ compile-fail +//@ error-code: E0263 +module agent_forgery { + meta { purpose: "LLM forging human review should be rejected" } + + @confidence(0.5) + @reviewed_by(human: "claude") + fn sensitive_fn(x: Int) -> Int { + return x + } + + fn main() { + let result: Int = sensitive_fn(1) + print_int(result) + } +} diff --git a/tests/ui/traceability/trust/kodo.toml b/tests/ui/traceability/trust/kodo.toml new file mode 100644 index 0000000..aad4e2e --- /dev/null +++ b/tests/ui/traceability/trust/kodo.toml @@ -0,0 +1,6 @@ +module = "trust-tests" +version = "0.1.0" + +[trust] +known_agents = ["claude", "gpt-4", "copilot", "gemini"] +human_reviewers = ["rfunix", "alice", "bob"] diff --git a/tests/ui/traceability/trust/reviewer_not_allowed.ko b/tests/ui/traceability/trust/reviewer_not_allowed.ko new file mode 100644 index 0000000..0065e40 --- /dev/null +++ b/tests/ui/traceability/trust/reviewer_not_allowed.ko @@ -0,0 +1,16 @@ +//@ compile-fail +//@ error-code: E0264 +module reviewer_not_allowed { + meta { purpose: "Unlisted reviewer should be rejected when allowlist is configured" } + + @confidence(0.5) + @reviewed_by(human: "unknown_person") + fn verified_fn(x: Int) -> Int { + return x + } + + fn main() { + let result: Int = verified_fn(1) + print_int(result) + } +} diff --git a/tests/ui/traceability/trust/valid_reviewer.ko b/tests/ui/traceability/trust/valid_reviewer.ko new file mode 100644 index 0000000..b8ac771 --- /dev/null +++ b/tests/ui/traceability/trust/valid_reviewer.ko @@ -0,0 +1,15 @@ +//@ check-pass +module valid_reviewer { + meta { purpose: "Authorized human reviewer should pass trust verification" } + + @confidence(0.5) + @reviewed_by(human: "alice") + fn verified_fn(x: Int) -> Int { + return x + } + + fn main() { + let result: Int = verified_fn(1) + print_int(result) + } +}