Layer E + G. Role TOML gains extends/relaxes for parent-role composition; agent spawn gets self-describing DNA identity alongside UUID. Role expression: - _roles/*.toml gain optional `extends = "<parent>"` + `relaxes = [...]` - compose.rs + verify.rs delegate to new role::resolve_role() with recursive extends-chain resolution + cycle detection - explorer.toml: 28→18 LOC (extends read-only) - edit-shared.toml: 31→23 LOC (extends edit-local, relaxes scope::files-whitelist for task-param override) DNA identity: - new dna.rs (159 LOC) — compose/render/parse round-trip - AgentInvocation carries dna field (prepare.rs) - Format: <role>::<caps-bitmap>::<sha4-scope>::<sha4-body>-<hex4-nonce> - ≤ 80 chars total, greppable, parseable - 11 capability codes in CAP_CODES table: NG, FW, FD, CP, CG, TG, ND, RF, SG, DT, BA kei-ledger schema v2: - ADD COLUMN dna TEXT + prefix index - `kei-ledger fork --dna <string>` optional flag - AgentRow.dna: Option<String> - Backward compat: schema migration detects + applies on open Docs: AGENT-SUBSTRATE-SCHEMA.md Layer E + Layer G sections + CAP_CODES table. New deps: sha2 (workspace), rand 0.8. Tests: kei-agent-runtime 50 (was 41, +9: 4 role + 5 DNA), kei-ledger 10 (was 9, +1 DNA roundtrip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
149 lines
3 KiB
Rust
149 lines
3 KiB
Rust
//! Layer E — role expression resolver smoke tests.
|
|
//!
|
|
//! Fixtures built in tempdir; each test writes the role files it needs,
|
|
//! runs `resolve_role`, asserts the flattened required list.
|
|
|
|
use kei_agent_runtime::role::resolve_role;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
fn write_role(root: &Path, name: &str, body: &str) {
|
|
let dir = root.join("_roles");
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join(format!("{name}.toml")), body).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn extends_chain_merges_parent_plus_local() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let root = tmp.path();
|
|
write_role(
|
|
root,
|
|
"base",
|
|
r#"
|
|
[role]
|
|
name = "base"
|
|
|
|
[capabilities]
|
|
required = ["tools::deny-tools", "output::report-format"]
|
|
"#,
|
|
);
|
|
write_role(
|
|
root,
|
|
"child",
|
|
r#"
|
|
[role]
|
|
name = "child"
|
|
|
|
[capabilities]
|
|
extends = "base"
|
|
required = ["tools::bash-allowlist"]
|
|
"#,
|
|
);
|
|
|
|
let r = resolve_role(root, "child").unwrap();
|
|
assert_eq!(
|
|
r.required,
|
|
vec![
|
|
"tools::deny-tools".to_string(),
|
|
"output::report-format".to_string(),
|
|
"tools::bash-allowlist".to_string(),
|
|
],
|
|
"child should inherit parent ordering then append local"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_detection_errors_with_path() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let root = tmp.path();
|
|
write_role(
|
|
root,
|
|
"a",
|
|
r#"
|
|
[role]
|
|
name = "a"
|
|
|
|
[capabilities]
|
|
extends = "b"
|
|
"#,
|
|
);
|
|
write_role(
|
|
root,
|
|
"b",
|
|
r#"
|
|
[role]
|
|
name = "b"
|
|
|
|
[capabilities]
|
|
extends = "a"
|
|
"#,
|
|
);
|
|
|
|
let err = resolve_role(root, "a").unwrap_err();
|
|
let msg = format!("{err:#}");
|
|
assert!(
|
|
msg.contains("cycle"),
|
|
"error should mention cycle: got {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relaxes_drops_inherited_capability() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let root = tmp.path();
|
|
write_role(
|
|
root,
|
|
"parent",
|
|
r#"
|
|
[role]
|
|
name = "parent"
|
|
|
|
[capabilities]
|
|
required = ["scope::files-whitelist", "quality::cargo-check-green", "output::report-format"]
|
|
"#,
|
|
);
|
|
write_role(
|
|
root,
|
|
"relaxed",
|
|
r#"
|
|
[role]
|
|
name = "relaxed"
|
|
|
|
[capabilities]
|
|
extends = "parent"
|
|
relaxes = ["scope::files-whitelist"]
|
|
"#,
|
|
);
|
|
|
|
let r = resolve_role(root, "relaxed").unwrap();
|
|
assert!(
|
|
!r.required.iter().any(|c| c == "scope::files-whitelist"),
|
|
"relaxed cap must be removed from the inherited list"
|
|
);
|
|
assert!(r.required.iter().any(|c| c == "quality::cargo-check-green"));
|
|
assert!(r.required.iter().any(|c| c == "output::report-format"));
|
|
}
|
|
|
|
#[test]
|
|
fn flat_role_without_extends_still_works() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let root = tmp.path();
|
|
write_role(
|
|
root,
|
|
"flat",
|
|
r#"
|
|
[role]
|
|
name = "flat"
|
|
|
|
[capabilities]
|
|
required = ["policy::no-git-ops", "output::report-format"]
|
|
"#,
|
|
);
|
|
|
|
let r = resolve_role(root, "flat").unwrap();
|
|
assert_eq!(r.required.len(), 2);
|
|
assert_eq!(r.required[0], "policy::no-git-ops");
|
|
assert_eq!(r.required[1], "output::report-format");
|
|
}
|