KeiSeiKit-1.0/_primitives/_rust/kei-agent-runtime/tests/role_expression_smoke.rs
Parfii-bot a4e667de10 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

274 lines
6.7 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, RoleError, MAX_DEPTH};
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");
}
#[test]
fn extends_chain_deeper_than_max_depth_errors() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
// Build a linear chain r0 -> r1 -> ... -> r20, which exceeds
// MAX_DEPTH = 16 and must refuse rather than recurse.
let total = MAX_DEPTH + 5;
for i in 0..total {
let body = if i + 1 < total {
format!(
"[role]\nname = \"r{i}\"\n\n[capabilities]\nextends = \"r{next}\"\n",
i = i,
next = i + 1
)
} else {
format!("[role]\nname = \"r{i}\"\n\n[capabilities]\nrequired = []\n", i = i)
};
write_role(root, &format!("r{i}"), &body);
}
let err = resolve_role(root, "r0").unwrap_err();
let role_err = err
.downcast_ref::<RoleError>()
.expect("expected typed RoleError");
match role_err {
RoleError::MaxDepthExceeded { depth, .. } => {
assert_eq!(*depth, MAX_DEPTH, "error must report configured cap");
}
other => panic!("expected MaxDepthExceeded, got {other:?}"),
}
}
#[test]
fn role_name_with_path_traversal_is_refused_before_fs_join() {
// No file created — attacker-controlled name must fail at validation,
// not at read-time with an ambiguous NotFound.
let tmp = TempDir::new().unwrap();
let err = resolve_role(tmp.path(), "../../etc/passwd").unwrap_err();
let role_err = err
.downcast_ref::<RoleError>()
.expect("expected typed RoleError");
match role_err {
RoleError::InvalidName { kind, value } => {
assert_eq!(*kind, "role");
assert_eq!(value, "../../etc/passwd");
}
other => panic!("expected InvalidName, got {other:?}"),
}
}
#[test]
fn capability_name_with_path_traversal_is_refused_in_compose() {
use kei_agent_runtime::capability::TaskSpec;
use kei_agent_runtime::compose::compose_prompt;
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"traversal",
r#"
[role]
name = "traversal"
[capabilities]
required = ["../../etc::passwd"]
"#,
);
let mut task = TaskSpec::default();
task.task.role = "traversal".into();
task.task.agent_id = "traversal-attempt".into();
task.body.text = "whatever".into();
let err = compose_prompt(&task, root).unwrap_err();
let role_err = err
.chain()
.find_map(|e| e.downcast_ref::<RoleError>())
.expect("expected typed RoleError in error chain");
match role_err {
RoleError::InvalidName { kind, .. } => {
assert!(
kind.starts_with("capability-"),
"kind should be capability-category or capability-slug, got {kind}"
);
}
other => panic!("expected InvalidName, got {other:?}"),
}
}
#[test]
fn relaxes_missing_cap_collects_warning_without_failing() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"base-warn",
r#"
[role]
name = "base-warn"
[capabilities]
required = ["policy::no-git-ops"]
"#,
);
write_role(
root,
"child-warn",
r#"
[role]
name = "child-warn"
[capabilities]
extends = "base-warn"
relaxes = ["scope::files-whitelist"]
"#,
);
let r = resolve_role(root, "child-warn").expect("must not fail");
assert_eq!(r.required, vec!["policy::no-git-ops".to_string()]);
assert_eq!(r.warnings.len(), 1, "expected exactly one warning");
let w = &r.warnings[0];
assert!(
w.contains("scope::files-whitelist") && w.contains("no-op"),
"warning should name dropped cap + no-op, got: {w}"
);
}