Skip to content

Commit 9ba88d6

Browse files
committed
Make Phase 16 agent state capture deterministic
1 parent c1edde1 commit 9ba88d6

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

reports/phase_16_status.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
- **POLICY_DECISIONS**:
3333
- The local control plane is implemented strictly offline-only with no network connection.
3434
- The transient `.comptext` directory is ignored by file collection to prevent concurrent test race conditions.
35+
- Agent state capture evidence is deterministically sorted by ID and file path to guarantee a stable artifact output.
3536
- **RISKS**: Local checksums are supplementary change-detection metadata and do not represent unsupported assurance claims.
3637
- **NEXT**: Phase 16 Review-Gate closeout

src/cli.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,14 +1668,23 @@ fn is_sensitive_path(path: &std::path::Path) -> bool {
16681668
|| file_name == ".git"
16691669
|| file_name == ".ssh"
16701670
|| file_name == ".aws"
1671+
|| file_name == ".netrc"
1672+
|| file_name == ".git-credentials"
1673+
|| file_name == ".envrc"
16711674
{
16721675
return true;
16731676
}
16741677
}
16751678
for component in path.components() {
16761679
if let std::path::Component::Normal(os_str) = component {
16771680
if let Some(s) = os_str.to_str() {
1678-
if s == ".git" || s == ".ssh" || s == ".aws" {
1681+
if s == ".git"
1682+
|| s == ".ssh"
1683+
|| s == ".aws"
1684+
|| s == ".netrc"
1685+
|| s == ".git-credentials"
1686+
|| s == ".envrc"
1687+
{
16791688
return true;
16801689
}
16811690
}
@@ -1746,6 +1755,9 @@ fn handle_state_capture(task: &str) -> Result<(), String> {
17461755
let mut evidence = Vec::new();
17471756
collect_files_recursive(&current_dir, &current_dir, &mut evidence)?;
17481757

1758+
// Sort evidence by id, then by file_path to guarantee stable/deterministic order
1759+
evidence.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.file_path.cmp(&b.file_path)));
1760+
17491761
let state = AgentState {
17501762
schema_version: "0.1".to_string(),
17511763
task: task.to_string(),
@@ -2760,6 +2772,24 @@ mod tests {
27602772
.unwrap_err()
27612773
.contains("Accessing secrets or sensitive files"));
27622774

2775+
let verify_netrc_res = handle_state_verify(".netrc");
2776+
assert!(verify_netrc_res.is_err());
2777+
assert!(verify_netrc_res
2778+
.unwrap_err()
2779+
.contains("Accessing secrets or sensitive files"));
2780+
2781+
let verify_gitcreds_res = handle_state_verify(".git-credentials");
2782+
assert!(verify_gitcreds_res.is_err());
2783+
assert!(verify_gitcreds_res
2784+
.unwrap_err()
2785+
.contains("Accessing secrets or sensitive files"));
2786+
2787+
let verify_envrc_res = handle_state_verify(".envrc");
2788+
assert!(verify_envrc_res.is_err());
2789+
assert!(verify_envrc_res
2790+
.unwrap_err()
2791+
.contains("Accessing secrets or sensitive files"));
2792+
27632793
// 2. Test state report rejects secrets in its own path
27642794
let report_env_res = handle_state_report(".env");
27652795
assert!(report_env_res.is_err());
@@ -2825,7 +2855,7 @@ mod tests {
28252855

28262856
let captured_content = std::fs::read_to_string(temp_state_file).unwrap();
28272857
let state: AgentState = serde_json::from_str(&captured_content).unwrap();
2828-
for entry in state.evidence {
2858+
for entry in &state.evidence {
28292859
let path = std::path::Path::new(&entry.file_path);
28302860
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
28312861
assert_ne!(name, ".env");
@@ -2835,6 +2865,32 @@ mod tests {
28352865
assert!(!entry.file_path.contains(".git"));
28362866
assert!(!entry.file_path.contains(".ssh"));
28372867
assert!(!entry.file_path.contains(".aws"));
2868+
assert_ne!(name, ".netrc");
2869+
assert_ne!(name, ".git-credentials");
2870+
assert_ne!(name, ".envrc");
2871+
}
2872+
2873+
// Verify evidence entries are sorted deterministically by id, then file_path
2874+
let mut prev_id = String::new();
2875+
let mut prev_file_path = String::new();
2876+
for entry in &state.evidence {
2877+
if entry.id == prev_id {
2878+
assert!(
2879+
entry.file_path >= prev_file_path,
2880+
"Paths out of order: '{}' vs '{}'",
2881+
prev_file_path,
2882+
entry.file_path
2883+
);
2884+
} else {
2885+
assert!(
2886+
entry.id > prev_id,
2887+
"IDs out of order: '{}' vs '{}'",
2888+
prev_id,
2889+
entry.id
2890+
);
2891+
}
2892+
prev_id = entry.id.clone();
2893+
prev_file_path = entry.file_path.clone();
28382894
}
28392895

28402896
// Clean up

0 commit comments

Comments
 (0)