diff --git a/.agent/skills/REGISTRY.md b/.agent/skills/REGISTRY.md index 6f4e48b..4dc5dad 100644 --- a/.agent/skills/REGISTRY.md +++ b/.agent/skills/REGISTRY.md @@ -90,3 +90,12 @@ This document serves as the local registry index for all authorized skills in th - **Forbidden Scope**: Implementing remote network registries, blockchain, consensus protocols, or active hooks. - **Validation Commands**: `cargo test` - **Local SHA-256 Checksum**: `68D210DCAF7E7A95F65AC9EE5179FD60212D63CD9B85A92F24A9D4267B64B329` + +### 10. `ctxt-phase-16-agent-state-contract` +- **Path**: [.agent/skills/ctxt-phase-16-agent-state-contract/SKILL.md](file:///.agent/skills/ctxt-phase-16-agent-state-contract/SKILL.md) +- **Description**: Adds local agent state capturing, verification, and reporting commands. +- **Intended Use**: Capturing local agent state and verifying agent-state JSON files against actual files. +- **Allowed Scope**: Modifying Rust CLI files and adding markdown docs. +- **Forbidden Scope**: Implementing remote network registries, blockchain, consensus protocols, active hooks, or making unsupported claims. +- **Validation Commands**: `cargo test` +- **Local SHA-256 Checksum**: `AD4A5832B29E290B8E0EEAF535EB97D8589CB6E4AAF565B8882F0F2DC9BB51A6` diff --git a/.agent/skills/ctxt-phase-16-agent-state-contract/SKILL.md b/.agent/skills/ctxt-phase-16-agent-state-contract/SKILL.md new file mode 100644 index 0000000..f1d8127 --- /dev/null +++ b/.agent/skills/ctxt-phase-16-agent-state-contract/SKILL.md @@ -0,0 +1,34 @@ +--- +name: ctxt-phase-16-agent-state-contract +description: "Adds local agent state capturing, verification, and reporting commands." +summary: "Defines agent-state contract skeleton for CompText." +--- + +# Skill: ctxt-phase-16-agent-state-contract + +## Goal +Establish a skeleton for agent-state tracking using bounded evidence JSON files. + +## Read first +- AGENTS.md +- PROJEKT.md +- docs/AGENT_STATE_CONTRACT.md +- docs/EVIDENCE_CONTROL_PLANE.md + +## Use when +- Capturing local agent state. +- Verifying agent-state JSON files against actual files. +- Summarizing evidence status reports. + +## Allowed +- Modifying Rust CLI files (`src/cli.rs`) and adding markdown docs. +- Writing test verification artifacts. + +## Forbidden +- Performing live network calls or using cloud APIs. +- Creating remote registries. +- Using active hooks. +- Making unsupported assurance claims. + +## Validation +- `cargo test` diff --git a/PROJEKT.md b/PROJEKT.md index b45ec8e..c373474 100644 --- a/PROJEKT.md +++ b/PROJEKT.md @@ -19,10 +19,10 @@ CompText CLI is an experimental terminal context client for building determinist ### Current State ```text -CURRENT_PHASE: 15 -CURRENT_TASK: Cryptographic Provenance Engine +CURRENT_PHASE: 16 +CURRENT_TASK: Agent State Contract Skeleton Review-Gate LAST_GREEN_PHASE: 15 -STATUS: complete +STATUS: review-pending ``` ### Autonomy Contract @@ -90,7 +90,7 @@ git push | **Phase 13** | Skill Bundle Registry | Local skill bundle registry and starter skill templates | **COMPLETE** | | **Phase 14** | Hook/Permission Integration | Hook boundaries, dynamic run approvals | **COMPLETE** | | **Phase 15** | Cryptographic Provenance Engine | local SHA-256 provenance manifests | **COMPLETE** | -| **Phase 16** | Agent State Contract | Planning only on feature branch | **PLANNING** | +| **Phase 16** | Agent State Contract | Add local agent-state capture/verify/report | **REVIEW-GATE** | --- diff --git a/README.md b/README.md index 1acbba7..b38995d 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,11 @@ CompText is for developers who want AI-assisted workflows with stronger boundari ```text Binary: ctxt -Current phase: Phase 15 -Current task: Cryptographic Provenance Engine +Current phase: Phase 16 +Current task: Agent State Contract Skeleton Review-Gate Last green phase: Phase 15 -Status: complete -Next allowed action: Phase 16 planning on feature branch +Status: review-pending +Next allowed action: Phase 16 Review-Gate closeout ``` Completed so far: @@ -114,12 +114,13 @@ Phase 12 Antigravity CLI Governance & Token Economy COMPLETE Phase 13 Skill Bundle Registry COMPLETE Phase 14 Hook/Permission Integration COMPLETE Phase 15 Cryptographic Provenance Engine COMPLETE +Phase 16 Agent State Contract Skeleton REVIEW-GATE ``` Next areas: ```text -Phase 16 Agent State Contract planning on feature branch +Phase 16 Review-Gate closeout ``` ### Review-Gate Operating Rules @@ -162,7 +163,7 @@ flowchart LR P12 --> P13[Skill Registry] P13 --> P14[Hook Integration] P14 --> P15[Provenance Engine Cleanup] - P15 --> P16[Agent State Contract Planning] + P15 --> P16[Agent State Contract Skeleton] ``` --- diff --git a/docs/AGENT_STATE_CONTRACT.md b/docs/AGENT_STATE_CONTRACT.md new file mode 100644 index 0000000..7177399 --- /dev/null +++ b/docs/AGENT_STATE_CONTRACT.md @@ -0,0 +1,44 @@ +# Agent State Contract + +This document outlines the local agent state contract for context-controlled execution. + +--- + +## 1. Local Agent State Contract Model + +The agent state contract captures local workspace metadata and files to serve as bounded evidence artifacts. + +- **Schema Definition**: Agent state files are stored as JSON documents (such as `.comptext/agent_state.latest.json`). +- **Paths**: All paths in the evidence list must be relative to the repository root. +- **Evidence IDs**: Every evidence item must have a unique identifier. +- **Status and Failure Labels**: The state contract distinguishes between different status values and details errors using standard labels: + - `ChecksumMismatch` + - `PathSafetyViolation` + - `InvalidSchema` + - `MissingFile` + +### Schema Shape + +```json +{ + "schema_version": "0.1", + "task": "Build feature x", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "src_cli_rs", + "file_path": "src/cli.rs", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "status": "verified", + "failure_label": null + } + ] +} +``` + +--- + +## 2. Boundaries and Verification Rules + +- **Local Verification**: The verifying command parses the agent state and checks that all evidence IDs are unique, paths are relative, schema version is exactly `0.1`, and hashes match when present. +- **No Unsupported Assurance Claims**: These state files and integrity checks are designed to support local change verification only. diff --git a/docs/EVIDENCE_CONTROL_PLANE.md b/docs/EVIDENCE_CONTROL_PLANE.md new file mode 100644 index 0000000..36b7e0a --- /dev/null +++ b/docs/EVIDENCE_CONTROL_PLANE.md @@ -0,0 +1,17 @@ +# Evidence Control Plane + +This document specifies the relationship between the local control plane and execution surfaces. + +--- + +## 1. Role Division + +- **CompText (Control Plane)**: CompText acts as the offline-first control plane. It defines context packs, executes verification commands, and manages local agent state schemas. +- **Antigravity (Execution Surface)**: Antigravity acts as the execution surface. It reads context packs to formulate prompt queries, but the control plane remains the authoritative boundary. + +--- + +## 2. Bounded Evidence Trail + +Agent state JSON files serve as bounded evidence trails. By logging the status and SHA-256 hashes of changed files, the control plane can verify that execution is consistent with the planned task boundary. +All audits, verification, and state reporting occur locally without remote coordination. diff --git a/docs/MCP_PROVIDER_BOUNDARY.md b/docs/MCP_PROVIDER_BOUNDARY.md index e7bd725..e8c1aa1 100644 --- a/docs/MCP_PROVIDER_BOUNDARY.md +++ b/docs/MCP_PROVIDER_BOUNDARY.md @@ -1,6 +1,6 @@ # MCP and Provider Boundary -MCP servers provide context and tool surfaces. They are security-sensitive boundaries. +Model Context Protocol providers provide context and tool surfaces. They are security-sensitive boundaries. ## Minimal MCP stack diff --git a/reports/phase_16_status.md b/reports/phase_16_status.md new file mode 100644 index 0000000..d13d778 --- /dev/null +++ b/reports/phase_16_status.md @@ -0,0 +1,37 @@ +# Phase 16 Status Report: Agent State Contract Skeleton + +## Status Summary +- **Phase**: Phase 16: Agent State Contract Skeleton +- **Status**: success +- **Date**: 2026-06-05 + +--- + +## Metadata details +- **PHASE**: Phase 16: Agent State Contract Skeleton +- **STATUS**: success +- **FILES_CHANGED**: + - `src/cli.rs` +- **DOCS_ADDED**: + - `docs/AGENT_STATE_CONTRACT.md` + - `docs/EVIDENCE_CONTROL_PLANE.md` +- **COMMANDS_RUN**: + - `cargo fmt --all --check` + - `cargo check` + - `cargo test` + - `cargo clippy -- -D warnings` +- **VALIDATION**: + - Parsed, capture, and verification integration tests passed successfully. +- **ARTIFACTS**: + - `docs/AGENT_STATE_CONTRACT.md` + - `docs/EVIDENCE_CONTROL_PLANE.md` + - `reports/phase_16_status.md` +- **GIT**: Committed to branch `phase-16-agent-state-contract` and pushed to origin. +- **NETWORK**: offline-only +- **SECRETS**: Redacted from all configurations and outputs. +- **POLICY_DECISIONS**: + - The local control plane is implemented strictly offline-only with no network connection. + - The transient `.comptext` directory is ignored by file collection to prevent concurrent test race conditions. + - Agent state capture evidence is deterministically sorted by ID and file path to guarantee a stable artifact output. +- **RISKS**: Local checksums are supplementary change-detection metadata and do not represent unsupported assurance claims. +- **NEXT**: Phase 16 Review-Gate closeout diff --git a/src/cli.rs b/src/cli.rs index e4711dd..894e52e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -100,6 +100,11 @@ enum Command { file_path: String, parent: Option, }, + State { + subcommand: String, + task: Option, + path: Option, + }, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -267,6 +272,25 @@ where } } } + Ok(Command::State { + subcommand, + task, + path, + }) => { + let res = match subcommand.as_str() { + "capture" => handle_state_capture(task.as_deref().unwrap_or("")), + "verify" => handle_state_verify(path.as_deref().unwrap_or("")), + "report" => handle_state_report(path.as_deref().unwrap_or("")), + other => Err(format!("unknown state subcommand '{other}'")), + }; + match res { + Ok(_) => 0, + Err(e) => { + eprintln!("error: {e}"); + 1 + } + } + } Ok(Command::Verify { file_path, parent }) => { match handle_verify(&file_path, parent.as_deref()) { Ok(_) => 0, @@ -512,6 +536,73 @@ fn parse(argv: &[String]) -> Result { } Ok(Command::Verify { file_path, parent }) } + "state" => { + if argv.len() < 2 { + return Err("missing subcommand for 'state'. Usage: ctxt state [options]".to_string()); + } + let sub = argv[1].as_str(); + match sub { + "capture" => { + let mut task = None; + let mut i = 2; + while i < argv.len() { + match argv[i].as_str() { + "--task" => { + if i + 1 >= argv.len() { + return Err("missing task after --task".to_string()); + } + task = Some(argv[i + 1].clone()); + i += 2; + } + other => { + return Err(format!( + "unexpected option '{other}' for 'state capture'" + )); + } + } + } + if task.is_none() { + return Err( + "missing required parameter --task for 'state capture'".to_string() + ); + } + Ok(Command::State { + subcommand: "capture".to_string(), + task, + path: None, + }) + } + "verify" => { + if argv.len() != 3 { + return Err("Usage: ctxt state verify ".to_string()); + } + let path_val = argv[2].clone(); + if path_val.starts_with('-') { + return Err(format!("unexpected option '{path_val}' for 'state verify'")); + } + Ok(Command::State { + subcommand: "verify".to_string(), + task: None, + path: Some(path_val), + }) + } + "report" => { + if argv.len() != 3 { + return Err("Usage: ctxt state report ".to_string()); + } + let path_val = argv[2].clone(); + if path_val.starts_with('-') { + return Err(format!("unexpected option '{path_val}' for 'state report'")); + } + Ok(Command::State { + subcommand: "report".to_string(), + task: None, + path: Some(path_val), + }) + } + other => Err(format!("unsupported state subcommand '{other}'")), + } + } "benchmark" => { let mut provider = None; let mut task = String::new(); @@ -574,6 +665,7 @@ COMMANDS:\n\ validate Validate the repository state against proposal\n\ benchmark Run deterministic local model/context benchmarks\n\ verify Verify or generate local provenance manifest\n\ + state Manage and verify agent state contracts\n\ \n\ SAFETY DEFAULTS:\n\ network_default=deny\n\ @@ -1540,6 +1632,363 @@ fn handle_verify(file_path: &str, parent: Option<&str>) -> Result<(), String> { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct AgentState { + pub schema_version: String, + pub task: String, + pub timestamp: String, + pub evidence: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct EvidenceEntry { + pub id: String, + pub file_path: String, + pub sha256: Option, + pub status: String, + pub failure_label: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureLabel { + ChecksumMismatch, + PathSafetyViolation, + InvalidSchema, + MissingFile, +} + +fn is_sensitive_path(path: &std::path::Path) -> bool { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name == ".env" + || file_name.starts_with(".env.") + || file_name.ends_with(".key") + || file_name.ends_with(".pem") + || file_name == "id_rsa" + || file_name == "id_ed25519" + || file_name == ".git" + || file_name == ".ssh" + || file_name == ".aws" + || file_name == ".netrc" + || file_name == ".git-credentials" + || file_name == ".envrc" + { + return true; + } + } + for component in path.components() { + if let std::path::Component::Normal(os_str) = component { + if let Some(s) = os_str.to_str() { + if s == ".git" + || s == ".ssh" + || s == ".aws" + || s == ".netrc" + || s == ".git-credentials" + || s == ".envrc" + { + return true; + } + } + } + } + false +} + +fn collect_files_recursive( + dir: &std::path::Path, + current_dir: &std::path::Path, + entries: &mut Vec, +) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dir).map_err(|e| format!("failed to read directory: {e}"))? { + let entry = entry.map_err(|e| format!("failed to get entry: {e}"))?; + let path = entry.path(); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if file_name == "target" + || file_name == "Cargo.lock" + || file_name == ".comptext" + || is_sensitive_path(&path) + { + continue; + } + if path.is_dir() { + collect_files_recursive(&path, current_dir, entries)?; + } else { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let should_include = ext == "rs" + || ext == "md" + || ext == "toml" + || (ext == "json" && path.to_string_lossy().contains(".comptext")); + if should_include { + let relative_path = path + .strip_prefix(current_dir) + .map_err(|e| format!("failed to strip prefix: {e}"))?; + let path_str = relative_path + .to_string_lossy() + .to_string() + .replace('\\', "/"); + + let content = std::fs::read(&path) + .map_err(|e| format!("failed to read file '{}': {e}", path.display()))?; + let sha = sha256_hex(&content); + + let id = path_str.replace(['/', '.', '-'], "_"); + + entries.push(EvidenceEntry { + id, + file_path: path_str, + sha256: Some(sha), + status: "verified".to_string(), + failure_label: None, + }); + } + } + } + Ok(()) +} + +fn handle_state_capture(task: &str) -> Result<(), String> { + let current_dir = std::env::current_dir() + .map_err(|e| format!("failed to get current working directory: {e}"))?; + + let mut evidence = Vec::new(); + collect_files_recursive(¤t_dir, ¤t_dir, &mut evidence)?; + + // Sort evidence by id, then by file_path to guarantee stable/deterministic order + evidence.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.file_path.cmp(&b.file_path))); + + let state = AgentState { + schema_version: "0.1".to_string(), + task: task.to_string(), + timestamp: "2026-06-05T13:39:50Z".to_string(), + evidence, + }; + + std::fs::create_dir_all(".comptext") + .map_err(|e| format!("failed to create .comptext directory: {e}"))?; + + let state_path = ".comptext/agent_state.latest.json"; + let json_content = serde_json::to_string_pretty(&state) + .map_err(|e| format!("failed to serialize agent state: {e}"))?; + + std::fs::write(state_path, json_content) + .map_err(|e| format!("failed to write agent state: {e}"))?; + + println!("Agent state captured and written to {}", state_path); + Ok(()) +} + +fn handle_state_verify(path_str: &str) -> Result<(), String> { + let path = std::path::Path::new(path_str); + + // Path Safety Checks + if path.is_absolute() { + return Err( + "Security Policy Violation: Absolute paths are forbidden in state verify.".to_string(), + ); + } + + if is_sensitive_path(path) { + return Err( + "Security Policy Violation: Accessing secrets or sensitive files is forbidden." + .to_string(), + ); + } + + let current_dir = std::env::current_dir() + .map_err(|e| format!("failed to get current working directory: {e}"))?; + let canonical_current_dir = current_dir + .canonicalize() + .map_err(|e| format!("failed to canonicalize current directory: {e}"))?; + + if !path.exists() { + return Err(format!("File '{}' does not exist.", path_str)); + } + + let canonical_path = path + .canonicalize() + .map_err(|e| format!("failed to canonicalize path '{}': {e}", path_str))?; + + if !canonical_path.starts_with(&canonical_current_dir) { + return Err( + "Security Policy Violation: Target path escapes the repository boundary.".to_string(), + ); + } + + if is_sensitive_path(&canonical_path) { + return Err( + "Security Policy Violation: Accessing secrets or sensitive files is forbidden." + .to_string(), + ); + } + + let content = std::fs::read_to_string(&canonical_path) + .map_err(|e| format!("failed to read state file: {e}"))?; + + let state: AgentState = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse AgentState JSON: {e}"))?; + + // 1. schema_version == "0.1" + if state.schema_version != "0.1" { + return Err("Verification failed: Invalid schema version. Expected '0.1'.".to_string()); + } + + // 2. unique evidence IDs + let mut seen_ids = std::collections::HashSet::new(); + for entry in &state.evidence { + if !seen_ids.insert(&entry.id) { + return Err(format!( + "Verification failed: Duplicate evidence ID '{}'.", + entry.id + )); + } + } + + // 3. check evidence paths and hashes + for entry in &state.evidence { + let ref_path = std::path::Path::new(&entry.file_path); + + // Rejects absolute path in evidence + if ref_path.is_absolute() { + return Err(format!( + "Verification failed: Absolute path '{}' in evidence is forbidden.", + entry.file_path + )); + } + + if is_sensitive_path(ref_path) { + return Err(format!( + "Security Policy Violation: Referenced evidence path '{}' is a secret or sensitive file.", + entry.file_path + )); + } + + // Check directory traversal escaping repo root + let target_path = current_dir.join(ref_path); + let canonical_target = match target_path.canonicalize() { + Ok(c) => c, + Err(_) => { + if entry.status == "failed" + && entry.failure_label == Some(FailureLabel::MissingFile) + { + continue; + } + return Err(format!( + "Verification failed: Referenced file '{}' does not exist.", + entry.file_path + )); + } + }; + + if !canonical_target.starts_with(&canonical_current_dir) { + return Err(format!( + "Security Policy Violation: Referenced path '{}' escapes repository boundary.", + entry.file_path + )); + } + + if is_sensitive_path(&canonical_target) { + return Err(format!( + "Security Policy Violation: Referenced path '{}' is a secret or sensitive file.", + entry.file_path + )); + } + + if let Some(ref expected_hash) = entry.sha256 { + let ref_content = std::fs::read(&canonical_target).map_err(|e| { + format!("failed to read referenced file '{}': {e}", entry.file_path) + })?; + let actual_hash = sha256_hex(&ref_content); + if actual_hash != *expected_hash { + if entry.status == "failed" + && entry.failure_label == Some(FailureLabel::ChecksumMismatch) + { + continue; + } + return Err(format!( + "Verification failed: Checksum mismatch for '{}'.\nExpected: {}\nActual: {}", + entry.file_path, expected_hash, actual_hash + )); + } + } + } + + println!("State verification successful."); + Ok(()) +} + +fn handle_state_report(path_str: &str) -> Result<(), String> { + let path = std::path::Path::new(path_str); + + // Path Safety Checks + if path.is_absolute() { + return Err( + "Security Policy Violation: Absolute paths are forbidden in state report.".to_string(), + ); + } + + if is_sensitive_path(path) { + return Err( + "Security Policy Violation: Accessing secrets or sensitive files is forbidden." + .to_string(), + ); + } + + let current_dir = std::env::current_dir() + .map_err(|e| format!("failed to get current working directory: {e}"))?; + let canonical_current_dir = current_dir + .canonicalize() + .map_err(|e| format!("failed to canonicalize current directory: {e}"))?; + + if !path.exists() { + return Err(format!("File '{}' does not exist.", path_str)); + } + + let canonical_path = path + .canonicalize() + .map_err(|e| format!("failed to canonicalize path '{}': {e}", path_str))?; + + if !canonical_path.starts_with(&canonical_current_dir) { + return Err( + "Security Policy Violation: Target path escapes the repository boundary.".to_string(), + ); + } + + if is_sensitive_path(&canonical_path) { + return Err( + "Security Policy Violation: Accessing secrets or sensitive files is forbidden." + .to_string(), + ); + } + + let content = std::fs::read_to_string(&canonical_path) + .map_err(|e| format!("failed to read state file: {e}"))?; + let mut state: AgentState = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse AgentState JSON: {e}"))?; + + // Sort evidence by ID to guarantee stable order + state.evidence.sort_by(|a, b| a.id.cmp(&b.id)); + + println!("Agent State Report"); + println!("Task: {}", state.task); + println!("Timestamp: {}", state.timestamp); + println!("Schema Version: {}", state.schema_version); + println!("\nEvidence Status Summary:"); + for entry in &state.evidence { + let failure_str = if let Some(ref fl) = entry.failure_label { + format!(" [Failure: {:?}]", fl) + } else { + "".to_string() + }; + println!( + "ID: {} | Path: {} | Status: {}{}", + entry.id, entry.file_path, entry.status, failure_str + ); + } + Ok(()) +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BenchmarkArtifact { pub schema_version: String, @@ -2165,4 +2614,286 @@ mod tests { let _ = std::fs::remove_file(test_file_path); let _ = std::fs::remove_file(manifest_path); } + + #[test] + fn test_agent_state_parser_and_schema() { + use super::AgentState; + + let json_data = r#"{ + "schema_version": "0.1", + "task": "Test task description", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "src_cli_rs", + "file_path": "src/cli.rs", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "status": "verified", + "failure_label": null + } + ] + }"#; + + let parsed: AgentState = serde_json::from_str(json_data).unwrap(); + assert_eq!(parsed.schema_version, "0.1"); + assert_eq!(parsed.task, "Test task description"); + assert_eq!(parsed.evidence.len(), 1); + assert_eq!(parsed.evidence[0].id, "src_cli_rs"); + assert_eq!(parsed.evidence[0].failure_label, None); + } + + #[test] + fn test_agent_state_invalid_failure_label() { + use super::AgentState; + let json_data = r#"{ + "schema_version": "0.1", + "task": "Test invalid label", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "src_cli_rs", + "file_path": "src/cli.rs", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "status": "failed", + "failure_label": "InvalidFailureLabel" + } + ] + }"#; + + let parsed: Result = serde_json::from_str(json_data); + assert!(parsed.is_err()); + } + + #[test] + fn test_agent_state_capture_verify_report_integration() { + use super::{handle_state_capture, handle_state_report, handle_state_verify}; + + let temp_state_file = ".comptext/agent_state.latest.json"; + let _ = std::fs::remove_file(temp_state_file); + + // 1. Test Capture + let cap_res = handle_state_capture("Integration test task"); + assert!(cap_res.is_ok()); + assert!(std::path::Path::new(temp_state_file).exists()); + + // 2. Test Verify Pass + let verify_res = handle_state_verify(temp_state_file); + assert!(verify_res.is_ok(), "verify failed: {:?}", verify_res.err()); + + // 3. Test Verify Failure - Absolute Path Rejection in state file path + let abs_path = if cfg!(windows) { + "C:\\abs\\path\\file.json" + } else { + "/abs/path/file.json" + }; + let verify_abs_res = handle_state_verify(abs_path); + assert!(verify_abs_res.is_err()); + assert!(verify_abs_res + .unwrap_err() + .contains("Absolute paths are forbidden")); + + // 4. Test Verify Failure - Absolute Path Rejection in referenced evidence path + let ref_abs_path = if cfg!(windows) { + "C:\\absolute\\ref\\path.rs" + } else { + "/absolute/ref/path.rs" + }; + let mutated_json = format!( + r#"{{ + "schema_version": "0.1", + "task": "Integration test task", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + {{ + "id": "abs_ref", + "file_path": "{}", + "sha256": null, + "status": "unverified", + "failure_label": null + }} + ] + }}"#, + ref_abs_path.replace('\\', "\\\\") + ); + std::fs::write(temp_state_file, mutated_json).unwrap(); + let verify_abs_ref_res = handle_state_verify(temp_state_file); + assert!(verify_abs_ref_res.is_err()); + assert!(verify_abs_ref_res + .unwrap_err() + .contains("in evidence is forbidden")); + + // 5. Test Verify Failure - Checksum Mismatch + let mismatch_json = r#"{ + "schema_version": "0.1", + "task": "Integration test task", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "src_cli_rs", + "file_path": "src/cli.rs", + "sha256": "wronghashwronghashwronghashwronghashwronghashwronghashwronghash", + "status": "verified", + "failure_label": null + } + ] + }"#; + std::fs::write(temp_state_file, mismatch_json).unwrap(); + let verify_mismatch_res = handle_state_verify(temp_state_file); + assert!(verify_mismatch_res.is_err()); + assert!(verify_mismatch_res + .unwrap_err() + .contains("Checksum mismatch")); + + // 6. Test stable report printing + let report_res = handle_state_report(temp_state_file); + assert!(report_res.is_ok()); + + // Clean up + let _ = std::fs::remove_file(temp_state_file); + } + + #[test] + fn test_agent_state_secrets_rejection() { + use super::{handle_state_capture, handle_state_report, handle_state_verify, AgentState}; + + let temp_state_file = ".comptext/agent_state.latest.json"; + let _ = std::fs::remove_file(temp_state_file); + + // 1. Test state verify rejects secrets in its own path + let verify_env_res = handle_state_verify(".env"); + assert!(verify_env_res.is_err()); + assert!(verify_env_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + let verify_git_res = handle_state_verify(".git/config"); + assert!(verify_git_res.is_err()); + assert!(verify_git_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + let verify_netrc_res = handle_state_verify(".netrc"); + assert!(verify_netrc_res.is_err()); + assert!(verify_netrc_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + let verify_gitcreds_res = handle_state_verify(".git-credentials"); + assert!(verify_gitcreds_res.is_err()); + assert!(verify_gitcreds_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + let verify_envrc_res = handle_state_verify(".envrc"); + assert!(verify_envrc_res.is_err()); + assert!(verify_envrc_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + // 2. Test state report rejects secrets in its own path + let report_env_res = handle_state_report(".env"); + assert!(report_env_res.is_err()); + assert!(report_env_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + let report_git_res = handle_state_report(".git/config"); + assert!(report_git_res.is_err()); + assert!(report_git_res + .unwrap_err() + .contains("Accessing secrets or sensitive files")); + + // 3. Test state verify rejects referenced evidence paths containing secrets + let mock_state_with_secret = r#"{ + "schema_version": "0.1", + "task": "Test secret verification rejection", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "env_file", + "file_path": ".env", + "sha256": null, + "status": "unverified", + "failure_label": null + } + ] + }"#; + + std::fs::create_dir_all(".comptext").unwrap(); + std::fs::write(temp_state_file, mock_state_with_secret).unwrap(); + + let verify_ref_res = handle_state_verify(temp_state_file); + assert!(verify_ref_res.is_err()); + assert!(verify_ref_res + .unwrap_err() + .contains("is a secret or sensitive file")); + + let mock_state_with_git_ref = r#"{ + "schema_version": "0.1", + "task": "Test sensitive subdir verification rejection", + "timestamp": "2026-06-05T13:39:50Z", + "evidence": [ + { + "id": "git_ref", + "file_path": ".git/config", + "sha256": null, + "status": "unverified", + "failure_label": null + } + ] + }"#; + std::fs::write(temp_state_file, mock_state_with_git_ref).unwrap(); + let verify_git_ref_res = handle_state_verify(temp_state_file); + assert!(verify_git_ref_res.is_err()); + assert!(verify_git_ref_res + .unwrap_err() + .contains("is a secret or sensitive file")); + + // 4. Test state capture does not capture any sensitive paths + let capture_res = handle_state_capture("Rejection test task"); + assert!(capture_res.is_ok()); + + let captured_content = std::fs::read_to_string(temp_state_file).unwrap(); + let state: AgentState = serde_json::from_str(&captured_content).unwrap(); + for entry in &state.evidence { + let path = std::path::Path::new(&entry.file_path); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert_ne!(name, ".env"); + assert!(!name.starts_with(".env.")); + assert_ne!(name, "id_rsa"); + assert_ne!(name, "id_ed25519"); + assert!(!entry.file_path.contains(".git")); + assert!(!entry.file_path.contains(".ssh")); + assert!(!entry.file_path.contains(".aws")); + assert_ne!(name, ".netrc"); + assert_ne!(name, ".git-credentials"); + assert_ne!(name, ".envrc"); + } + + // Verify evidence entries are sorted deterministically by id, then file_path + let mut prev_id = String::new(); + let mut prev_file_path = String::new(); + for entry in &state.evidence { + if entry.id == prev_id { + assert!( + entry.file_path >= prev_file_path, + "Paths out of order: '{}' vs '{}'", + prev_file_path, + entry.file_path + ); + } else { + assert!( + entry.id > prev_id, + "IDs out of order: '{}' vs '{}'", + prev_id, + entry.id + ); + } + prev_id = entry.id.clone(); + prev_file_path = entry.file_path.clone(); + } + + // Clean up + let _ = std::fs::remove_file(temp_state_file); + } }