F5: Governance -- Replace with apcore ACL + apcore-cli Audit/Sandbox¶
| Field | Value |
|---|---|
| Feature ID | F5 |
| Tech Design Section | 5.5 |
| Priority | P1 (Security) |
| Dependencies | F2 (Module Executor) |
| Depended On By | F7 (Config Integration) |
| New Files | src/governance/acl.rs (rewritten), src/governance/audit.rs (rewritten), src/governance/sandbox.rs (new), src/governance/mod.rs (updated) |
| Deleted Files | src/governance/annotations.rs (moved to F1 adapter) |
| Estimated LOC | ~350 |
| Estimated Tests | ~20 |
1. Purpose¶
Replace apexe's custom governance layer (ACL generator, annotation logic, audit logger) with wrappers around apcore ecosystem primitives: apcore::ACL for access control, apcore_cli::AuditLogger for audit logging, and apcore_cli::Sandbox for subprocess isolation. This gains rule conditions, structured JSONL audit format, and timeout-enforced sandboxing.
2. Module Structure¶
2.1 src/governance/mod.rs¶
pub mod acl;
pub mod audit;
pub mod sandbox;
pub use acl::AclManager;
pub use audit::AuditManager;
pub use sandbox::SandboxManager;
3. AclManager¶
3.1 Type Definition¶
// src/governance/acl.rs
use std::path::Path;
use apcore::{ACL, ACLRule, ModuleAnnotations, ModuleError, ErrorCode};
use apcore_toolkit::ScannedModule;
/// Manages access control for CLI modules using apcore's ACL system.
pub struct AclManager {
acl: ACL,
}
3.2 Public Methods¶
impl AclManager {
/// Load ACL rules from a YAML configuration file.
///
/// File format:
/// ```yaml
/// default_effect: deny
/// rules:
/// - callers: ["@external", "*"]
/// targets: ["cli.git.status", "cli.git.log"]
/// effect: allow
/// description: "Auto-allow readonly CLI commands"
/// - callers: ["@external", "*"]
/// targets: ["cli.git.push"]
/// effect: deny
/// description: "Block destructive commands by default"
/// conditions:
/// require_approval: true
/// ```
pub fn from_config(config_path: &Path) -> Result<Self, ModuleError>;
/// Generate a default ACL from scanned modules based on their annotations.
///
/// Logic:
/// 1. Collect all module_ids where annotations.readonly == true.
/// Create rule: allow @external/* to access these modules.
/// 2. Collect all module_ids where annotations.destructive == true.
/// Create rule: deny @external/* with require_approval condition.
/// 3. All remaining modules: deny by default.
/// 4. Return ACL with default_effect = Deny.
pub fn generate_default(modules: &[ScannedModule]) -> ACL;
/// Write ACL configuration to a YAML file.
pub fn write_config(acl: &ACL, path: &Path) -> Result<(), ModuleError>;
/// Consume the manager and return the inner ACL for use with Executor.
pub fn into_inner(self) -> ACL;
/// Check if a caller has access to a target module.
pub fn check(
&self,
caller_id: &str,
caller_roles: &[String],
target_module: &str,
) -> bool;
}
3.3 Rule Generation Logic (from generate_default)¶
pub fn generate_default(modules: &[ScannedModule]) -> ACL {
let mut rules = Vec::new();
// Group 1: Readonly modules -> allow
let readonly_ids: Vec<String> = modules.iter()
.filter(|m| m.annotations.readonly)
.map(|m| m.module_id.clone())
.collect();
if !readonly_ids.is_empty() {
rules.push(ACLRule {
callers: vec!["@external".into(), "*".into()],
targets: readonly_ids,
effect: Effect::Allow,
description: Some("Auto-allow readonly CLI commands".into()),
conditions: None,
});
}
// Group 2: Destructive modules -> deny with approval
let destructive_ids: Vec<String> = modules.iter()
.filter(|m| m.annotations.destructive)
.map(|m| m.module_id.clone())
.collect();
if !destructive_ids.is_empty() {
rules.push(ACLRule {
callers: vec!["@external".into(), "*".into()],
targets: destructive_ids,
effect: Effect::Deny,
description: Some("Block destructive CLI commands by default".into()),
conditions: Some(serde_json::json!({"require_approval": true})),
});
}
// Group 3: Write modules (non-readonly, non-destructive) -> deny
let write_ids: Vec<String> = modules.iter()
.filter(|m| !m.annotations.readonly && !m.annotations.destructive)
.map(|m| m.module_id.clone())
.collect();
if !write_ids.is_empty() {
rules.push(ACLRule {
callers: vec!["@external".into(), "*".into()],
targets: write_ids,
effect: Effect::Deny,
description: Some("Deny write CLI commands by default".into()),
conditions: None,
});
}
ACL::new(rules, Effect::Deny)
}
4. AuditManager¶
4.1 Type Definition¶
// src/governance/audit.rs
use std::path::Path;
use apcore_cli::AuditLogger;
use serde_json::Value;
/// Manages append-only JSONL audit logging for CLI module executions.
pub struct AuditManager {
logger: AuditLogger,
}
4.2 Public Methods¶
impl AuditManager {
/// Create a new AuditManager writing to the given file path.
///
/// The file is created if it does not exist.
/// Entries are appended (never truncated).
pub fn new(audit_path: &Path) -> Self {
Self {
logger: AuditLogger::new(audit_path),
}
}
/// Log a module execution event.
///
/// Writes a JSONL entry with:
/// - timestamp (ISO 8601)
/// - module_id
/// - input (JSON)
/// - output (JSON, truncated if large)
/// - duration_ms
/// - exit_code (extracted from output)
/// - success (exit_code == 0)
pub fn log_execution(
&self,
module_id: &str,
input: &Value,
output: &Value,
duration_ms: u64,
);
/// Return the path to the audit log file.
pub fn log_path(&self) -> &Path;
}
4.3 Integration with CliModule¶
The AuditManager is called inside CliModule::execute() after subprocess completion:
// Inside CliModule::execute()
let start = std::time::Instant::now();
let result = execute_subprocess(...).await?;
let duration_ms = start.elapsed().as_millis() as u64;
if let Some(ref audit) = self.audit {
audit.log_execution(&self.module_id, &input, &result, duration_ms);
}
5. SandboxManager¶
5.1 Type Definition¶
// src/governance/sandbox.rs
use apcore::ModuleError;
use apcore_cli::Sandbox;
use serde_json::Value;
/// Manages subprocess isolation using apcore-cli's Sandbox.
pub struct SandboxManager {
sandbox: Sandbox,
}
5.2 Public Methods¶
impl SandboxManager {
/// Create a new SandboxManager.
///
/// - enabled: Whether sandboxing is active (if false, execute() is a pass-through).
/// - timeout_ms: Maximum execution time for sandboxed processes.
pub fn new(enabled: bool, timeout_ms: u64) -> Self {
Self {
sandbox: Sandbox::new(enabled, timeout_ms),
}
}
/// Execute a module in the sandbox.
///
/// If sandboxing is enabled:
/// - Subprocess runs in an isolated environment.
/// - Timeout is enforced (kills process after timeout_ms).
/// - Returns output or ModuleError on timeout/failure.
///
/// If sandboxing is disabled:
/// - Pass-through to normal subprocess execution.
pub fn execute(
&self,
module_id: &str,
input: &Value,
) -> Result<Value, ModuleError>;
/// Check if sandboxing is enabled.
pub fn is_enabled(&self) -> bool;
}
5.3 Integration with CliModule¶
The SandboxManager is called in CliModule::execute() as an alternative execution path:
// Inside CliModule::execute()
let result = if let Some(ref sandbox) = self.sandbox {
sandbox.execute(&self.module_id, &input)?
} else {
let args = build_arguments(&input_map)?;
execute_subprocess(&self.binary_path, &args, self.json_flag.as_deref(), None, self.timeout_ms).await?
};
6. Test Scenarios¶
6.1 AclManager Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_acl_generate_default_readonly_allowed |
2 readonly modules | Rule with effect=Allow for those module_ids |
test_acl_generate_default_destructive_denied |
1 destructive module | Rule with effect=Deny and require_approval |
test_acl_generate_default_write_denied |
1 write module | Rule with effect=Deny |
test_acl_generate_default_mixed |
3 modules (1 each type) | 3 rules |
test_acl_generate_default_empty |
No modules | ACL with only default deny |
test_acl_from_config_valid_yaml |
Well-formed YAML | ACL loaded with rules |
test_acl_from_config_missing_file |
Nonexistent path | Err(ModuleError) |
test_acl_write_config_creates_file |
Write and re-read | File exists, content matches |
test_acl_check_readonly_allowed |
Check @external -> readonly module | true |
test_acl_check_destructive_denied |
Check @external -> destructive module | false |
6.2 AuditManager Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_audit_log_creates_file |
Log one execution | File exists |
test_audit_log_appends_jsonl |
Log two executions | File has 2 lines |
test_audit_log_entry_format |
Log and parse | Valid JSON with timestamp, module_id, duration_ms |
test_audit_log_large_output_truncated |
Output > 10KB | Truncated in log entry |
test_audit_log_path_returns_path |
Create manager | Returns configured path |
6.3 SandboxManager Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_sandbox_enabled_timeout |
Enabled, command exceeds timeout | Err with Timeout |
test_sandbox_disabled_passthrough |
Disabled, normal command | Ok with output |
test_sandbox_is_enabled_true |
Created with enabled=true | is_enabled() == true |
test_sandbox_is_enabled_false |
Created with enabled=false | is_enabled() == false |
test_sandbox_execute_normal_command |
Enabled, echo hello | Ok with stdout |
7. Migration from v0.1.x¶
What Changes¶
| v0.1.x | v0.2.0 | Change Type |
|---|---|---|
generate_acl() free function |
AclManager::generate_default() method |
Restructured |
serde_json::Map ACL format |
apcore::ACL type |
Type change |
Custom write_acl() |
AclManager::write_config() |
Simplified |
annotate_bindings() |
Moved to F1 adapter::annotations::infer() |
Relocated |
| Custom audit JSONL writer | apcore_cli::AuditLogger |
Replaced |
| No sandbox support | apcore_cli::Sandbox |
New capability |
ACL YAML Format Change¶
v0.1.x format:
default_effect: deny
rules:
- callers: ["@external", "*"]
targets: ["cli.git.status"]
effect: allow
description: "Auto-allow readonly CLI commands"
v0.2.0 format (apcore ACL):
default_effect: deny
rules:
- callers: ["@external", "*"]
targets: ["cli.git.status"]
effect: allow
description: "Auto-allow readonly CLI commands"
conditions: null
The format is nearly identical. The conditions field is new (nullable). Existing v0.1.x ACL files are forward-compatible.
Annotation Logic Relocation¶
The annotate_bindings() function from src/governance/annotations.rs is not rewritten here. Its logic moves to src/adapter/annotations.rs (F1) where it produces ModuleAnnotations instead of HashMap<String, JsonValue>. The governance module only consumes annotations, it does not generate them.