Consistency review fixes + comprehensive refactoring (simplicity, correctness, DRY, idiomatic Rust)#13
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a full HTTP/HTTPS proxy (credential injection, DLP, MITM, tunneling), reorganizes and expands puzzled-types domain types, implements BranchManager with commit/governance/WAL changes, adds many Linux sandbox/enforcement features, extensive CLI command surface, and numerous tests and plumbing updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as HTTP Client
participant Proxy as PuzzleProxy
participant PTM as PhantomTokenManager
participant Upstream as Upstream Server
participant DLP as DLP Engine
participant Audit as Audit Channel
Client->>Proxy: HTTP request (may include phantom token)
Proxy->>PTM: Resolve phantom token (if present)
alt token resolved
PTM-->>Proxy: Resolved credential
Proxy->>Proxy: Inject credential (header/query/AWS)
Proxy->>Upstream: Forward request / open tunnel / MITM
Upstream-->>Proxy: Response
Proxy->>DLP: Inspect response body (decompress if needed)
alt DLP allows
Proxy->>Proxy: Scan for echoed credential
alt leak detected
Proxy->>Audit: Emit leak/quarantine event
Proxy-->>Client: Block (502/403)
else no leak
Proxy->>Audit: Emit detected/redacted event
Proxy-->>Client: Forward/redacted response
end
else DLP blocks
Proxy->>Audit: Emit DLP block/quarantine
Proxy-->>Client: 403 Forbidden
end
else token invalid/absent
Proxy->>Audit: Emit denied/stripped event
Proxy-->>Client: 401 or forwarded without injection
end
sequenceDiagram
participant Agent as Branch Agent
participant Manager as BranchManager
participant Policy as PolicyEngine
participant Conflict as ConflictDetector
participant WAL as WalWriter
participant Audit as AuditLogger
Agent->>Manager: Request commit
Manager->>Manager: Freeze cgroup / snapshot
Manager->>Manager: Generate diff (includes entropy/base64 metadata)
Manager->>Policy: Evaluate snapshot (policy snapshot load)
alt policy approved
Manager->>Conflict: Reserve/check cross-branch conflicts
alt conflicts detected
Manager->>Manager: Resolve or reject
Manager-->>Agent: Rejected (conflict)
else no conflicts
Manager->>WAL: begin_commit_writer -> write ops -> finish
Manager->>Manager: Thaw / finalize / replay network journal
Manager->>Audit: Emit commit audit
Manager-->>Agent: Commit approved
end
else policy rejected
Manager->>Manager: Thaw / rollback
Manager-->>Agent: Rejected (policy)
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 9
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (7)
crates/puzzlectl/src/sim/repl.rs (1)
340-349:⚠️ Potential issue | 🟡 MinorMode is not restored after
executecompletes.The comment on line 342 says "Switching temporarily" to sandbox mode, but
saved_modecaptured at line 340 is never used to restore the original mode afterexecutecompletes. If the user was in Direct mode, they will remain in Sandbox mode after runningexecute.If the intent is truly temporary, restore the mode after the command finishes:
Proposed fix to restore mode
Add mode restoration at the end of the
"execute"branch (after line 375):None => { println!("[puzzled] APPROVED: changes committed"); } } + + // Restore original mode if it was changed temporarily + if saved_mode == SimMode::Direct && engine.mode() == SimMode::Sandbox { + engine.reset_with_mode(SimMode::Direct); + // No scenario to preserve here since execute already completed + } }Alternatively, if the mode change is intentional and permanent, update the message to remove "temporarily":
- eprintln!("Note: 'execute' uses sandbox mode. Switching temporarily."); + eprintln!("Note: 'execute' uses sandbox mode. Switching to sandbox.");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/repl.rs` around lines 340 - 349, You capture the original mode in saved_mode when switching to SimMode::Sandbox for the "execute" branch but never restore it, leaving the engine stuck in Sandbox; after the "execute" branch finishes, call engine.reset_with_mode(saved_mode) to restore the original mode (and if you previously preserved a scenario like current_scenario, reload it via engine.load(...) when restoring a non-sandbox mode) so the behavior truly remains temporary; refer to saved_mode, engine.mode(), SimMode::Direct, engine.reset_with_mode, engine.scenario(), and engine.load to locate where to add the restoration.crates/puzzle-proxy/tests/handler_tests.rs (1)
668-716:⚠️ Potential issue | 🟡 MinorUpdate stale assertion messages to match new split module paths.
The tests now read
handler/credentials.rs,handler/mod.rs, andhandler/forward.rs, but severalexpect(...)/ assertion texts still referencehandler.rs, which makes failures harder to triage.🛠️ Suggested wording cleanup
- .expect("G13: QueryParameter injection code must exist in handler.rs"); + .expect("G13: QueryParameter injection code must exist in handler/credentials.rs"); - .expect("G14: token extraction code must exist in handler.rs"); + .expect("G14: token extraction code must exist in handler/credentials.rs"); - .expect("G23: credential exfiltration check must exist in handler.rs"); + .expect("G23: credential exfiltration check must exist in handler/mod.rs");Also applies to: 737-737
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/tests/handler_tests.rs` around lines 668 - 716, Update stale expect/assertion messages that reference "handler.rs" to reflect the new split module paths (e.g., "handler/credentials.rs", "handler/mod.rs", "handler/forward.rs"). In tests such as test_g13 (look for the expect that checks QueryParameter injection), test_g14 (the token extraction expect), and test_g23 (the credential exfiltration expect and the nearby assertion around line ~737), replace the "handler.rs" text in the expect(...) / assert! messages with the correct module path for the piece of code being searched so failure messages point to the actual file being inspected.crates/puzzled/src/ima.rs (1)
219-253:⚠️ Potential issue | 🟠 MajorVerify the manifest before writing it out.
verify_manifest()now runs only afterpersist()succeeds, so a verification failure still leaves a manifest file behind and returns an error only after that side effect. Move the verification up so the write/log path runs only for a manifest that already passed validation.Minimal fix
let manifest = CommitManifest { branch_id: branch_id.as_str().to_string(), timestamp, agent_id: agent_id.to_string(), agent_profile: agent_profile.to_string(), checksum_before: checksum_before.to_string(), files, signature: sig_hex, }; + + self.verify_manifest(&manifest)?; // C4: Atomic manifest write — write to temp file, fsync, then rename. let manifest_path = self .manifest_dir .join(format!("{}.manifest.yaml", branch_id.as_str())); @@ - self.verify_manifest(&manifest)?; - Ok(manifest)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/ima.rs` around lines 219 - 253, The code currently writes and persists the manifest file (tmp.persist(&manifest_path)) before calling self.verify_manifest(&manifest), which can leave a file on disk if verification fails; move the verification up so verification happens prior to creating/writing/persisting the temp file. Concretely, call self.verify_manifest(&manifest)? immediately after creating/serializing the manifest (before creating NamedTempFile/write_all/persist), then proceed with the temp-file write, tmp.persist(&manifest_path), the tracing::info log, and the #[cfg(target_os = "linux")] self.extend_ima_log(&manifest_path); also remove or avoid the later duplicate self.verify_manifest(&manifest)? call so verification only occurs before the disk side-effect.crates/puzzled/src/profile.rs (1)
77-101:⚠️ Potential issue | 🟠 MajorValidate
read_denylistandwrite_denylistpaths as absolute too.
LandlockBuilder::apply()canonicalizes those lists directly, so a relative entry is resolved against the daemon’s current working directory and can silently miss the subtree you intended to exclude. The same absolute-path checks you added for the other filesystem lists need to cover these two fields as well.Suggested fix
for path in &profile.filesystem.denylist { if !path.is_absolute() { errors.push(format!( "denylist path must be absolute, got '{}'", path.display() )); } } + for path in &profile.filesystem.read_denylist { + if !path.is_absolute() { + errors.push(format!( + "read_denylist path must be absolute, got '{}'", + path.display() + )); + } + } + for path in &profile.filesystem.write_denylist { + if !path.is_absolute() { + errors.push(format!( + "write_denylist path must be absolute, got '{}'", + path.display() + )); + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/profile.rs` around lines 77 - 101, The validation loop currently checks absolute paths for profile.filesystem.read_allowlist, write_allowlist, and denylist but misses profile.filesystem.read_denylist and profile.filesystem.write_denylist; add identical checks for each entry in read_denylist and write_denylist (using path.is_absolute() and pushing the same formatted error messages like "read_denylist path must be absolute, got '{}'" and "write_denylist path must be absolute, got '{}'" to the errors vector) so relative entries cannot be silently canonicalized by LandlockBuilder::apply().crates/puzzled/src/commit.rs (1)
157-181:⚠️ Potential issue | 🟠 MajorClose the remaining symlink check/use race.
Lines 157-170 validate the current symlink target, but Line 232 opens
fromagain by path. If that entry can change between the check and the open, the copy can still follow a different target than the one you approved. Use the validated target for the read, or switch this to an fd-based/openat2-style open that prevents re-resolution.Also applies to: 232-234
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/commit.rs` around lines 157 - 181, The symlink TOCTOU: you validate the symlink target via std::fs::read_link/canonical_target (variables abs_target and canonical_target) but later reopen the path `from` (allowing the symlink to change). Fix by using the already-validated target for the read/copy instead of re-opening `from` — e.g., open the resolved canonical_target/abs_target (the same PathBuf you computed) for reading, or replace the reopen with an fd-based open (openat/openat2 or nix::openat) against the parent dir to avoid re-resolution; update the code that previously opens `from` (the later open at the lines referenced) to use the validated target or an O_NOFOLLOW/openat-style call to close the race.crates/puzzlectl/src/main.rs (1)
1583-1595:⚠️ Potential issue | 🟠 Major
puzzlectl policy testis still dropping metadata the daemon evaluates.The comment says
old_modeis included, but the JSON omits it; it also omits the newentropyandhas_base64_blocksfields. That means local policy tests can miss the metadata-driven rules added in this PR and disagree with runtime decisions.💡 Minimal fix
"changes": changes.iter().map(|c| { serde_json::json!({ "path": c.path.to_string_lossy(), "kind": format!("{:?}", c.kind), "size": c.size, "checksum": &c.checksum, "target": c.target.as_deref().unwrap_or(""), + "old_mode": c.old_mode, "new_mode": c.new_mode, + "entropy": c.entropy, + "has_base64_blocks": c.has_base64_blocks, }) }).collect::<Vec<_>>() });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/main.rs` around lines 1583 - 1595, The JSON built for policy tests inside the changes.iter().map closure is missing the metadata fields the daemon evaluates; update the object created in that closure (the mapping used to construct `input`) to include "old_mode" (from c.old_mode), "entropy" (from c.entropy) and "has_base64_blocks" (from c.has_base64_blocks) alongside the existing "target" and "new_mode" entries so local `puzzlectl policy test` matches runtime metadata-driven rules.crates/puzzled/src/policy.rs (1)
392-437:⚠️ Potential issue | 🟠 MajorWatcher saturation permanently inflates
leaked_policy_threads.Line 394 increments the leaked-thread counter before the watcher-limit check. Once Line 405 stops spawning a watcher, completed evaluation threads no longer have any path to decrement that counter, so a burst of timeouts can leave the engine rejecting every future evaluation until restart.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/policy.rs` around lines 392 - 437, The leaked_policy_threads counter is incremented before checking watcher saturation, so if we skip spawning a watcher (active_watcher_threads >= Self::MAX_WATCHER_THREADS) there is no later decrement path; move the fetch_add on self.leaked_policy_threads into the branch where we actually spawn a watcher (i.e., perform leaked_count = self.leaked_policy_threads.fetch_add(1, Ordering::Relaxed) inside the else just before cloning leaked_counter) or alternatively decrement it immediately when skipping watcher creation. Update the tracing logs to use the correct leaked_count/state (and keep active_watcher_threads, leaked_policy_threads, MAX_WATCHER_THREADS, watcher_counter references) so the counter only increases when a watcher exists to decrement it.
🟡 Minor comments (6)
crates/puzzled/src/seccomp_handler.rs-4-14 (1)
4-14:⚠️ Potential issue | 🟡 MinorUse approved comment prefixes instead of
WS9.Line 4-Line 14 and Line 41-Line 50 introduce
WS9tags, but this repo’s allowed source-comment prefixes areH/M/SC/PM/DC/L/A/B/C. Please retag these (e.g.,SC-WS9) to stay compliant.Suggested edit
-// WS9 LIMITATION: The single-threaded poll loop means one slow notification +// SC-WS9 LIMITATION: The single-threaded poll loop means one slow notification ... -/// WS9: Wall-clock budget from when the poll loop begins handling a notification +/// SC-WS9: Wall-clock budget from when the poll loop begins handling a notification ... -/// WS9: Notifications denied with EPERM because the wall-clock deadline was exceeded +/// SC-WS9: Notifications denied with EPERM because the wall-clock deadline was exceededAs per coding guidelines: "Use prefixed comment tags in source code: H (hardening), M (mitigation), SC (seccomp design), PM (Phase 2 feature), DC (design choice), L (lifecycle constraint), A/B/C (v6 audit fixes)."
Also applies to: 41-50
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/seccomp_handler.rs` around lines 4 - 14, Replace the non-compliant "WS9" comment tags in seccomp_handler.rs with an approved prefixed tag (e.g., change "WS9" to "SC-WS9") so the file uses allowed prefixes; update both occurrences (the top-block comment describing the single-threaded poll loop/timeout and the later comment at lines ~41-50) to use the new prefix, preserving the original text and meaning, and ensure any references in related symbols such as the seccomp handler/poll loop documentation remain consistent.crates/puzzled-types/src/audit.rs-45-50 (1)
45-50:⚠️ Potential issue | 🟡 MinorAdd missing
skip_serializing_ifattribute tobranch_id.The
branch_idfield inAuditRecordEventis the only optional field in either struct that lacks the#[serde(default, skip_serializing_if = "Option::is_none")]attribute. Without it, absentbranch_idvalues will serialize as"branch_id": nullinstead of being omitted, which breaks the documented wire format compatibility goal stated in the module docs (lines 7–8).Suggested fix
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct AuditRecordEvent { pub event_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub branch_id: Option<String>, pub details: serde_json::Value, }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/audit.rs` around lines 45 - 50, The branch_id field on struct AuditRecordEvent is missing the serde attribute to omit nulls; update the AuditRecordEvent definition so the branch_id field has #[serde(default, skip_serializing_if = "Option::is_none")] (matching the other optional fields) to ensure absent branch_id values are omitted from serialized output and preserve the documented wire-format compatibility.crates/puzzled/src/branch/tests.rs-765-786 (1)
765-786:⚠️ Potential issue | 🟡 MinorThis doesn't exercise
max_branchesenforcement.
insert_test_branch()writes directly intomanager.branches, so the admission logic increate()never runs. A regression in the limit check would still pass. Route this through the production creation path, or rename it to a pure map-count test.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/tests.rs` around lines 765 - 786, The test currently bypasses the admission logic by calling insert_test_branch which writes directly to manager.branches, so it never exercises BranchManager::create() and max_branches enforcement; update the test (or replace insert_test_branch usage) to add branches via the production creation path (call manager.create(...) or the public API that invokes BranchManager::create) so the max_branches limit (configured by make_test_manager) is enforced and the assertion fails when exceeded, or alternatively rename the test to indicate it only asserts direct map length without checking admission logic if you intend to keep the direct writes.crates/puzzled/src/branch/tests.rs-684-704 (1)
684-704:⚠️ Potential issue | 🟡 MinorThis test never persists the WAL state it claims to recover.
It only calls
recover()against an empty WAL twice, so a regression in startup replay would still pass. Either create an incomplete WAL record beforemanager2, or rename this to cover the empty-WAL case.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/tests.rs` around lines 684 - 704, The test test_recovery_on_startup_loads_persisted_wal_state currently only calls recover() on an empty WAL (via make_test_manager and manager1/manager2) so it never actually persists WAL state to exercise startup replay; modify the test to persist a WAL record before creating manager2 (for example, use manager1 to write a branch/operation to the WAL or append an incomplete WAL entry to the wal dir) so that manager2.recover() must load/replay that persisted state and then assert the expected branch appears via manager2.list(); alternatively, if you intended to test an empty-WAL startup, rename the test to reflect that behavior instead of claiming it loads persisted state.crates/puzzled-types/src/credential.rs-21-46 (1)
21-46:⚠️ Potential issue | 🟡 Minor
credential_mappings()silently drops credentials without Env exposure.When a
CredentialSpechas noCredentialExposure::Envvariant in itsexposelist,unwrap_or_default()on line 35 returns an empty string forenv_var. This means credentials with onlyFileexposure will be included in the result with an empty env_var, which could lead to confusing downstream behavior.Consider either filtering out specs without Env exposure, or returning
Option<String>for the env_var field.🔧 Proposed fix
pub fn credential_mappings(&self) -> Vec<(String, String, String, bool)> { let mut result = Vec::new(); for spec in &self.secrets { - let env_var = spec + // Skip credentials without Env exposure — they don't need phantom token mappings + let Some(env_var) = spec .expose .iter() .find_map(|e| match e { CredentialExposure::Env { var, .. } => Some(var.clone()), _ => None, }) - .unwrap_or_default(); + else { + continue; + }; for domain in &spec.domains {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/credential.rs` around lines 21 - 46, The credential_mappings() currently uses unwrap_or_default() so specs without a CredentialExposure::Env produce an empty env_var; instead, change credential_mappings() to skip any CredentialSpec that lacks an Env exposure: use find_map on spec.expose to get Option<String> (e.g., let env_var = spec.expose.iter().find_map(|e| match e { CredentialExposure::Env { var, .. } => Some(var.clone()), _ => None, }); if env_var.is_none() { continue; } ), then push (domain.clone(), spec.name.clone(), env_var.unwrap(), spec.required) for each domain; this ensures only specs with an actual Env variable are included.crates/puzzle-proxy/src/handler/forward.rs-94-122 (1)
94-122:⚠️ Potential issue | 🟡 MinorRequest body streaming uses wrong size constant.
Line 104 compares
total_body_sizeagainstMAX_RESPONSE_BODY_BYTESbut this is for the request body in the pinned-IP path. Should this useMAX_BODY_SIZEinstead for consistency with the fallback path (line 270)?The G25 comment says "size limit to prevent memory exhaustion" but the message says "request body too large" while using the response limit constant.
🐛 Proposed fix for consistent limit usage
+use super::MAX_BODY_SIZE; + // Stream body (G25: with size limit to prevent memory exhaustion) let mut body_parts = Vec::new(); let mut total_body_size: usize = 0; let mut body_stream = req.into_body(); use http_body_util::BodyExt; while let Some(frame_result) = body_stream.frame().await { match frame_result { Ok(frame) => { if let Ok(data) = frame.into_data() { total_body_size += data.len(); - if total_body_size > MAX_RESPONSE_BODY_BYTES { + if total_body_size > MAX_BODY_SIZE { tracing::warn!( total_body_size, - limit = MAX_RESPONSE_BODY_BYTES, + limit = MAX_BODY_SIZE, "G25: request body too large in pinned-IP path" );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/forward.rs` around lines 94 - 122, The request-body streaming loop in forward.rs uses the response-size constant MAX_RESPONSE_BODY_BYTES when checking total_body_size, which is incorrect for the request/pinned-IP path; change the comparison to use the request limit constant MAX_BODY_SIZE (replace MAX_RESPONSE_BODY_BYTES with MAX_BODY_SIZE) inside the loop that reads from body_stream and updates total_body_size, and update the log/error message if needed to remain consistent (symbols: total_body_size, body_stream, MAX_RESPONSE_BODY_BYTES -> MAX_BODY_SIZE).
🧹 Nitpick comments (7)
crates/puzzle-proxy/src/handler/util.rs (1)
96-103: Function namechrono_nowis misleading.The function doesn't use the
chronocrate — it usesstd::time::SystemTime. Consider renaming tounix_timestamp_now()ortimestamp_now()to avoid confusion.Suggested rename
-/// Get current timestamp as string. -pub(super) fn chrono_now() -> String { +/// Get current Unix timestamp as string. +pub(super) fn unix_timestamp_now() -> String { // Simple timestamp without chrono dependency🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/util.rs` around lines 96 - 103, The function chrono_now() is misleading since it uses std::time::SystemTime rather than the chrono crate; rename it to a clearer name such as unix_timestamp_now() (or timestamp_now()) and update all call sites, imports, tests, and documentation to use the new identifier; keep the signature and visibility (pub(super) fn ...) and preserve behavior (returning String with "0" on pre-epoch), and run cargo check/tests to ensure no remaining references to chrono_now remain.crates/puzzle-proxy/src/handler/credentials.rs (2)
405-424: Defense-in-depth stripping of credential headers when no phantom token found.The M-4 fix correctly strips
x-api-keyandproxy-authorizationheaders when no phantom token is detected. This prevents agents from sending raw credentials.Consider whether other common credential headers should be included (e.g.,
cookie,x-auth-token).🛡️ Consider expanding stripped headers
- for header_name in &["x-api-key", "proxy-authorization"] { + // Common credential-bearing headers that should only contain phantom tokens + for header_name in &[ + "x-api-key", + "proxy-authorization", + "x-auth-token", + "x-access-token", + ] { if parts.headers.remove(*header_name).is_some() {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 405 - 424, The stripping loop in credentials handling only removes "x-api-key" and "proxy-authorization"; expand the header list to include other common credential-bearing headers such as "cookie", "authorization", and "x-auth-token" to improve defense-in-depth. Update the array used in the for header_name in &[...] loop (the place that calls parts.headers.remove(*header_name) and logs with branch_id/header_name) so the same removal, tracing::warn, and stripped handling apply to the additional header names, then return the cleaned Request::from_parts(parts, body) as before.
716-731: Response leak detection has a subtle size check issue.Line 717 checks
scan_bytes.len() >= cred_bytes.len()before the firstwindows()call, but the subsequent checks forcred_base64,cred_url_encoded, andcred_html_entitycould callwindows()on ascan_bytesthat is smaller than those encoded variants. Whilewindows()returns an empty iterator if the window size exceeds the slice length, the explicitscan_bytes.len() >= cred_html_entity.len()check on line 728 suggests this was recognized for HTML entities but not for base64/URL encoding.This is not a bug (empty iterator is safe), but the inconsistency could be cleaned up.
♻️ Consistent size checks
let leaked = !cred_bytes.is_empty() - && scan_bytes.len() >= cred_bytes.len() && (scan_bytes + .len() >= cred_bytes.len() && scan_bytes .windows(cred_bytes.len()) .any(|w| w == cred_bytes) - || scan_bytes + || (scan_bytes.len() >= cred_base64.len() && scan_bytes .windows(cred_base64.len()) - .any(|w| w == cred_base64.as_bytes()) - || scan_bytes + .any(|w| w == cred_base64.as_bytes())) + || (scan_bytes.len() >= cred_url_encoded.len() && scan_bytes .windows(cred_url_encoded.len()) - .any(|w| w == cred_url_encoded.as_bytes()) + .any(|w| w == cred_url_encoded.as_bytes())) || (!cred_html_entity.is_empty() && scan_bytes.len() >= cred_html_entity.len() && scan_bytes .windows(cred_html_entity.len()) .any(|w| w == cred_html_entity.as_bytes())));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 716 - 731, The leak detection logic that computes `leaked` mixes a global `scan_bytes.len() >= cred_bytes.len()` check with subsequent `windows()` calls for other encodings, which is inconsistent; update the `leaked` expression in `credentials.rs` so each comparison first ensures `scan_bytes.len() >=` the respective pattern length (e.g., `cred_base64.len()`, `cred_url_encoded.len()`, and `cred_html_entity.len()`) before calling `.windows(...)`, or restructure into per-variant guarded checks (using `cred_bytes`, `cred_base64`, `cred_url_encoded`, `cred_html_entity` and `scan_bytes`) to make the size checks consistent and avoid relying on implicit empty iterators.crates/puzzled-types/src/policy.rs (1)
89-100: Consider using typed timestamps instead of raw strings.
AuditFilter.sinceusesOption<String>with a comment indicating RFC 3339 format. Usingchrono::DateTime<Utc>(already a dependency per the branch.rs file) would provide compile-time type safety and validation at deserialization time.♻️ Proposed change for type safety
use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use chrono::{DateTime, Utc}; use zbus::zvariant::Type; // ... pub struct AuditFilter { pub branch_id: Option<String>, pub event_type: Option<String>, - /// Filter events since this timestamp (RFC 3339). - pub since: Option<String>, + /// Filter events since this timestamp. + pub since: Option<DateTime<Utc>>, pub limit: Option<u32>, }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/policy.rs` around lines 89 - 100, AuditFilter currently stores since as Option<String> which defers RFC3339 validation; change AuditFilter::since to Option<chrono::DateTime<chrono::Utc>> so deserialization enforces correct timestamp format and gives type safety. Update the struct definition for AuditFilter (field since), adjust any serde attributes if needed (e.g., ensure RFC3339 parsing via serde feature) and update any code constructing or reading AuditFilter (tests, builders, query handlers) to parse/format DateTime<Utc> instead of raw strings; ensure imports reference chrono::DateTime and chrono::Utc and run tests to catch remaining call sites that expect String.crates/puzzle-proxy/src/handler/journal.rs (2)
72-80: Consider tightening malformed Content-Length handling.When Content-Length contains non-digit characters, the code logs a warning but then attempts to parse it anyway (line 81). If parsing fails, the request proceeds without size validation. For a security-sensitive journal path, consider rejecting malformed Content-Length headers outright rather than defaulting to allowing the request through.
🛡️ Proposed tightening
if let Some(cl) = headers.get("content-length") { let cl_str = cl.to_str().unwrap_or("0"); if !cl_str.bytes().all(|b| b.is_ascii_digit()) { tracing::warn!( branch = %branch_id, raw_value = %cl_str, "V6: malformed Content-Length in journal request, defaulting to 0" ); + // Fail-closed: reject requests with malformed Content-Length + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Malformed Content-Length header\n"))) + .unwrap()); } - if let Ok(len) = cl_str.parse::<usize>() {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/journal.rs` around lines 72 - 80, The Content-Length handling block (the if let Some(cl) = headers.get("content-length") { ... } that creates cl_str and logs via tracing::warn) currently logs malformed values but then continues to parse; change it to reject malformed Content-Length headers immediately: if cl_str contains any non-digit, return an error response (e.g., 400 Bad Request) or propagate an Err from the request handler instead of attempting to parse or defaulting to 0, and avoid silently unwrapping/clobbering the header value (replace unwrap_or("0") usage with safe to_str handling and early return). Ensure the rejection includes a clear log using branch_id and raw_value for diagnostics.
218-229: Header serialization may lose non-UTF-8 header values silently.Line 224 uses
unwrap_or("")when converting header values to strings, which silently drops non-UTF-8 header values. While HTTP headers should be ASCII/UTF-8, this could mask issues. Consider logging when a header value cannot be converted.🔍 Proposed improvement
headers: headers .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .filter_map(|(k, v)| { + match v.to_str() { + Ok(s) => Some((k.to_string(), s.to_string())), + Err(_) => { + tracing::warn!(header = %k, "skipping non-UTF-8 header value in journal entry"); + None + } + } + }) .collect(),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/journal.rs` around lines 218 - 229, The current header serialization in the JournalEntry construction uses header value.to_str().unwrap_or("") which silently drops non-UTF-8 values; update the loop that builds headers (the headers.iter().map(...) block) to detect to_str() errors, log a warning with the header name and raw bytes, and preserve the value in a reversible way (e.g., store a lossless representation such as a base64 or hex-encoded string with a clear prefix like "base64:...") so non-UTF-8 header values are not lost when creating crate::replay::JournalEntry.crates/puzzled-types/src/branch.rs (1)
97-119:From<String>panicking on invalid input is intentional but risky.The documentation clearly states this is for trusted/internal use, with
validated()recommended for external input. The G28 comment explains the design choice.However,
Fromis often called implicitly via.into(), which could lead to unexpected panics. Consider whether aTryFromimplementation would be safer while still providing a convenient API.🛡️ Consider adding TryFrom as the primary conversion
+impl TryFrom<String> for BranchId { + type Error = String; + + fn try_from(s: String) -> Result<Self, Self::Error> { + Self::validated(s) + } +} + impl From<String> for BranchId { /// Create a BranchId from a string. /// - /// G28: Always validates input. Panics on invalid input in both debug and - /// release builds to prevent silently accepting path-traversal or injection - /// attacks. This is intended for internal/trusted use (e.g., UUIDs generated - /// by `BranchId::new()`, test fixtures). For external/untrusted input - /// (D-Bus, CLI arguments), use `BranchId::validated()` instead. + /// # Panics + /// Panics on invalid input. For fallible conversion, use `TryFrom` or `validated()`. fn from(s: String) -> Self { - match Self::validated(s.clone()) { - Ok(id) => id, - Err(e) => { - eprintln!( - "ERROR: G28: BranchId::from() called with invalid input '{}': {}", - s, e - ); - panic!("G28: BranchId::from() called with invalid input: {e}"); - } - } + Self::try_from(s).expect("BranchId::from() called with invalid input") } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/branch.rs` around lines 97 - 119, The current impl From<String> for BranchId panics on invalid input (using BranchId::validated) which is risky because .into() is implicit; add an impl TryFrom<String> for BranchId that returns Result<BranchId, ValidationError> by delegating to BranchId::validated(), and update or remove the existing From<String> impl (or make it explicitly internal/trusted, e.g., call BranchId::new() or document it clearly) so external callers use TryFrom/.try_into() instead; ensure documentation for BranchId, BranchId::validated, BranchId::new, From<String>, and the new TryFrom<String> impl is updated to reflect the safer API.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 64bbdb37-cf51-4b72-b4c5-8d645de76376
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (97)
crates/puzzle-hook/src/main.rscrates/puzzle-init/Cargo.tomlcrates/puzzle-init/src/main.rscrates/puzzle-phase2-demo/src/main.rscrates/puzzle-proxy/src/handler.rscrates/puzzle-proxy/src/handler/connect.rscrates/puzzle-proxy/src/handler/credentials.rscrates/puzzle-proxy/src/handler/dlp_inspect.rscrates/puzzle-proxy/src/handler/forward.rscrates/puzzle-proxy/src/handler/intercept.rscrates/puzzle-proxy/src/handler/journal.rscrates/puzzle-proxy/src/handler/mod.rscrates/puzzle-proxy/src/handler/routing.rscrates/puzzle-proxy/src/handler/tls.rscrates/puzzle-proxy/src/handler/transparent.rscrates/puzzle-proxy/src/handler/util.rscrates/puzzle-proxy/src/lib.rscrates/puzzle-proxy/src/secure_memory.rscrates/puzzle-proxy/tests/handler_tests.rscrates/puzzlectl/src/bin/puzzle_sim_worker.rscrates/puzzlectl/src/compliance.rscrates/puzzlectl/src/main.rscrates/puzzlectl/src/sim/engine.rscrates/puzzlectl/src/sim/repl.rscrates/puzzled-types/src/attestation.rscrates/puzzled-types/src/audit.rscrates/puzzled-types/src/behavioral.rscrates/puzzled-types/src/branch.rscrates/puzzled-types/src/change.rscrates/puzzled-types/src/credential.rscrates/puzzled-types/src/identity.rscrates/puzzled-types/src/lib.rscrates/puzzled-types/src/policy.rscrates/puzzled-types/src/profile.rscrates/puzzled-types/src/provenance.rscrates/puzzled-types/src/trust.rscrates/puzzled/Cargo.tomlcrates/puzzled/benches/branch.rscrates/puzzled/benches/policy.rscrates/puzzled/benches/wal.rscrates/puzzled/src/attestation.rscrates/puzzled/src/audit_store.rscrates/puzzled/src/branch.rscrates/puzzled/src/branch/activate.rscrates/puzzled/src/branch/cleanup.rscrates/puzzled/src/branch/commit_flow.rscrates/puzzled/src/branch/mod.rscrates/puzzled/src/branch/tests.rscrates/puzzled/src/commit.rscrates/puzzled/src/config.rscrates/puzzled/src/conflict.rscrates/puzzled/src/dbus.rscrates/puzzled/src/dbus_helpers.rscrates/puzzled/src/diff.rscrates/puzzled/src/identity.rscrates/puzzled/src/ima.rscrates/puzzled/src/landlock_rules.rscrates/puzzled/src/lib.rscrates/puzzled/src/main.rscrates/puzzled/src/policy.rscrates/puzzled/src/profile.rscrates/puzzled/src/provenance.rscrates/puzzled/src/sandbox/bpf_lsm.rscrates/puzzled/src/sandbox/cgroup.rscrates/puzzled/src/sandbox/fanotify.rscrates/puzzled/src/sandbox/landlock.rscrates/puzzled/src/sandbox/mod.rscrates/puzzled/src/sandbox/namespace.rscrates/puzzled/src/sandbox/overlay.rscrates/puzzled/src/sandbox/quota.rscrates/puzzled/src/sandbox/seccomp/filter.rscrates/puzzled/src/sandbox/seccomp/mod.rscrates/puzzled/src/sandbox/seccomp/notif.rscrates/puzzled/src/seccomp_handler.rscrates/puzzled/src/seccomp_profile.rscrates/puzzled/src/sync_util.rscrates/puzzled/src/test_helpers.rscrates/puzzled/src/wal.rscrates/puzzled/tests/branch_lifecycle.rscrates/puzzled/tests/crash_recovery.rscrates/puzzled/tests/cross_module_integration.rscrates/puzzled/tests/e2e_adversarial.rscrates/puzzled/tests/ima_integration.rscrates/puzzled/tests/phase2_features.rscrates/puzzled/tests/podman_native_integration.rscrates/puzzled/tests/policy_evaluation.rscrates/puzzled/tests/rogue_agent.rscrates/puzzled/tests/seccomp_notif_handler.rscrates/puzzled/tests/security_hardening.rsdocs/compliance/eu-ai-act.mddocs/compliance/nist-ai-rmf.mddocs/security-guide.mddocs/technical-design.mdpolicies/profiles/restricted.yamlpolicies/profiles/standard.yamlpolicies/rules/commit.regopolicies/schemas/profile.schema.json
| // C-1/M-2: Apply Landlock network rules (ABI v4+, kernel 6.7+). | ||
| // | ||
| // Mirrors sandbox/landlock.rs: irrevocable ConnectTcp/BindTcp restrictions | ||
| // that survive puzzled crash. Without this, Podman-native mode lacks the | ||
| // kernel-enforced network ACL layer that direct mode provides. | ||
| if !rules.connect_tcp_ports.is_empty() || !rules.bind_tcp_ports.is_empty() { | ||
| use landlock::{AccessNet, NetPort}; | ||
|
|
||
| let mut net_ruleset = Ruleset::default() | ||
| .handle_access(AccessNet::ConnectTcp | AccessNet::BindTcp) | ||
| .map_err(|e| format!("creating Landlock network ruleset: {e}"))? | ||
| .create() | ||
| .map_err(|e| format!("creating Landlock network ruleset: {e}"))?; | ||
|
|
||
| for port in &rules.connect_tcp_ports { | ||
| net_ruleset = net_ruleset | ||
| .add_rule(NetPort::new(*port, AccessNet::ConnectTcp)) | ||
| .map_err(|e| format!("adding Landlock ConnectTcp rule for port {port}: {e}"))?; | ||
| } | ||
|
|
||
| for port in &rules.bind_tcp_ports { | ||
| net_ruleset = net_ruleset | ||
| .add_rule(NetPort::new(*port, AccessNet::BindTcp)) | ||
| .map_err(|e| format!("adding Landlock BindTcp rule for port {port}: {e}"))?; | ||
| } | ||
|
|
||
| // H-24: Apply network ruleset FIRST (broader surface), before | ||
| // filesystem ruleset (more granular). Both applied pre-exec. | ||
| let net_status = net_ruleset | ||
| .restrict_self() | ||
| .map_err(|e| format!("applying Landlock network ruleset: {e}"))?; | ||
|
|
||
| match net_status.ruleset { | ||
| RulesetStatus::FullyEnforced => { | ||
| eprintln!( | ||
| "puzzle-init: Landlock network rules enforced (ConnectTcp: {} ports, BindTcp: {} ports)", | ||
| rules.connect_tcp_ports.len(), | ||
| rules.bind_tcp_ports.len() | ||
| ); | ||
| } | ||
| RulesetStatus::PartiallyEnforced => { | ||
| eprintln!( | ||
| "puzzle-init: Landlock network rules partially enforced — \ | ||
| kernel may not support ABI v4 (requires 6.7+)" | ||
| ); | ||
| } | ||
| RulesetStatus::NotEnforced => { | ||
| eprintln!( | ||
| "puzzle-init: Landlock network rules not enforced — \ | ||
| kernel does not support ABI v4 (requires 6.7+). \ | ||
| Falling back to seccomp + nftables for network gating." | ||
| ); | ||
| } |
There was a problem hiding this comment.
Don’t let the new Landlock port ACLs degrade silently.
This path is still fail-open: the if !rules.connect_tcp_ports.is_empty() || !rules.bind_tcp_ports.is_empty() guard skips the network ruleset entirely when both lists are empty, even though the field docs say empty should deny all, and PartiallyEnforced/NotEnforced only warn and continue. In both cases the requested ConnectTcp/BindTcp policy can disappear while the container still execs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-init/src/main.rs` around lines 265 - 317, The network Landlock
path currently skips creating/applying a network ruleset when both
rules.connect_tcp_ports and rules.bind_tcp_ports are empty, which allows a
silent fail-open; fix by removing that guard so you always create a net_ruleset
(using Ruleset::default()...create()) even if no ports are added (an empty
ruleset means "deny all"), then call net_ruleset.restrict_self() and treat any
RulesetStatus other than RulesetStatus::FullyEnforced (PartiallyEnforced or
NotEnforced) as an error (return/map_err/bail) instead of only logging; update
the match on net_status.ruleset to propagate an error for non-FullyEnforced
cases so the requested ConnectTcp/BindTcp policy cannot disappear silently.
| if credential_mode == puzzled_types::CredentialMode::Blocked { | ||
| let before_len = headers.len(); | ||
| headers.retain(|(name, _)| { | ||
| let lower = name.to_lowercase(); | ||
| lower != "authorization" && lower != "proxy-authorization" && lower != "x-api-key" | ||
| }); | ||
| if headers.len() < before_len { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| stripped = before_len - headers.len(), | ||
| "D-I3/§3.4: credential mode=Blocked, stripped auth headers in TLS intercept path" | ||
| ); | ||
| } | ||
| } else if credential_mode == puzzled_types::CredentialMode::Phantom { | ||
| if let Some(ref ptm) = phantom_token_manager { | ||
| let mgr = ptm.read().await; | ||
| // Check each header for phantom tokens, stripping auth scheme prefixes | ||
| let mut inject_header_idx: Option<usize> = None; | ||
| let mut inject_token: Option<String> = None; | ||
| // Q5: Removed dead `_inject_auth_prefix` variable (was unused after D-C4 refactor) | ||
| for (idx, (_name, value)) in headers.iter().enumerate() { | ||
| // D-C4: Strip "Bearer " or "Basic " prefix before checking, | ||
| // matching the non-TLS path in inject_credentials(). | ||
| // H61: Use case-insensitive matching for auth scheme prefixes | ||
| let (_prefix, token_part) = | ||
| if value.len() >= 7 && value[..7].eq_ignore_ascii_case("Bearer ") { | ||
| (Some("Bearer "), &value[7..]) | ||
| } else if value.len() >= 6 && value[..6].eq_ignore_ascii_case("Basic ") { | ||
| (Some("Basic "), &value[6..]) | ||
| } else { | ||
| (None, value.as_str()) | ||
| }; | ||
| if mgr.is_phantom_token(token_part) { | ||
| inject_header_idx = Some(idx); | ||
| inject_token = Some(token_part.to_string()); | ||
| break; | ||
| } | ||
| } | ||
| if let Some((idx, token)) = inject_header_idx.zip(inject_token) { | ||
| if let Some(resolved) = mgr.resolve(&token, Some(branch_id)).await { | ||
| // Domain scope check | ||
| let domain_ok = resolved | ||
| .target_domains | ||
| .iter() | ||
| .any(|d| crate::credentials::domain_matches(domain, d)); | ||
| if domain_ok { | ||
| // Profile check | ||
| let profile_ok = match &agent_profile { | ||
| Some(prof) => resolved | ||
| .allowed_profiles | ||
| .iter() | ||
| .any(|p| p == "*" || p == prof), | ||
| None => true, | ||
| }; | ||
| if profile_ok { | ||
| // D-C1: Use helper that dispatches on InjectionMethod | ||
| injected_credential_value = inject_credential_into_header_vec( | ||
| &mut headers, | ||
| &mut path, | ||
| &resolved, | ||
| idx, | ||
| ); | ||
| // N10: Capture custom header name for journal redaction. | ||
| if let InjectionMethod::CustomHeader { ref header_name } = | ||
| resolved.injection | ||
| { | ||
| injected_custom_header_name = Some(header_name.clone()); | ||
| } | ||
| // N5: Track query parameter injection for URI redaction. | ||
| if matches!( | ||
| resolved.injection, | ||
| InjectionMethod::QueryParameter { .. } | ||
| ) { | ||
| injected_via_query_param = true; | ||
| } | ||
| tracing::info!( | ||
| branch = %branch_id, | ||
| credential = %resolved.credential_name, | ||
| "§3.4/C4: credential injected in TLS intercept path" | ||
| ); | ||
| if let Some(ref sender) = audit_sender { | ||
| send_audit( | ||
| sender, | ||
| crate::ProxyAuditEvent::CredentialInjected { | ||
| branch_id: branch_id.clone(), | ||
| credential_name: resolved.credential_name.clone(), | ||
| domain: domain.to_string(), | ||
| }, | ||
| ); | ||
| // §3.4 T2.1: Emit CredentialResolved for D-Bus signal | ||
| send_audit( | ||
| sender, | ||
| crate::ProxyAuditEvent::CredentialResolved { | ||
| branch_id: branch_id.clone(), | ||
| credential_name: resolved.credential_name.clone(), | ||
| domain: domain.to_string(), | ||
| }, | ||
| ); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| credential = %resolved.credential_name, | ||
| "§3.4/C4: credential not authorized for this profile" | ||
| ); | ||
| let error_body = "Credential not authorized for this profile\n"; | ||
| let error_response = format!( | ||
| "HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | ||
| error_body.len(), error_body | ||
| ); | ||
| if let Err(e) = agent_tls.write_all(error_response.as_bytes()).await | ||
| { | ||
| tracing::debug!(error = %e, "L40: failed to write TLS error response to agent"); | ||
| } | ||
| if let Err(e) = agent_tls.flush().await { | ||
| tracing::debug!(error = %e, "L40: failed to flush TLS error response to agent"); | ||
| } | ||
| return Ok(()); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| credential = %resolved.credential_name, | ||
| "§3.4/C4: credential not authorized for domain '{}'", domain | ||
| ); | ||
| let error_body = "Credential not authorized for this domain\n"; | ||
| let error_response = format!( | ||
| "HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | ||
| error_body.len(), error_body | ||
| ); | ||
| if let Err(e) = agent_tls.write_all(error_response.as_bytes()).await { | ||
| tracing::debug!(error = %e, "L40: failed to write TLS error response to agent"); | ||
| } | ||
| if let Err(e) = agent_tls.flush().await { | ||
| tracing::debug!(error = %e, "L40: failed to flush TLS error response to agent"); | ||
| } | ||
| return Ok(()); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "§3.4/C4: invalid or expired phantom token in TLS intercept" | ||
| ); | ||
| let error_body = "Invalid or expired phantom token\n"; | ||
| let error_response = format!( | ||
| "HTTP/1.1 401 Unauthorized\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | ||
| error_body.len(), error_body | ||
| ); | ||
| if let Err(e) = agent_tls.write_all(error_response.as_bytes()).await { | ||
| tracing::debug!(error = %e, "L40: failed to write TLS error response to agent"); | ||
| } | ||
| if let Err(e) = agent_tls.flush().await { | ||
| tracing::debug!(error = %e, "L40: failed to flush TLS error response to agent"); | ||
| } | ||
| return Ok(()); | ||
| } | ||
| } | ||
|
|
||
| // D-C2: Strip any remaining phantom tokens from other headers. | ||
| // If the agent sent phantom tokens in multiple headers (e.g., both | ||
| // Authorization and X-Api-Key), only one was resolved above. Any | ||
| // unresolved phantom tokens must be removed to prevent leaking the | ||
| // phantom token prefix pattern to upstream. | ||
| let resolved_idx = inject_header_idx; // the index we already resolved | ||
| let mut indices_to_remove: Vec<usize> = Vec::new(); | ||
| for (idx, (_name, value)) in headers.iter().enumerate() { | ||
| if Some(idx) == resolved_idx { | ||
| continue; // already handled | ||
| } | ||
| // J43: Case-insensitive prefix matching per RFC 7235 §2.1 | ||
| let token_part = | ||
| if value.len() >= 7 && value[..7].eq_ignore_ascii_case("bearer ") { | ||
| &value[7..] | ||
| } else if value.len() >= 6 && value[..6].eq_ignore_ascii_case("basic ") { | ||
| &value[6..] | ||
| } else { | ||
| value.as_str() | ||
| }; | ||
| if mgr.is_phantom_token(token_part) { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| header_name = %_name, | ||
| "D-C2: stripping additional phantom token from header (not resolved, would leak to upstream)" | ||
| ); | ||
| indices_to_remove.push(idx); | ||
| } | ||
| } | ||
| // Remove in reverse order to preserve indices | ||
| for idx in indices_to_remove.into_iter().rev() { | ||
| headers.remove(idx); | ||
| } | ||
|
|
||
| // D-I2/M-4: If no phantom token was found, strip all credential-bearing | ||
| // headers (defense-in-depth). The agent should only use phantom tokens; | ||
| // any other auth header is fabricated or leaked. PRD §3.4.8 Step 5. | ||
| if inject_header_idx.is_none() { | ||
| let before_len = headers.len(); | ||
| headers.retain(|(name, _)| { | ||
| !name.eq_ignore_ascii_case("authorization") | ||
| && !name.eq_ignore_ascii_case("x-api-key") | ||
| && !name.eq_ignore_ascii_case("proxy-authorization") | ||
| }); | ||
| if headers.len() < before_len { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "D-I2/M-4/§3.4: stripped non-phantom credential headers in TLS intercept path" | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Mirror the non-TLS fail-closed behavior when phantom_token_manager is absent.
handle_request() strips auth headers in Phantom mode when no PTM exists, but this TLS path leaves them untouched because the whole branch is gated by if let Some(ref ptm). HTTPS requests can therefore forward raw Authorization or X-Api-Key headers upstream whenever PTM is missing or failed to initialize.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/intercept.rs` around lines 337 - 546, The TLS
path currently only strips/handles phantom tokens when phantom_token_manager is
Some(...), leaving auth headers untouched if the PTM is missing; add an else
branch for the if let Some(ref ptm) in intercept.rs that performs the same
fail-closed behavior as the non-TLS path: record before_len, call
headers.retain(...) to drop authorization, proxy-authorization and x-api-key
(using case-insensitive checks like name.eq_ignore_ascii_case), emit a
tracing::warn (include branch = %branch_id and the same D-I2/M-4/§3.4 message),
and preserve the existing control flow (no early return) so TLS requests never
forward raw credentials when PTM is absent. Use the same local
variables/structure as the existing Phantom branch (headers, branch_id,
before_len) so the change is minimal and consistent.
| // §4.3: Domain matching prevents credential injection to wrong domain | ||
| #[test] | ||
| fn test_domain_matching_prevents_exfiltration() { | ||
| use crate::credentials::domain_matches; | ||
|
|
||
| // Exact domain match | ||
| assert!(domain_matches("api.example.com", "api.example.com")); | ||
|
|
||
| // Wildcard domain match | ||
| assert!(domain_matches("sub.example.com", "*.example.com")); | ||
|
|
||
| // Non-matching domain must NOT match | ||
| assert!( | ||
| !domain_matches("evil.attacker.com", "api.example.com"), | ||
| "§4.3: non-matching domain must be rejected to prevent credential exfiltration" | ||
| ); | ||
| assert!( | ||
| !domain_matches("evil.attacker.com", "*.example.com"), | ||
| "§4.3: wildcard must not match different TLD" | ||
| ); | ||
|
|
||
| // Subdomain of attacker-controlled wildcard must not match legitimate domain | ||
| assert!( | ||
| !domain_matches("api.example.com", "*.attacker.com"), | ||
| "§4.3: legitimate domain must not match attacker wildcard" | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify domain_matches function exists in credentials module
rg -n "pub fn domain_matches" crates/puzzle-proxy/src/Repository: LobsterTrap/puzzlepod
Length of output: 168
🏁 Script executed:
rg -n "is_domain_allowed" crates/puzzle-proxy/src/Repository: LobsterTrap/puzzlepod
Length of output: 1919
🏁 Script executed:
sed -n '853,870p' crates/puzzle-proxy/src/credentials.rsRepository: LobsterTrap/puzzlepod
Length of output: 592
🏁 Script executed:
sed -n '420,450p' crates/puzzle-proxy/src/handler/routing.rsRepository: LobsterTrap/puzzlepod
Length of output: 1424
Inconsistent domain matching logic between domain_matches and is_domain_allowed — refactor needed.
The test imports domain_matches from the credentials module, but this function has different behavior than is_domain_allowed in the routing module. Specifically:
domain_matches("example.com", "*.example.com")returns false (does not match bare domain)is_domain_allowed("example.com", &["*.example.com"])returns true (matches bare domain via explicit check at line ~449)
The routing module's is_domain_allowed includes H5 hardening with dot-boundary enforcement and explicitly handles bare domain matching. The test should use a consistent matching function or clearly document why two different implementations are acceptable. Additionally, the test doesn't cover the bare domain case, which would expose this inconsistency.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/routing.rs` around lines 649 - 675, The test
uses domain_matches from crate::credentials but routing uses is_domain_allowed
with different behavior (bare domain matching and dot-boundary hardening),
causing inconsistency; update the test to exercise the same matching logic used
by routing by either (A) importing and asserting against is_domain_allowed
instead of domain_matches, or (B) add explicit assertions for the bare-domain
case and document the divergence, and then refactor domain_matches (in
credentials) to match is_domain_allowed semantics (dot-boundary enforcement and
bare-domain acceptance) if you want a single canonical implementation; refer to
the functions domain_matches and is_domain_allowed when making the change.
| let domain = match crate::tls::extract_sni(&peek_buf[..n]) { | ||
| Some(domain) => domain, | ||
| None => { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "§3.4 G7: no SNI in ClientHello — cannot determine upstream hostname" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| tracing::info!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| "§3.4 G7: transparent proxy connection — SNI extracted" | ||
| ); | ||
|
|
||
| // 2. Issue a leaf cert for the domain | ||
| let (leaf_cert_der, leaf_key_der) = match ca.issue_leaf_cert(&domain) { | ||
| Ok(pair) => pair, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| error = %e, | ||
| "§3.4 G7: failed to issue leaf cert for transparent connection" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // 3. Accept TLS from the agent using the leaf cert | ||
| let tls_acceptor = match build_tls_acceptor(leaf_cert_der, leaf_key_der) { | ||
| Ok(acceptor) => acceptor, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| error = %e, | ||
| "§3.4 G7: failed to build TLS acceptor" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| let agent_tls = match tls_acceptor.accept(stream).await { | ||
| Ok(stream) => stream, | ||
| Err(e) => { | ||
| tracing::debug!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| error = %e, | ||
| "§3.4 G7: TLS handshake with agent failed" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // 4. Connect to upstream with TLS | ||
| let target_with_port = format!("{}:443", domain); | ||
| let upstream_tcp = match tokio::net::TcpStream::connect(&target_with_port).await { | ||
| Ok(stream) => stream, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| error = %e, | ||
| "§3.4 G7: failed to connect to upstream" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| let upstream_tls = match connect_upstream_tls(upstream_tcp, &domain).await { | ||
| Ok(stream) => stream, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| branch = %branch_id, | ||
| domain = %domain, | ||
| error = %e, | ||
| "§3.4 G7: upstream TLS handshake failed" | ||
| ); | ||
| return; | ||
| } | ||
| }; | ||
|
|
||
| // 5. Run the shared MITM pipeline (same as CONNECT path) | ||
| if let Err(e) = | ||
| handle_intercepted_stream(agent_tls, upstream_tls, &domain, &target_with_port, &ctx).await | ||
| { |
There was a problem hiding this comment.
Transparent mode currently bypasses the domain gate.
Once SNI is extracted, this handler goes straight to TcpStream::connect() / connect_upstream_tls() without checking ProxyMode, the denylist, or either allowlist. Since the transparent path only gets ctx, it can establish upstream sessions that the explicit proxy path would reject.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/transparent.rs` around lines 46 - 135,
Transparent handler extracts SNI then proceeds to connect upstream without
applying proxy policy checks; before calling
TcpStream::connect/connect_upstream_tls and before issuing or using the leaf
cert, replicate the explicit-proxy checks using the same symbols (inspect ctx
and its ProxyMode, run the denylist/allowlist checks for domain and target, and
enforce any per-branch policy) and return/log appropriately when the domain is
disallowed; ensure the same gating logic used by the explicit CONNECT path is
invoked (i.e., consult ctx.ProxyMode, the denylist/allowlist validators, and any
allowlist functions) so handle_intercepted_stream/connect_upstream_tls is only
reached for permitted domains.
| let credential_proxy = profile.credentials.as_ref().and_then(|cred_config| { | ||
| if !cred_config.proxy.enabled { | ||
| return None; | ||
| } | ||
| // M-9: Fail-closed — do not silently default to localhost if | ||
| // proxy_listen_addr is invalid. Wrong gateway IP breaks the | ||
| // defense-in-depth model entirely. | ||
| let gateway_ip = match self | ||
| .config | ||
| .network | ||
| .proxy_listen_addr | ||
| .parse::<std::net::IpAddr>() | ||
| { | ||
| Ok(ip) => ip, | ||
| Err(e) => { | ||
| tracing::error!( | ||
| addr = %self.config.network.proxy_listen_addr, | ||
| error = %e, | ||
| "M-9: credential proxy requires valid proxy_listen_addr; \ | ||
| cannot activate branch with credential proxy" | ||
| ); | ||
| return None; | ||
| } | ||
| }; | ||
| let global_port_range = self | ||
| .config | ||
| .credential_proxy | ||
| .parse_port_range() | ||
| .unwrap_or(18000..=18999); | ||
| // M-10: proxy_port is the per-branch allocated credential proxy port. | ||
| // At seccomp registration time, the actual port may not be allocated yet | ||
| // (it's assigned during ProvisionCredentials). The global_port_range | ||
| // blocks the entire range, providing security coverage regardless. | ||
| // Use the range start as a placeholder; the critical defense is the | ||
| // global_port_range check in validate_connect(). | ||
| let proxy_port = *global_port_range.start(); | ||
| Some(crate::seccomp_handler::CredentialProxyContext { | ||
| enabled: true, | ||
| proxy_gateway_ip: gateway_ip, | ||
| proxy_port, | ||
| proxied_ports: cred_config.proxy.ports.clone(), | ||
| global_port_range, | ||
| }) | ||
| }); | ||
| handler.register(notify_fd, id.clone(), profile.clone(), credential_proxy)?; |
There was a problem hiding this comment.
Invalid proxy_listen_addr currently disables the credential-proxy guard instead of failing closed.
When proxying is enabled and the address parse fails, this closure returns None and handler.register(...) continues without a CredentialProxyContext. That silently turns off the direct-connect defense on the exact config error this block says should stop activation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/activate.rs` around lines 82 - 126, The closure
that builds credential_proxy currently swallows a parse error for
self.config.network.proxy_listen_addr by returning None, which disables the
credential-proxy guard; instead, propagate a failure so branch activation
aborts: change the closure/logic around credential_proxy (the code that
constructs crate::seccomp_handler::CredentialProxyContext) to return or
propagate an Err (or otherwise early-return from the surrounding activation
function) when proxy_listen_addr.parse::<IpAddr>() fails, keep the
tracing::error logging, and ensure handler.register(...) is only called with a
successful credential proxy construction (or that the error is returned up to
the caller) so activation fails closed rather than silently disabling the guard.
| // SH1: Send seccomp ACK to child AFTER registering the notify fd. | ||
| // The child blocks on this ACK before calling execve(), ensuring the | ||
| // SeccompNotifHandler is polling the notify fd before any USER_NOTIF- | ||
| // gated syscalls occur. | ||
| crate::sandbox::send_seccomp_ack(&mut handle)?; |
There was a problem hiding this comment.
Provision credentials before releasing the child or register a rollback.
send_seccomp_ack(), branch state mutation, and resource insertion all happen before issue_for_branch(). If required credentials are unavailable, activate_branch() returns Err after the child has already been unblocked and the branch has been marked Active, leaving a live sandbox behind the failure path.
Also applies to: 418-504
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/activate.rs` around lines 130 - 134, The branch
activation currently calls crate::sandbox::send_seccomp_ack and mutates branch
state (marking it Active and inserting resources) before calling
issue_for_branch(), so failures in credential provisioning leave a live sandbox;
update activate_branch() to provision required credentials (or call
issue_for_branch()) before unblocking the child and flipping state, or, if that
ordering is not possible, register a rollback/cleanup that undoes the Active
state, removes inserted resources, and kills the child sandbox on any subsequent
error; locate the send_seccomp_ack call, the branch state mutation logic, and
the issue_for_branch() invocation and either move send_seccomp_ack after
successful issue_for_branch() (and credential checks) or add an error-path
cleanup that reverses the operations performed prior to send_seccomp_ack.
| // Step 3c: Check for cross-branch conflicts (after policy evaluation per PRD) | ||
| { | ||
| let mut detector = unlock_poisoned(self.conflict_detector.lock()); | ||
| detector.register_changes(id, &base_path, &changes); | ||
| let conflicts = detector.check_conflicts_with_time( | ||
| id, | ||
| &base_path, | ||
| &changes, | ||
| Some(branch_created_at), | ||
| ); | ||
| if !conflicts.is_empty() { | ||
| if let Some(m) = self.get_metrics() { | ||
| // Q9: Use try_from instead of bare `as u64` for len-to-u64 conversion | ||
| m.conflicts_total | ||
| .inc_by(u64::try_from(conflicts.len()).unwrap_or(u64::MAX)); | ||
| } | ||
| } | ||
| if let Err(e) = detector.resolve(&conflicts) { | ||
| tracing::warn!(branch = %id, error = %e, "cross-branch conflict detected"); | ||
| // Unregister and rollback | ||
| detector.unregister_branch(id); | ||
| drop(detector); | ||
| // Thaw cgroup before rollback | ||
| self.thaw_cgroup(id); | ||
| self.rollback_internal("conflict: cross-branch conflict detected", id)?; | ||
| return Ok(CommitResult { | ||
| branch_id: id.clone(), | ||
| files_committed: 0, | ||
| bytes_committed: 0, | ||
| policy_result: PolicyDecision::Rejected(vec![puzzled_types::Violation { | ||
| rule: "conflict_detection".to_string(), | ||
| message: e.to_string(), | ||
| severity: puzzled_types::ViolationSeverity::Error, | ||
| }]), | ||
| }); | ||
| } | ||
|
|
||
| // C8: Two-phase conflict protocol — reserve paths after conflict check | ||
| // passes but before WAL commit, preventing TOCTOU between check and commit. | ||
| let reservation_paths: Vec<PathBuf> = changes.iter().map(|c| c.path.clone()).collect(); | ||
| if let Err(e) = detector.reserve_paths(id, reservation_paths) { | ||
| tracing::warn!(branch = %id, error = %e, "C8: path reservation failed"); | ||
| detector.unregister_branch(id); | ||
| drop(detector); | ||
| self.thaw_cgroup(id); | ||
| self.rollback_internal("C8: path reservation conflict", id)?; | ||
| return Ok(CommitResult { | ||
| branch_id: id.clone(), | ||
| files_committed: 0, | ||
| bytes_committed: 0, | ||
| policy_result: PolicyDecision::Rejected(vec![puzzled_types::Violation { | ||
| rule: "conflict_reservation".to_string(), | ||
| message: e, | ||
| severity: puzzled_types::ViolationSeverity::Error, | ||
| }]), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // M-br6: Check commit timeout before proceeding to WAL commit | ||
| if std::time::Instant::now() > commit_deadline { | ||
| tracing::error!(branch = %id, timeout_secs, "M-br6: commit timeout exceeded during policy/conflict evaluation"); | ||
| unlock_poisoned(self.conflict_detector.lock()).cancel_reservation(id); |
There was a problem hiding this comment.
Policy evaluation errors need an unconditional fail-closed cleanup path.
By the time PolicyDecision::Error reaches this arm, Step 3c may already have registered changes and reserved paths. Calling apply_fail_mode() here can leave the branch in Degraded with those reservations still held, even though governance never produced a verdict. Cancel the reservation and roll back unconditionally here.
Based on learnings “Fail closed: if governance cannot be determined, rollback instead of commit”.
Also applies to: 303-319
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/commit_flow.rs` around lines 196 - 258, The
policy-evaluation error path must unconditionally clean up any registered
changes and path reservations before switching to degraded/fail mode; update the
arm handling PolicyDecision::Error (where apply_fail_mode() is currently called)
to first call
unlock_poisoned(self.conflict_detector.lock()).cancel_reservation(id), then
unregister_branch via the detector, thaw the cgroup with self.thaw_cgroup(id),
and call self.rollback_internal(...) to roll back the branch, and only after
these guaranteed cleanup steps (or instead of leaving reservations held) invoke
apply_fail_mode() if still needed; ensure the code uses the same detector
obtained from unlock_poisoned(self.conflict_detector.lock()) so that
register_changes/reserve_paths are properly released.
| /// Attach governance to a running container (Podman-native mode). | ||
| /// | ||
| /// Called by the OCI hook at createRuntime stage. Registers the container | ||
| /// PID with the branch and optionally starts BPF LSM and fanotify monitoring. | ||
| pub fn attach_governance( | ||
| &self, | ||
| id: &BranchId, | ||
| container_pid: u32, | ||
| _container_id: &str, | ||
| ) -> Result<()> { | ||
| // M4: Validate PID before attaching governance | ||
| if container_pid == 0 { | ||
| return Err(crate::error::PuzzledError::Branch( | ||
| "container_pid must be > 0".to_string(), | ||
| )); | ||
| } | ||
|
|
||
| // M4: Verify the PID exists via pidfd_open (race-free check). | ||
| // The fd is closed immediately — we only need to confirm the PID is valid. | ||
| #[cfg(target_os = "linux")] | ||
| { | ||
| let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, container_pid, 0) }; | ||
| if fd < 0 { | ||
| return Err(crate::error::PuzzledError::Branch(format!( | ||
| "container_pid {} does not exist or is not accessible: {}", | ||
| container_pid, | ||
| std::io::Error::last_os_error() | ||
| ))); | ||
| } | ||
| // H6: Safe cast — use i32::try_from to prevent truncation of large fd values. | ||
| let fd_i32 = i32::try_from(fd).map_err(|_| { | ||
| crate::error::PuzzledError::Branch(format!( | ||
| "pidfd_open returned fd {} which overflows i32", | ||
| fd | ||
| )) | ||
| })?; | ||
| unsafe { libc::close(fd_i32) }; | ||
| } | ||
|
|
||
| let mut branch = self.branches.get_mut(id).ok_or_else(|| { | ||
| crate::error::PuzzledError::Branch(format!("branch {} not found", id)) | ||
| })?; | ||
|
|
||
| branch.pid = Some(container_pid); | ||
| tracing::info!( | ||
| branch_id = %id, | ||
| container_pid, | ||
| "governance attached to container process" | ||
| ); | ||
|
|
||
| Ok(()) |
There was a problem hiding this comment.
quarantine_branch() can return Ok(()) without freezing anything.
On Linux this only freezes branches that have a SandboxHandle. Podman-native branches can be Active with only pid/cgroup discovery, and they currently fall through to success while the agent keeps running. Use the same PID-based cgroup resolution as commit(), or fail the call.
♻️ Proposed fix
#[cfg(target_os = "linux")]
- if let Some(handle) = self.sandboxes.get(id) {
- crate::sandbox::cgroup::CgroupManager::freeze(&handle.cgroup_path)?;
- tracing::warn!(
- branch = %id,
- "§3.3: branch quarantined via cgroup.freeze (DLP violation)"
- );
- }
+ if let Some(handle) = self.sandboxes.get(id) {
+ crate::sandbox::cgroup::CgroupManager::freeze(&handle.cgroup_path)?;
+ } else if let Some(pid) = self.branches.get(id).and_then(|r| r.pid) {
+ let cgroup_path = crate::sandbox::cgroup::cgroup_v2_fs_path_for_pid(pid)?;
+ crate::sandbox::cgroup::CgroupManager::freeze(&cgroup_path)?;
+ } else {
+ return Err(PuzzledError::Sandbox(
+ "cannot quarantine branch without sandbox handle or PID".into(),
+ ));
+ }
+ tracing::warn!(
+ branch = %id,
+ "§3.3: branch quarantined via cgroup.freeze (DLP violation)"
+ );Also applies to: 498-527
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/mod.rs` around lines 291 - 341, quarantine_branch()
currently returns Ok(()) for podman-native branches that have no SandboxHandle
but only a pid, so nothing gets frozen; update quarantine_branch to mirror
commit()'s PID-based cgroup resolution: when SandboxHandle is None but
branch.pid is Some(pid), resolve the branch's cgroup/path from the pid (same
logic used in commit()), then proceed to perform the freeze operation; if cgroup
resolution fails return an Err instead of Ok; look for symbols
quarantine_branch, commit, SandboxHandle, and Branch::pid to implement this
PID-to-cgroup fallback and ensure failure on unresolved cases.
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/puzzlectl/src/sim/repl.rs (1)
340-376:⚠️ Potential issue | 🟡 MinorMode is not restored after
executedespite "Switching temporarily" comment.
saved_modeis captured at line 340 but never used to restore the original mode after execution completes. If the user was in Direct mode before runningexecute, they remain in Sandbox mode afterward, contradicting the "Switching temporarily" message.🛠️ Proposed fix to restore mode after execute
let state = engine.wait_for_worker(client).await?; println!("[orchestrator] Worker finished (state: {})", state); println!("[orchestrator] Submitting for policy review..."); let result = engine.approve(client).await?; match result.deny_reason { Some(reason) => { println!("[puzzled] DENIED: {}", reason); println!("Branch rolled back."); } None => { println!("[puzzled] APPROVED: changes committed"); } } + + // Restore original mode if we switched temporarily + if saved_mode == SimMode::Direct { + let current_scenario = engine.scenario().cloned(); + engine.reset_with_mode(SimMode::Direct); + if let Some(scenario) = current_scenario { + engine.load(scenario); + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/repl.rs` around lines 340 - 376, The code captures saved_mode (from engine.mode()) when switching from SimMode::Direct to Sandbox for execute but never restores it; after the approval/rollback flow (after engine.approve and its match), call engine.reset_with_mode(saved_mode) to restore the original mode (and re-load the previously saved scenario if necessary, using the same pattern as before) so the temporary switch is truly temporary—ensure this uses the saved_mode variable and mirrors the earlier scenario-preservation logic around engine.reset_with_mode.
🧹 Nitpick comments (20)
crates/puzzlectl/src/sim/repl.rs (1)
209-224: Scenario preservation pattern is duplicated across mode switches.The clone-reset-reload pattern appears three times (lines 210-214, 218-223, 344-348). Consider extracting a helper method to reduce duplication.
♻️ Optional helper extraction
Add a method to
SimEngine(inengine.rs) or a local helper:fn switch_mode_preserving_scenario(engine: &mut SimEngine, mode: SimMode) { let current_scenario = engine.scenario().cloned(); engine.reset_with_mode(mode); if let Some(scenario) = current_scenario { engine.load(scenario); } }Then simplify each call site:
- let current_scenario = engine.scenario().cloned(); - engine.reset_with_mode(SimMode::Direct); - if let Some(scenario) = current_scenario { - engine.load(scenario); - } + switch_mode_preserving_scenario(engine, SimMode::Direct);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/repl.rs` around lines 209 - 224, Duplicate clone-reset-reload logic for preserving the current scenario when switching modes should be extracted into a helper to avoid repetition; add a function (either on SimEngine or a local free function) that accepts &mut SimEngine and a SimMode, captures engine.scenario().cloned(), calls engine.reset_with_mode(mode), and if the cloned scenario is Some(...) calls engine.load(...), then replace the three repeated blocks around engine.reset_with_mode(SimMode::Direct), engine.reset_with_mode(SimMode::Sandbox) (and the third occurrence) with a single call to that helper (e.g., switch_mode_preserving_scenario(&mut engine, SimMode::Direct)).crates/puzzled-types/src/trust.rs (2)
25-32: Scores above 100 silently map toTrusted.The catch-all
_ => TrustLevel::Trustedat line 31 accepts any score ≥80, including scores >100. WhileTrustState::newandapply_deltaclamp to 100, external callers offrom_scorecould pass unclamped values.Consider adding a
debug_assert!(score <= 100)for consistency with the F26 pattern inapply_delta, or documenting that values >100 are treated as 100.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/trust.rs` around lines 25 - 32, The from_score function currently maps any score ≥80 (including >100) to TrustLevel::Trusted; add a guard to make this explicit by inserting a debug_assert!(score <= 100) at the start of TrustLevel::from_score (or alternatively clamp the input to 100) so external callers match the clamping behavior used in TrustState::new and apply_delta; reference the function name from_score and the enum TrustLevel when making the change and mirror the F26/clamping pattern used in apply_delta for consistency.
104-119: Edge case:override_activewithoutoverride_expiresis never cleared.If
override_activeistruebutoverride_expiresisNone, this method returnsfalsewithout clearing the override. This creates a permanent override that cannot be cleared by expiry. While this might be intentional for indefinite overrides, the docstring says "clear the override if it has expired" which suggests expiry is expected.Consider either:
- Documenting that
override_expires: Nonemeans "indefinite override"- Adding a separate
clear_override()method for manual clearing- Treating
Noneas "already expired" and clearing it🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/trust.rs` around lines 104 - 119, The current clear_expired_override behavior leaves an active override with override_expires == None uncleared; add a new public method pub fn clear_override(&mut self) -> bool that force-clears the override by setting override_active = false, override_level = None, override_expires = None and returns true if anything changed, and update the docstring on clear_expired_override to explicitly state that None means an indefinite override (so clear_expired_override only clears when override_expires is Some and parsed time is past); reference the existing clear_expired_override function and the fields override_active, override_level, and override_expires when making these changes.crates/puzzlectl/src/commands/status.rs (1)
22-35: Silent fallback on JSON parse failure may hide daemon issues.When
serde_json::from_strfails (line 23-24), the raw string becomes thebranchesfield, andbranch_countbecomes 0. This silently masks potential protocol mismatches or daemon errors. Consider logging a warning to stderr when the response isn't valid JSON.📝 Suggested improvement
OutputFormat::Json => { - let parsed: serde_json::Value = serde_json::from_str(&branches) - .unwrap_or(serde_json::Value::String(branches.clone())); + let parsed: serde_json::Value = match serde_json::from_str(&branches) { + Ok(v) => v, + Err(e) => { + eprintln!("Warning: unexpected daemon response format: {e}"); + serde_json::Value::String(branches.clone()) + } + };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/status.rs` around lines 22 - 35, The JSON branch parsing in the OutputFormat::Json arm uses serde_json::from_str(&branches) and silently falls back to treating branches as a raw string (setting parsed and count to 0); change this so that when serde_json::from_str returns an Err you write a warning to stderr (e.g., via eprintln!) including the parse error and the raw branches response, then proceed with the fallback so status contains the raw branches but with an explicit warning logged; update the logic around parsed, count, and the printed status in OutputFormat::Json to perform the parse-result match and emit the stderr warning on Err.crates/puzzlectl/src/commands/branch.rs (1)
99-125: TOCTOU window between profile read and write.The
--no-notiflogic reads the profile file, modifies it in memory, and writes it back. If another process modifies the file between read and write, changes could be lost. For a security-related seccomp profile, consider using atomic write (write to temp file, then rename) or file locking.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/branch.rs` around lines 99 - 125, The code inside the --no-notif branch (checking no_notif and using std::fs::read_to_string(&path), serde_json::from_str, serde_json::to_string_pretty, and std::fs::write(&path, &stripped)) has a TOCTOU window that can lose concurrent updates; change the write to an atomic replace by writing the modified profile to a temp file in the same directory (e.g., create a temp filename derived from path), flush and fsync the temp file, then rename/replace the original (std::fs::rename) to ensure atomicity; alternatively acquire an exclusive file lock on path while reading/modifying/writing (using a crate like fs2) to prevent concurrent writers — update the logic around read_to_string, to_string_pretty, and write(&path, ...) to perform one of these safe replace strategies.crates/puzzlectl/src/commands/audit.rs (1)
45-45:unreachable!()could panic if dispatch logic changes.If future changes route
AuditAction::Verifythroughhandle_dbus, this will panic. Consider returning an error with guidance instead.♻️ Suggested fix
- crate::cli::AuditAction::Verify { .. } => unreachable!(), + crate::cli::AuditAction::Verify { .. } => { + anyhow::bail!("Verify action should be handled by cmd_audit_verify, not D-Bus dispatch") + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/audit.rs` at line 45, The match arm that currently uses unreachable!() for AuditAction::Verify is unsafe if dispatching changes; replace the panic with a proper error return from the surrounding function (e.g., handle_dbus) that reports AuditAction::Verify is unsupported in this path and instructs the caller to use the correct command (or route to the verify handler). Update the match arm for AuditAction::Verify to construct and return an appropriate error type used in this crate (e.g., anyhow::Error or the project’s CommandError) with a clear message guiding the caller, and ensure the function signature and callers propagate that Result/error accordingly.crates/puzzlectl/src/commands/policy.rs (1)
29-110:cmd_policy_testlacksOutputFormatsupport — violates CLI output guideline.Per coding guidelines, CLI commands must support machine-parseable JSON output via
--output=json. This function usesprintln!directly without accepting anOutputFormatparameter, making it inconsistent with other command handlers.♻️ Suggested signature change
-pub fn cmd_policy_test(changeset_path: &str, policy_dir: &str) -> Result<()> { +pub fn cmd_policy_test(changeset_path: &str, policy_dir: &str, output: OutputFormat) -> Result<()> {Then wrap the result output in JSON when
output == OutputFormat::Json:match output { OutputFormat::Json => { let result = serde_json::json!({ "status": if allowed { "approved" } else { "rejected" }, "policy_count": policy_count, "changes_count": changes.len(), "violations": violations_list, }); println!("{}", serde_json::to_string_pretty(&result)?); } OutputFormat::Text => { // existing println! logic } }As per coding guidelines: "Use clap derive macros for CLI implementation; output must be machine-parseable JSON when using
--output=jsonflag"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/policy.rs` around lines 29 - 110, The cmd_policy_test function must accept an OutputFormat parameter and conditionally emit JSON when OutputFormat::Json; update the signature (cmd_policy_test(..., output: OutputFormat)) and where you currently use println! build a serializable result object containing "status" (approved/rejected using the existing allowed boolean), "policy_count" (policy_count), "changes_count" (changes.len()), and a collected violations_list (derive from engine.eval_query result and print_violations logic but returning structured data instead of printing). Keep the existing human-readable println! path when OutputFormat::Text. Ensure you use serde_json::to_string_pretty for JSON output and return errors via the current Result flow.crates/puzzlectl/src/commands/profile.rs (2)
110-139:cmd_profile_validatelacksOutputFormatsupport.Per coding guidelines, validation results should be JSON-serializable for automation/CI pipelines.
♻️ Suggested change
-pub fn cmd_profile_validate(path: &str) -> Result<()> { +pub fn cmd_profile_validate(path: &str, output: OutputFormat) -> Result<()> { // ... validation logic ... - println!("Profile '{}' is valid", profile.name); + match output { + OutputFormat::Json => { + let result = serde_json::json!({ + "status": "valid", + "name": profile.name, + }); + println!("{}", serde_json::to_string_pretty(&result)?); + } + OutputFormat::Text => { + println!("Profile '{}' is valid", profile.name); + } + }As per coding guidelines: "output must be machine-parseable JSON when using
--output=jsonflag"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 110 - 139, cmd_profile_validate currently always prints a human message; change its signature to accept an OutputFormat (or fetch CLI OutputFormat) and emit machine-parseable JSON when OutputFormat::Json is selected: build a result struct/object with fields like name (profile.name), valid (rl_errors.is_empty() && other checks passed), and errors (vec of strings assembled from the empty-name/memory/max_pids checks plus rl_errors from profile.resource_limits.validate()), then serialize that object with serde_json (pretty or compact) and print it; if OutputFormat is not Json, keep the existing human-readable println. Ensure serde_json errors are mapped to anyhow::Error and reference cmd_profile_validate, profile.resource_limits.validate(), and the OutputFormat enum in your changes.
141-239:cmd_profile_testshould supportOutputFormatfor CI/automation use cases.Similar to
cmd_profile_validate, test results would benefit from structured JSON output for pipeline integration.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 141 - 239, cmd_profile_test currently only prints human-readable results; update it to accept an OutputFormat parameter (same enum used by cmd_profile_validate) and emit structured output when format == OutputFormat::Json: build a small result struct (e.g., ProfileTestResult { profile: String, total: usize, passed: usize, failed: usize, entries: Vec<Entry> } with Entry { path: String, status: "pass" | "fail", reason: Option<String> }) and serialize it with serde_json::to_writer or to_string; keep the existing console printlns for Default/Text mode and return the same exit behavior (bail on failures) but ensure JSON mode writes the structured result instead of human lines and does not mix extra prints into the JSON output; adjust the function signature of cmd_profile_test(name: &str, changeset_path: &str, dir: &str) -> Result<()> to include the OutputFormat parameter and update callers accordingly.crates/puzzlectl/src/commands/credential.rs (1)
257-276: Minor TOCTOU window in permission setting.
create_dir_allcreates the directory with default permissions, thenset_permissionstightens them. Similarly,std::fs::writecreates the file before permissions are set. This creates a brief window where the file/directory is world-readable.For secret storage, consider using
std::os::unix::fs::OpenOptionsExtwith mode set at creation time. However, given this is user-space credential storage and the window is very brief, this is low priority.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/credential.rs` around lines 257 - 276, The current flow calls std::fs::create_dir_all and std::fs::write then tightens permissions with set_permissions, creating a TOCTOU window; instead, on unix use DirBuilderExt::mode to create the secrets dir with 0o700 atomically (use std::fs::DirBuilder and std::os::unix::fs::DirBuilderExt::mode(...).create(...)), and replace std::fs::write + set_permissions for the encrypted file with std::fs::OpenOptions plus std::os::unix::fs::OpenOptionsExt::mode(0o600) (create_new/write) and write_all to the opened file so the file is created with correct permissions from the start; update the code paths that currently call create_dir_all, std::fs::write, and set_permissions to use these DirBuilder/OpenOptions changes on unix while keeping existing behavior for non-unix targets.crates/puzzlectl/src/output.rs (1)
56-66:truncatehas inconsistent byte vs character semantics.The early return checks
s.len() <= max_lenwheres.len()is byte length, butchar_indices().nth(max_len)treatsmax_lenas a character count. For ASCII strings this works, but for multi-byte UTF-8, the function could return more bytes than expected ifmax_lenis interpreted as a byte limit.This is likely fine if
max_lenis always meant to be a character limit (consistent with the L11 comment about UTF-8 safe truncation), but the function name and early-return check suggest byte semantics.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/output.rs` around lines 56 - 66, The truncate function mixes byte-length (s.len()) with character-based indexing causing inconsistent behavior for multi-byte UTF‑8; replace the early-return byte check with a character-based check — for example, change the if to use s.char_indices().nth(max_len).is_none() (or s.chars().count() <= max_len) so that truncate consistently treats max_len as a character limit; update the code in the truncate function accordingly.crates/puzzle-proxy/src/handler/mod.rs (4)
246-258: Variable shadowing reduces readability.The
geo_ipvariable is defined and then immediately shadowed in the same block. Consider using distinct names likeresolved_geo_ipfor the initial value andgeo_ipfor the final resolution to improve clarity.♻️ Suggested refactor for clarity
- let geo_ip = resolved_addrs + let resolved_geo_ip = resolved_addrs .as_ref() .and_then(|a| a.first().map(|s| s.ip())); - let geo_ip = if let Some(ip) = geo_ip { + let geo_ip = if let Some(ip) = resolved_geo_ip { Some(ip)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 246 - 258, The code shadows geo_ip by first assigning from resolved_addrs and then reassigning after a lookup; rename the first binding to something like resolved_geo_ip (using resolved_addrs.as_ref().and_then(|a| a.first().map(|s| s.ip()))) and then compute the final geo_ip by falling back to tokio::net::lookup_host(format!("{}:0", host)).await when resolved_geo_ip is None; update any subsequent uses to reference the final geo_ip and remove the re-shadowing to improve clarity in this block (symbols: geo_ip, resolved_addrs, resolved_geo_ip, tokio::net::lookup_host, host).
1786-1788: Source introspection test uses path outside the handler module.The
test_memfd_create_in_seccomp_deny_listtest references../../../puzzled/src/sandbox/seccomp/mod.rswhich is outside thepuzzle-proxycrate. This creates a cross-crate coupling that could break if the directory structure changes.Consider either:
- Moving this test to the
puzzledcrate where the seccomp module lives- Using a runtime check or integration test instead
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 1786 - 1788, The unit test test_memfd_create_in_seccomp_deny_list currently reads the seccomp source via include_str! with a relative path ("../../../puzzled/src/sandbox/seccomp/mod.rs"), coupling puzzle-proxy to another crate's file layout; fix by either moving the test into the puzzled crate (place the test in puzzled/src/... and reference the seccomp module directly) or replace the static include_str! check with a runtime/integration test that verifies behavior (e.g., call the seccomp-related API or assert the deny list via the seccomp module's public interface) so you remove the include_str! path reference and use the seccomp module’s public symbols instead.
600-619: Credential exfiltration check is case-sensitive and encoding-naive.The
contains()check at line 606 is case-sensitive and won't detect URL-encoded or otherwise obfuscated credentials. While the comment notes that DLP handles body checking, the URI check could miss%XX-encoded variants.Since this is documented as defense-in-depth and the DLP engine provides deeper inspection, this is acceptable but worth noting for future hardening.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 600 - 619, The URI credential check using original_uri_str.contains(real_value.as_str()) is case-sensitive and won't catch percent-encoded or case-variations; update the check to decode percent-encoding and normalize case before matching. Specifically, parse original_uri_str (e.g., with url::Url or percent-decoding utilities), percent-decode the path and query components, normalize both the decoded URI and injected_credential_value to a canonical form (lowercase or Unicode-normalized) and then perform the contains() test; keep the same behavior of logging with branch_id and returning the same FORBIDDEN response when a match is found (preserve the Response::builder(...) logic).
1413-1416: Test relies on timing which may be flaky under CI load.The 2-second sleep combined with the 5-second timeout leaves only 3 seconds of margin. Under heavy CI load, this could cause intermittent failures. Consider using a synchronization primitive (e.g., a channel or barrier) instead of a fixed sleep to coordinate test phases.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 1413 - 1416, The test currently uses a fixed tokio::time::sleep(Duration::from_secs(2)) between upstream_write_half.flush().await.unwrap() and drop(upstream_write_half), which is flaky; replace the sleep with an explicit sync primitive such as a tokio::sync::oneshot channel or a tokio::sync::Barrier so the spawned task signals completion (send on oneshot or wait on barrier) after flush(), and the test waits on the corresponding receiver/clause instead of sleeping; update the code around upstream_write_half.flush().await.unwrap() and drop(upstream_write_half) to use that handshake to deterministically coordinate phases.crates/puzzlectl/src/cli.rs (1)
441-457:StoreandRotatealways read from stdin; consider adding--from-env/--from-filefor consistency.The
Addcommand provides--from-envand--from-fileoptions for scripting convenience, butStoreandRotateonly support stdin. For automation scenarios, consider adding similar options to these commands for consistency.Also applies to: 463-467
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/cli.rs` around lines 441 - 457, The Store and Rotate subcommands currently only read credential values from stdin; add optional flags --from-env and --from-file (matching the Add command) to both Store and Rotate so callers can supply values from an environment variable name or a file path for scripting; update the CLI enum variants Store and Rotate to include optional fields (e.g., from_env: Option<String>, from_file: Option<PathBuf>), update parsing/validation logic in the handler for Store and Rotate to prefer --from-file, then --from-env, then stdin (and error on conflicting flags), and mirror the Add command's behavior and help text for consistency.crates/puzzlectl/src/commands/compliance_cmd.rs (2)
89-93: Silent fallback when signing key doesn't exist.When
signing_key.exists()is false, the code silently passesNonetogenerate_report_package. Consider logging a warning when a signing key path is provided but the file doesn't exist, as this might indicate a configuration error.♻️ Suggested improvement
let signing_key_opt = if signing_key.exists() { Some(signing_key) } else { + if !signing_key.as_os_str().is_empty() { + eprintln!("warning: signing key not found at {}, report will be unsigned", signing_key.display()); + } None };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/compliance_cmd.rs` around lines 89 - 93, The code builds signing_key_opt by checking signing_key.exists() and silently using None when the file is missing; update the logic in the block that constructs signing_key_opt (where signing_key and signing_key.exists() are referenced) to log a warning via the appropriate logger (or eprintln! if no logger) when a signing key path was provided but the file does not exist, then pass the existing Some(signing_key) or None through to generate_report_package as before so callers get both the warning and the same behavior.
62-84: Duplicate loading pattern across compliance commands.The audit record and profile loading logic with warning messages is duplicated across
cmd_compliance_report,cmd_compliance_status, andcmd_compliance_gaps. Consider extracting to a helper function to reduce duplication.♻️ Suggested refactor: Extract loading helper
fn load_compliance_data( audit_dir: &Path, profiles_dir: &Path, period_secs: Option<u64>, ) -> Result<(Vec<AuditRecord>, HashMap<String, u64>, Vec<Profile>)> { let load_result = compliance::load_audit_records(audit_dir, period_secs)?; if load_result.parse_failures > 0 { eprintln!( "warning: {} of {} audit lines failed to parse", load_result.parse_failures, load_result.total_lines ); } if load_result.timestamp_parse_failures > 0 { eprintln!( "warning: {} records had unparseable timestamps (included unfiltered)", load_result.timestamp_parse_failures ); } let event_counts = compliance::count_events_by_type(&load_result.records); let profile_result = compliance::load_profiles(profiles_dir); if profile_result.parse_failures > 0 { eprintln!( "warning: {} of {} profile files failed to parse", profile_result.parse_failures, profile_result.total_files ); } Ok((load_result.records, event_counts, profile_result.profiles)) }Also applies to: 222-238, 311-327
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/compliance_cmd.rs` around lines 62 - 84, The audit/profile loading and warning logic is duplicated in cmd_compliance_report, cmd_compliance_status, and cmd_compliance_gaps; extract it into a single helper (e.g., fn load_compliance_data(audit_dir: &Path, profiles_dir: &Path, period_secs: Option<u64>) -> Result<(Vec<AuditRecord>, HashMap<String,u64>, Vec<Profile>)>) that calls compliance::load_audit_records, compliance::count_events_by_type and compliance::load_profiles, emits the same eprintln! warnings when parse_failures or timestamp_parse_failures are > 0, and returns (records, event_counts, profiles); then replace the three duplicated blocks with calls to load_compliance_data in cmd_compliance_report, cmd_compliance_status, and cmd_compliance_gaps.crates/puzzlectl/src/commands/attestation.rs (2)
65-68: Error handling in hex decode returns empty vec instead of propagating error.When
hex_decodefails, the code prints an error but returns an emptyVec, which then causes the subsequent length check to fail with a generic "wrong length" message. Consider propagating the actual hex decode error for better diagnostics.♻️ Suggested fix for clearer error propagation
- let sig_bytes = hex_decode(signature_hex).unwrap_or_else(|e| { - eprintln!("S44: hex decode failed for record {}: {}", record_id, e); - Vec::new() - }); - if sig_bytes.len() != 64 { - return SignatureResult::Fail(format!( - " FAIL: signature wrong length ({} bytes) for record {}", - sig_bytes.len(), - record_id - )); - } + let sig_bytes = match hex_decode(signature_hex) { + Ok(bytes) => bytes, + Err(e) => { + return SignatureResult::Fail(format!( + " FAIL: hex decode failed for record {}: {}", + record_id, e + )); + } + }; + if sig_bytes.len() != 64 { + return SignatureResult::Fail(format!( + " FAIL: signature wrong length ({} bytes, expected 64) for record {}", + sig_bytes.len(), + record_id + )); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/attestation.rs` around lines 65 - 68, The hex decode error is currently swallowed by returning an empty Vec (sig_bytes) which later triggers a generic length error; instead propagate the actual decode error: change the hex_decode(signature_hex).unwrap_or_else(...) usage in the attestation code to return the hex decoding failure as an Err (or use ? to bubble the error) including record_id and signature_hex in the error context so callers see the real failure; update the surrounding function signature to return Result if necessary and ensure the error message replaces the generic "wrong length" diagnostics with the original hex decode error when decoding fails.
949-968: Warning messages go to stderr but return value is Null.This function prints warnings to stderr on parse/read failures but returns
Value::Null. This is appropriate for the current usage where callers check for null, but consider documenting this behavior or returningOption<Value>for clearer API semantics.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/attestation.rs` around lines 949 - 968, The load_manifest_file function prints warnings on read/parse failures but returns serde_json::Value::Null which is misleading; change its API to return Option<serde_json::Value> (i.e., Some(value) on success, None on read/parse failure) and replace the Value::Null returns with None, keeping the existing stderr warnings; then update all callers that expect Value::Null to handle Option (e.g., match Some(v) / None) so the semantics are explicit and documented in the function comment/signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/puzzle-hook/src/main.rs`:
- Around line 32-35: The comment above the DBUS_CALL_TIMEOUT constant (and the
other noted comment blocks) must include the repository's audit-prefix tag
(e.g., H:, M:, SC:, PM:, DC:, L:, or A/B/C:) per guidelines; update the
comment(s) that currently read "Maximum time to wait..." to start with the
appropriate tag (for example "H: Maximum time to wait..." if it's a hardening
concern) so the file's comment-tag convention is followed—locate constants
DBUS_CALL_TIMEOUT and the other comment occurrences in main.rs and prefix each
comment line with the correct audit tag.
In `@crates/puzzlectl/src/cli.rs`:
- Around line 315-333: The audit_dir argument is declared with a default_value
and conflicts_with bundle, causing clap v4 to treat it as present and always
trigger the conflict; change the audit_dir field from PathBuf with default_value
to Option<PathBuf> (i.e., #[arg(long)] audit_dir: Option<PathBuf>) and remove
the default_value, then where the CLI is consumed (the code that reads the
parsed struct before using audit_dir, e.g., the CLI handling or run function),
replace direct uses with audit_dir.unwrap_or_else(||
PathBuf::from("/var/lib/puzzled/branches/audit")) so the default is applied at
runtime only when audit_dir was not provided; keep bundle: Option<PathBuf> and
conflicts_with behavior intact.
In `@crates/puzzlectl/src/commands/attestation.rs`:
- Around line 676-688: Replace the string comparison of timestamps in the
attestation check with RFC3339-aware numeric comparison: use the existing
parse_rfc3339_approx() to parse both ts and the value stored in prev_timestamp
(or parse prev_timestamp when you set it) to a numeric epoch (or comparable
chrono::DateTime) and compare those values before incrementing
timestamp_violations and printing the failure for record rid; ensure to handle
parse errors (e.g., treat an unparseable timestamp as a violation or skip
comparison consistently) and update where prev_timestamp is set so it stores the
normalized/parsed form (or keep the original string but parse when comparing) to
avoid incorrect string-ordering between formats like "Z" vs "+00:00".
In `@crates/puzzlectl/src/commands/audit.rs`:
- Around line 24-26: The List action currently ignores the OutputFormat and
always prints raw JSON; modify the branch that calls
client.query_audit_events(&filter.to_string()).await? and replace the
println!("{result}") with a call that respects the output parameter (e.g., use
the existing output_json_or_text helper or equivalent) so that when `output` is
OutputFormat::Text it renders human-readable text and when OutputFormat::Json it
prints JSON; ensure you pass the obtained `result` (or its serialized form) and
the `output` variable into output_json_or_text (or implement the same formatting
logic) so the List action behaves consistently with other commands.
- Around line 50-85: The function cmd_audit_verify currently only prints the
manifest and warnings but returns Ok(()), which falsely signals success; either
make it fail explicitly or rename it if display-only is intended. To fix, change
cmd_audit_verify to return an error when verification isn’t implemented (e.g.,
return an anyhow error with a clear message like "signature verification not
implemented — use attestation verify-bundle") so callers get a non-zero exit, or
alternatively rename the function to cmd_audit_show and update the docstring and
all call sites to reflect display-only behavior; ensure to update any CLI wiring
that references cmd_audit_verify if you choose to rename it.
In `@crates/puzzlectl/src/commands/branch.rs`:
- Around line 120-123: The profile write currently swallows errors via
std::fs::write(&path, &stripped).ok(), which can leave disk state inconsistent;
change the code in branch.rs around the
serde_json::to_string_pretty(&profile).unwrap_or(content) /
std::fs::write(&path, &stripped) site to handle the Result: either propagate the
error (use the ? operator or return a proper Err from the enclosing function) or
at minimum log a warning/error including the file path and the write error, and
ensure callers of the enclosing function are updated to handle the propagated
error if you choose propagation; do not leave the write result ignored.
In `@crates/puzzlectl/src/commands/credential.rs`:
- Around line 267-276: Validate and sanitize the credential "name" before
building enc_path so it cannot escape secrets_dir; ensure the code that
constructs enc_path (the secrets_dir.join(format!("{}.enc", name)) usage)
rejects or normalizes names containing path separators, ".." components, or
absolute paths and only allows a safe character set (e.g., alphanumeric,
underscore, dash) or explicitly canonicalize and verify the resulting path is
inside secrets_dir; update the logic that currently calls
std::fs::write(&enc_path, &encrypted) to run after this validation so writing
and permission-setting (std::fs::set_permissions) cannot operate on a traversed
path.
In `@crates/puzzlectl/src/output.rs`:
- Around line 99-101: The compilation error comes from using the unstable method
is_multiple_of in hex_decode (used by BranchId::validated and thus
validate_branch_id); replace that call with an equivalent modulo-based check
(e.g., n % k == 0) inside the hex_decode function in the puzzled-types crate so
the code compiles on stable Rust 2021, keeping the same semantics and handling
for negative/unsigned types as appropriate.
In `@crates/puzzled-types/src/audit.rs`:
- Around line 45-50: The branch_id field on struct AuditRecordEvent is an Option
but lacks serde attributes, causing nulls to be emitted instead of being
omitted; update the AuditRecordEvent definition to add #[serde(default,
skip_serializing_if = "Option::is_none")] on the branch_id field so it is
omitted when None (matching other optional fields and the module's minimal
NDJSON format).
In `@crates/puzzled-types/src/lib.rs`:
- Around line 93-109: The use of the unstable method is_multiple_of in
hex_decode causes compilation failures on stable Rust; update the odd-length
guard to use a stable check such as s.len() % 2 != 0 (or s.len() % 2 == 1)
instead of is_multiple_of, keeping the existing non-ASCII guard and the rest of
the function (the indexing with &s[i..i+2] and the u8::from_str_radix mapping)
unchanged so behavior remains the same.
In `@crates/puzzled/src/branch/activate.rs`:
- Around line 189-224: The quarantine_sender closure captures a cgroup_path
computed from self.sandboxes.get(id) which is None because the sandbox handle
isn't inserted until later; to fix, grab the sandbox handle's cgroup path
directly and capture that into the spawned task (replace the
self.sandboxes.get(id).map(|h| h.cgroup_path.clone()) expression with
handle.cgroup_path.clone()), ensuring the local handle variable is in scope when
creating quarantine_sender so the rt_handle.spawn async block uses the real
cgroup path instead of an always-None value.
---
Outside diff comments:
In `@crates/puzzlectl/src/sim/repl.rs`:
- Around line 340-376: The code captures saved_mode (from engine.mode()) when
switching from SimMode::Direct to Sandbox for execute but never restores it;
after the approval/rollback flow (after engine.approve and its match), call
engine.reset_with_mode(saved_mode) to restore the original mode (and re-load the
previously saved scenario if necessary, using the same pattern as before) so the
temporary switch is truly temporary—ensure this uses the saved_mode variable and
mirrors the earlier scenario-preservation logic around engine.reset_with_mode.
---
Nitpick comments:
In `@crates/puzzle-proxy/src/handler/mod.rs`:
- Around line 246-258: The code shadows geo_ip by first assigning from
resolved_addrs and then reassigning after a lookup; rename the first binding to
something like resolved_geo_ip (using resolved_addrs.as_ref().and_then(|a|
a.first().map(|s| s.ip()))) and then compute the final geo_ip by falling back to
tokio::net::lookup_host(format!("{}:0", host)).await when resolved_geo_ip is
None; update any subsequent uses to reference the final geo_ip and remove the
re-shadowing to improve clarity in this block (symbols: geo_ip, resolved_addrs,
resolved_geo_ip, tokio::net::lookup_host, host).
- Around line 1786-1788: The unit test test_memfd_create_in_seccomp_deny_list
currently reads the seccomp source via include_str! with a relative path
("../../../puzzled/src/sandbox/seccomp/mod.rs"), coupling puzzle-proxy to
another crate's file layout; fix by either moving the test into the puzzled
crate (place the test in puzzled/src/... and reference the seccomp module
directly) or replace the static include_str! check with a runtime/integration
test that verifies behavior (e.g., call the seccomp-related API or assert the
deny list via the seccomp module's public interface) so you remove the
include_str! path reference and use the seccomp module’s public symbols instead.
- Around line 600-619: The URI credential check using
original_uri_str.contains(real_value.as_str()) is case-sensitive and won't catch
percent-encoded or case-variations; update the check to decode percent-encoding
and normalize case before matching. Specifically, parse original_uri_str (e.g.,
with url::Url or percent-decoding utilities), percent-decode the path and query
components, normalize both the decoded URI and injected_credential_value to a
canonical form (lowercase or Unicode-normalized) and then perform the contains()
test; keep the same behavior of logging with branch_id and returning the same
FORBIDDEN response when a match is found (preserve the Response::builder(...)
logic).
- Around line 1413-1416: The test currently uses a fixed
tokio::time::sleep(Duration::from_secs(2)) between
upstream_write_half.flush().await.unwrap() and drop(upstream_write_half), which
is flaky; replace the sleep with an explicit sync primitive such as a
tokio::sync::oneshot channel or a tokio::sync::Barrier so the spawned task
signals completion (send on oneshot or wait on barrier) after flush(), and the
test waits on the corresponding receiver/clause instead of sleeping; update the
code around upstream_write_half.flush().await.unwrap() and
drop(upstream_write_half) to use that handshake to deterministically coordinate
phases.
In `@crates/puzzlectl/src/cli.rs`:
- Around line 441-457: The Store and Rotate subcommands currently only read
credential values from stdin; add optional flags --from-env and --from-file
(matching the Add command) to both Store and Rotate so callers can supply values
from an environment variable name or a file path for scripting; update the CLI
enum variants Store and Rotate to include optional fields (e.g., from_env:
Option<String>, from_file: Option<PathBuf>), update parsing/validation logic in
the handler for Store and Rotate to prefer --from-file, then --from-env, then
stdin (and error on conflicting flags), and mirror the Add command's behavior
and help text for consistency.
In `@crates/puzzlectl/src/commands/attestation.rs`:
- Around line 65-68: The hex decode error is currently swallowed by returning an
empty Vec (sig_bytes) which later triggers a generic length error; instead
propagate the actual decode error: change the
hex_decode(signature_hex).unwrap_or_else(...) usage in the attestation code to
return the hex decoding failure as an Err (or use ? to bubble the error)
including record_id and signature_hex in the error context so callers see the
real failure; update the surrounding function signature to return Result if
necessary and ensure the error message replaces the generic "wrong length"
diagnostics with the original hex decode error when decoding fails.
- Around line 949-968: The load_manifest_file function prints warnings on
read/parse failures but returns serde_json::Value::Null which is misleading;
change its API to return Option<serde_json::Value> (i.e., Some(value) on
success, None on read/parse failure) and replace the Value::Null returns with
None, keeping the existing stderr warnings; then update all callers that expect
Value::Null to handle Option (e.g., match Some(v) / None) so the semantics are
explicit and documented in the function comment/signature.
In `@crates/puzzlectl/src/commands/audit.rs`:
- Line 45: The match arm that currently uses unreachable!() for
AuditAction::Verify is unsafe if dispatching changes; replace the panic with a
proper error return from the surrounding function (e.g., handle_dbus) that
reports AuditAction::Verify is unsupported in this path and instructs the caller
to use the correct command (or route to the verify handler). Update the match
arm for AuditAction::Verify to construct and return an appropriate error type
used in this crate (e.g., anyhow::Error or the project’s CommandError) with a
clear message guiding the caller, and ensure the function signature and callers
propagate that Result/error accordingly.
In `@crates/puzzlectl/src/commands/branch.rs`:
- Around line 99-125: The code inside the --no-notif branch (checking no_notif
and using std::fs::read_to_string(&path), serde_json::from_str,
serde_json::to_string_pretty, and std::fs::write(&path, &stripped)) has a TOCTOU
window that can lose concurrent updates; change the write to an atomic replace
by writing the modified profile to a temp file in the same directory (e.g.,
create a temp filename derived from path), flush and fsync the temp file, then
rename/replace the original (std::fs::rename) to ensure atomicity; alternatively
acquire an exclusive file lock on path while reading/modifying/writing (using a
crate like fs2) to prevent concurrent writers — update the logic around
read_to_string, to_string_pretty, and write(&path, ...) to perform one of these
safe replace strategies.
In `@crates/puzzlectl/src/commands/compliance_cmd.rs`:
- Around line 89-93: The code builds signing_key_opt by checking
signing_key.exists() and silently using None when the file is missing; update
the logic in the block that constructs signing_key_opt (where signing_key and
signing_key.exists() are referenced) to log a warning via the appropriate logger
(or eprintln! if no logger) when a signing key path was provided but the file
does not exist, then pass the existing Some(signing_key) or None through to
generate_report_package as before so callers get both the warning and the same
behavior.
- Around line 62-84: The audit/profile loading and warning logic is duplicated
in cmd_compliance_report, cmd_compliance_status, and cmd_compliance_gaps;
extract it into a single helper (e.g., fn load_compliance_data(audit_dir: &Path,
profiles_dir: &Path, period_secs: Option<u64>) -> Result<(Vec<AuditRecord>,
HashMap<String,u64>, Vec<Profile>)>) that calls compliance::load_audit_records,
compliance::count_events_by_type and compliance::load_profiles, emits the same
eprintln! warnings when parse_failures or timestamp_parse_failures are > 0, and
returns (records, event_counts, profiles); then replace the three duplicated
blocks with calls to load_compliance_data in cmd_compliance_report,
cmd_compliance_status, and cmd_compliance_gaps.
In `@crates/puzzlectl/src/commands/credential.rs`:
- Around line 257-276: The current flow calls std::fs::create_dir_all and
std::fs::write then tightens permissions with set_permissions, creating a TOCTOU
window; instead, on unix use DirBuilderExt::mode to create the secrets dir with
0o700 atomically (use std::fs::DirBuilder and
std::os::unix::fs::DirBuilderExt::mode(...).create(...)), and replace
std::fs::write + set_permissions for the encrypted file with
std::fs::OpenOptions plus std::os::unix::fs::OpenOptionsExt::mode(0o600)
(create_new/write) and write_all to the opened file so the file is created with
correct permissions from the start; update the code paths that currently call
create_dir_all, std::fs::write, and set_permissions to use these
DirBuilder/OpenOptions changes on unix while keeping existing behavior for
non-unix targets.
In `@crates/puzzlectl/src/commands/policy.rs`:
- Around line 29-110: The cmd_policy_test function must accept an OutputFormat
parameter and conditionally emit JSON when OutputFormat::Json; update the
signature (cmd_policy_test(..., output: OutputFormat)) and where you currently
use println! build a serializable result object containing "status"
(approved/rejected using the existing allowed boolean), "policy_count"
(policy_count), "changes_count" (changes.len()), and a collected violations_list
(derive from engine.eval_query result and print_violations logic but returning
structured data instead of printing). Keep the existing human-readable println!
path when OutputFormat::Text. Ensure you use serde_json::to_string_pretty for
JSON output and return errors via the current Result flow.
In `@crates/puzzlectl/src/commands/profile.rs`:
- Around line 110-139: cmd_profile_validate currently always prints a human
message; change its signature to accept an OutputFormat (or fetch CLI
OutputFormat) and emit machine-parseable JSON when OutputFormat::Json is
selected: build a result struct/object with fields like name (profile.name),
valid (rl_errors.is_empty() && other checks passed), and errors (vec of strings
assembled from the empty-name/memory/max_pids checks plus rl_errors from
profile.resource_limits.validate()), then serialize that object with serde_json
(pretty or compact) and print it; if OutputFormat is not Json, keep the existing
human-readable println. Ensure serde_json errors are mapped to anyhow::Error and
reference cmd_profile_validate, profile.resource_limits.validate(), and the
OutputFormat enum in your changes.
- Around line 141-239: cmd_profile_test currently only prints human-readable
results; update it to accept an OutputFormat parameter (same enum used by
cmd_profile_validate) and emit structured output when format ==
OutputFormat::Json: build a small result struct (e.g., ProfileTestResult {
profile: String, total: usize, passed: usize, failed: usize, entries: Vec<Entry>
} with Entry { path: String, status: "pass" | "fail", reason: Option<String> })
and serialize it with serde_json::to_writer or to_string; keep the existing
console printlns for Default/Text mode and return the same exit behavior (bail
on failures) but ensure JSON mode writes the structured result instead of human
lines and does not mix extra prints into the JSON output; adjust the function
signature of cmd_profile_test(name: &str, changeset_path: &str, dir: &str) ->
Result<()> to include the OutputFormat parameter and update callers accordingly.
In `@crates/puzzlectl/src/commands/status.rs`:
- Around line 22-35: The JSON branch parsing in the OutputFormat::Json arm uses
serde_json::from_str(&branches) and silently falls back to treating branches as
a raw string (setting parsed and count to 0); change this so that when
serde_json::from_str returns an Err you write a warning to stderr (e.g., via
eprintln!) including the parse error and the raw branches response, then proceed
with the fallback so status contains the raw branches but with an explicit
warning logged; update the logic around parsed, count, and the printed status in
OutputFormat::Json to perform the parse-result match and emit the stderr warning
on Err.
In `@crates/puzzlectl/src/output.rs`:
- Around line 56-66: The truncate function mixes byte-length (s.len()) with
character-based indexing causing inconsistent behavior for multi-byte UTF‑8;
replace the early-return byte check with a character-based check — for example,
change the if to use s.char_indices().nth(max_len).is_none() (or
s.chars().count() <= max_len) so that truncate consistently treats max_len as a
character limit; update the code in the truncate function accordingly.
In `@crates/puzzlectl/src/sim/repl.rs`:
- Around line 209-224: Duplicate clone-reset-reload logic for preserving the
current scenario when switching modes should be extracted into a helper to avoid
repetition; add a function (either on SimEngine or a local free function) that
accepts &mut SimEngine and a SimMode, captures engine.scenario().cloned(), calls
engine.reset_with_mode(mode), and if the cloned scenario is Some(...) calls
engine.load(...), then replace the three repeated blocks around
engine.reset_with_mode(SimMode::Direct),
engine.reset_with_mode(SimMode::Sandbox) (and the third occurrence) with a
single call to that helper (e.g., switch_mode_preserving_scenario(&mut engine,
SimMode::Direct)).
In `@crates/puzzled-types/src/trust.rs`:
- Around line 25-32: The from_score function currently maps any score ≥80
(including >100) to TrustLevel::Trusted; add a guard to make this explicit by
inserting a debug_assert!(score <= 100) at the start of TrustLevel::from_score
(or alternatively clamp the input to 100) so external callers match the clamping
behavior used in TrustState::new and apply_delta; reference the function name
from_score and the enum TrustLevel when making the change and mirror the
F26/clamping pattern used in apply_delta for consistency.
- Around line 104-119: The current clear_expired_override behavior leaves an
active override with override_expires == None uncleared; add a new public method
pub fn clear_override(&mut self) -> bool that force-clears the override by
setting override_active = false, override_level = None, override_expires = None
and returns true if anything changed, and update the docstring on
clear_expired_override to explicitly state that None means an indefinite
override (so clear_expired_override only clears when override_expires is Some
and parsed time is past); reference the existing clear_expired_override function
and the fields override_active, override_level, and override_expires when making
these changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: dc44a152-85cd-4919-bc98-cf1833cb1468
📒 Files selected for processing (58)
.cursor/.gitignorecrates/puzzle-hook/Cargo.tomlcrates/puzzle-hook/src/main.rscrates/puzzle-proxy/src/credentials.rscrates/puzzle-proxy/src/handler/mod.rscrates/puzzle-proxy/src/handler/routing.rscrates/puzzle-proxy/src/secure_memory.rscrates/puzzle-proxy/src/tls.rscrates/puzzle-proxy/tests/handler_tests.rscrates/puzzlectl/src/cli.rscrates/puzzlectl/src/commands/agent.rscrates/puzzlectl/src/commands/attestation.rscrates/puzzlectl/src/commands/audit.rscrates/puzzlectl/src/commands/branch.rscrates/puzzlectl/src/commands/compliance_cmd.rscrates/puzzlectl/src/commands/credential.rscrates/puzzlectl/src/commands/mod.rscrates/puzzlectl/src/commands/policy.rscrates/puzzlectl/src/commands/profile.rscrates/puzzlectl/src/commands/status.rscrates/puzzlectl/src/lib.rscrates/puzzlectl/src/main.rscrates/puzzlectl/src/output.rscrates/puzzlectl/src/sim/batch.rscrates/puzzlectl/src/sim/repl.rscrates/puzzled-types/Cargo.tomlcrates/puzzled-types/src/attestation.rscrates/puzzled-types/src/audit.rscrates/puzzled-types/src/behavioral.rscrates/puzzled-types/src/branch.rscrates/puzzled-types/src/change.rscrates/puzzled-types/src/lib.rscrates/puzzled-types/src/policy.rscrates/puzzled-types/src/provenance.rscrates/puzzled-types/src/trust.rscrates/puzzled/src/audit_store.rscrates/puzzled/src/branch/activate.rscrates/puzzled/src/branch/commit_flow.rscrates/puzzled/src/config.rscrates/puzzled/src/conflict.rscrates/puzzled/src/dbus/helpers.rscrates/puzzled/src/dbus/mod.rscrates/puzzled/src/dbus/tests.rscrates/puzzled/src/error.rscrates/puzzled/src/landlock_rules.rscrates/puzzled/src/lib.rscrates/puzzled/src/main.rscrates/puzzled/src/provenance.rscrates/puzzled/src/trust.rscrates/puzzled/src/wal.rscrates/puzzled/tests/branch_lifecycle.rscrates/puzzled/tests/common/mod.rscrates/puzzled/tests/concurrent_branches.rscrates/puzzled/tests/e2e_adversarial.rscrates/puzzled/tests/e2e_governance_lifecycle.rscrates/puzzled/tests/e2e_scenarios.rscrates/puzzled/tests/sandbox_containment.rscrates/puzzled/tests/security_hardening.rs
💤 Files with no reviewable changes (1)
- crates/puzzlectl/src/sim/batch.rs
✅ Files skipped from review due to trivial changes (8)
- .cursor/.gitignore
- crates/puzzle-hook/Cargo.toml
- crates/puzzled-types/Cargo.toml
- crates/puzzlectl/src/commands/mod.rs
- crates/puzzled-types/src/behavioral.rs
- crates/puzzle-proxy/src/tls.rs
- crates/puzzled-types/src/attestation.rs
- crates/puzzled-types/src/policy.rs
🚧 Files skipped from review as they are similar to previous changes (5)
- crates/puzzle-proxy/src/secure_memory.rs
- crates/puzzle-proxy/tests/handler_tests.rs
- crates/puzzled/src/audit_store.rs
- crates/puzzled-types/src/change.rs
- crates/puzzled/src/branch/commit_flow.rs
| /// Maximum time to wait for a D-Bus method call before failing. | ||
| /// Prevents the hook from stalling the container runtime indefinitely. | ||
| const DBUS_CALL_TIMEOUT: Duration = Duration::from_secs(30); | ||
|
|
There was a problem hiding this comment.
Prefix newly added source comments with required audit tags.
The newly added comments are unprefixed, which breaks the Rust comment-tag convention used in this repo.
Suggested patch
-/// Maximum time to wait for a D-Bus method call before failing.
-/// Prevents the hook from stalling the container runtime indefinitely.
+/// H: Maximum time to wait for a D-Bus method call before failing.
+/// H: Prevents the hook from stalling the container runtime indefinitely.
const DBUS_CALL_TIMEOUT: Duration = Duration::from_secs(30);
- // Wrapped in a timeout to prevent stalling the container runtime indefinitely.
+ // H: Wrapped in a timeout to prevent stalling the container runtime indefinitely.
let reply: bool = tokio::time::timeout(
- // Wrapped in a timeout to prevent stalling the container runtime indefinitely.
+ // H: Wrapped in a timeout to prevent stalling the container runtime indefinitely.
let reply: String = tokio::time::timeout(As per coding guidelines "Use prefixed comment tags in source code: H (hardening), M (mitigation), SC (seccomp design), PM (Phase 2 feature), DC (design choice), L (lifecycle constraint), A/B/C (v6 audit fixes)".
Also applies to: 179-180, 212-213
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-hook/src/main.rs` around lines 32 - 35, The comment above the
DBUS_CALL_TIMEOUT constant (and the other noted comment blocks) must include the
repository's audit-prefix tag (e.g., H:, M:, SC:, PM:, DC:, L:, or A/B/C:) per
guidelines; update the comment(s) that currently read "Maximum time to wait..."
to start with the appropriate tag (for example "H: Maximum time to wait..." if
it's a hardening concern) so the file's comment-tag convention is
followed—locate constants DBUS_CALL_TIMEOUT and the other comment occurrences in
main.rs and prefix each comment line with the correct audit tag.
| /// Path to the audit store directory (used when --bundle is not provided) | ||
| #[arg(long, default_value = "/var/lib/puzzled/branches/audit")] | ||
| audit_dir: PathBuf, | ||
| /// Path to the Ed25519 public key (hex file) for signature verification | ||
| #[arg(long)] | ||
| pubkey: Option<PathBuf>, | ||
| /// Only verify a specific branch's attestation chain | ||
| #[arg(long)] | ||
| branch_id: Option<String>, | ||
| /// Verify Merkle tree inclusion proofs (requires attestation dir) | ||
| #[arg(long)] | ||
| merkle: bool, | ||
| /// Path to the attestation/Merkle tree directory | ||
| #[arg(long, default_value = "/var/lib/puzzled/branches/attestation")] | ||
| attestation_dir: PathBuf, | ||
| /// Path to an exported attestation bundle file (alternative to --audit-dir) | ||
| #[arg(long, conflicts_with = "audit_dir")] | ||
| bundle: Option<PathBuf>, | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Test the conflicts_with behavior with default values
# This requires puzzlectl to be built, so just verify the clap version behavior
rg -n "conflicts_with.*default_value" --type=rust crates/puzzlectl/Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
# First, examine the full CLI struct context to see the complete picture
cat -n crates/puzzlectl/src/cli.rs | head -350 | tail -50Repository: LobsterTrap/puzzlepod
Length of output: 2317
🏁 Script executed:
# Check clap version in Cargo.toml
rg "clap" crates/puzzlectl/Cargo.toml -A 3Repository: LobsterTrap/puzzlepod
Length of output: 204
🏁 Script executed:
# Search for tests or usage of the verify command
rg -n "verify" crates/puzzlectl/src/ -t rust --type-list | head -20Repository: LobsterTrap/puzzlepod
Length of output: 454
🏁 Script executed:
# Search for any discussion of conflicts_with handling in the codebase
rg -n "bundle" crates/puzzlectl/src/cli.rs -B 2 -A 2Repository: LobsterTrap/puzzlepod
Length of output: 867
🏁 Script executed:
# Search for tests using the verify command with --bundle
rg -n "bundle\|verify" crates/puzzlectl/src/ -t rust -B 2 -A 2 | grep -E "(test|bundle|verify)" | head -30Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
# Check if there's any implementation code that handles the verify command
rg -n "AttestationAction::Verify" crates/puzzlectl/src/ -A 10Repository: LobsterTrap/puzzlepod
Length of output: 793
🌐 Web query:
clap 4 conflicts_with default_value behavior rust
💡 Result:
In Clap v4 for Rust, arguments with default_value (or default_value_t in derive) are treated as "used" during validation, even when not explicitly provided on the command line. This means conflicts_with checks consider them present via their default. If arg A has default_value and conflicts with arg B, running the program with only B (no A on CLI) will error with a conflict, because A's default makes it "used". This behavior was a bug in Clap v2 (issues #1071, #1605), fixed in v3+ so defaults do not trigger conflicts unless the arg is explicitly used. Workarounds: - Use Option fields without default_value; handle defaults in application code (e.g., field.unwrap_or(default)). - This avoids Clap setting the arg as used, allowing conflicts to work based on explicit CLI presence. - Note: Help text won't show defaults automatically. Examples from sources confirm this for builder API; derive behaves equivalently as it maps to builder methods like Arg::default_value and Arg::conflicts_with, which are raw attributes forwarded directly.
Citations:
- 1: Arguments with default values conflict even if not used clap-rs/clap#1071
- 2: Arguments with default values conflict even if not used clap-rs/clap#1605
- 3: https://docs.rs/clap/latest/clap/struct.Arg.html
- 4: https://docs.rs/clap/4.5.7/clap/_derive/index.html
- 5: https://chromium.googlesource.com/external/github.com/clap-rs/clap/+/refs/tags/upstream/clap_complete_fig-v3.2.4/examples/derive_ref/README.md
- 6: https://docs.rs/clap/4.4.6/clap/struct.Arg.html
🏁 Script executed:
# Check if audit_dir usage in the implementation actually requires it to work
cat -n crates/puzzlectl/src/main.rs | sed -n '54,75p'Repository: LobsterTrap/puzzlepod
Length of output: 967
Fix conflicts_with behavior with default values.
The --bundle option has conflicts_with = "audit_dir", but audit_dir has a default_value. In clap v4, arguments with default_value are marked as "used" during validation, causing conflicts_with to trigger even when the argument isn't explicitly provided. This means --bundle /path/to/bundle.json will fail with a conflict error.
Use Option<T> without default_value and handle the default in code instead:
#[arg(long)]
audit_dir: Option<PathBuf>,Then in the implementation, apply the default: audit_dir.unwrap_or_else(|| "/var/lib/puzzled/branches/audit".into())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/cli.rs` around lines 315 - 333, The audit_dir argument
is declared with a default_value and conflicts_with bundle, causing clap v4 to
treat it as present and always trigger the conflict; change the audit_dir field
from PathBuf with default_value to Option<PathBuf> (i.e., #[arg(long)]
audit_dir: Option<PathBuf>) and remove the default_value, then where the CLI is
consumed (the code that reads the parsed struct before using audit_dir, e.g.,
the CLI handling or run function), replace direct uses with
audit_dir.unwrap_or_else(|| PathBuf::from("/var/lib/puzzled/branches/audit")) so
the default is applied at runtime only when audit_dir was not provided; keep
bundle: Option<PathBuf> and conflicts_with behavior intact.
| // 4. §3.1.8: Verify timestamp ordering (timestamp >= parent's timestamp) | ||
| if let Some(ts) = record["timestamp"].as_str() { | ||
| if let Some(ref prev_ts) = prev_timestamp { | ||
| if ts < prev_ts.as_str() { | ||
| timestamp_violations += 1; | ||
| eprintln!( | ||
| " FAIL: timestamp regression — record {} has {} < parent {}", | ||
| rid, ts, prev_ts | ||
| ); | ||
| } | ||
| } | ||
| prev_timestamp = Some(ts.to_string()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how timestamps are formatted in the codebase
rg -n "timestamp.*RFC3339|chrono.*format|Utc\.now\(\)" --type=rust crates/puzzled-types/src/ crates/puzzled/src/ | head -20Repository: LobsterTrap/puzzlepod
Length of output: 381
🏁 Script executed:
# First, let's examine the attestation.rs file around the lines in question
head -n 700 crates/puzzlectl/src/commands/attestation.rs | tail -n 50Repository: LobsterTrap/puzzlepod
Length of output: 1877
🏁 Script executed:
# Search for timestamp-related code in attestation.rs to understand the data flow
rg -n "timestamp" crates/puzzlectl/src/commands/attestation.rs -B 2 -A 2 | head -40Repository: LobsterTrap/puzzlepod
Length of output: 1794
🏁 Script executed:
# Check the structure of the record being verified - likely a JSON structure
rg -n "record\[.*timestamp" crates/puzzlectl/src/commands/attestation.rs -B 3 -A 3Repository: LobsterTrap/puzzlepod
Length of output: 389
🏁 Script executed:
# Search for timestamp format specifications in the codebase (particularly in puzzled-types or schemas)
rg -n "timestamp|RFC.*3339|ISO.*8601" crates/puzzled-types/src/ --type=rust | head -20Repository: LobsterTrap/puzzlepod
Length of output: 1211
🏁 Script executed:
# Search for where records are parsed/loaded to understand timestamp source
rg -n "load.*record|parse.*record|serde.*deserialize" crates/puzzlectl/src/commands/attestation.rs -B 2 -A 2Repository: LobsterTrap/puzzlepod
Length of output: 470
🏁 Script executed:
# Check for any timestamp normalization or validation functions
rg -n "normalize.*timestamp|validate.*timestamp|parse.*timestamp" crates/puzzlectl/src/ --type=rustRepository: LobsterTrap/puzzlepod
Length of output: 438
🏁 Script executed:
# Look for the actual records being verified - where do they come from?
rg -n "records\s*=" crates/puzzlectl/src/commands/attestation.rs -B 3 -A 3Repository: LobsterTrap/puzzlepod
Length of output: 698
🏁 Script executed:
# Check if there's any RFC3339 parsing or formatting in attestation or related code
rg -n "RFC.*3339|rfc.*3339|DateTime|chrono::" crates/puzzlectl/src/commands/attestation.rsRepository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
# Find the parse_rfc3339_approx function to understand what it does
rg -n "fn parse_rfc3339_approx" crates/puzzlectl/src/ --type=rust -A 10Repository: LobsterTrap/puzzlepod
Length of output: 1850
🏁 Script executed:
# Check the compliance.rs usage context for parse_rfc3339_approx
rg -n "parse_rfc3339_approx" crates/puzzlectl/src/ --type=rust -B 3 -A 3Repository: LobsterTrap/puzzlepod
Length of output: 9665
🏁 Script executed:
# Look at the load_attestation_records function to see how timestamps are handled
sed -n '817,900p' crates/puzzlectl/src/commands/attestation.rsRepository: LobsterTrap/puzzlepod
Length of output: 3478
Use RFC3339 parsing instead of string comparison for timestamp ordering.
The comparison ts < prev_ts.as_str() is vulnerable to RFC3339 format variations. Equivalent timestamps like 2024-01-01T00:00:00Z and 2024-01-01T00:00:00+00:00 compare incorrectly as strings ('Z' < '+' is false). Since records are loaded from external NDJSON files without timestamp validation, inconsistent formats could cause false negatives in violation detection.
The codebase already defines parse_rfc3339_approx() in compliance.rs for proper RFC3339 parsing. Use it to parse both timestamps to epoch seconds before comparison, or add timestamp normalization during record loading.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/attestation.rs` around lines 676 - 688, Replace
the string comparison of timestamps in the attestation check with RFC3339-aware
numeric comparison: use the existing parse_rfc3339_approx() to parse both ts and
the value stored in prev_timestamp (or parse prev_timestamp when you set it) to
a numeric epoch (or comparable chrono::DateTime) and compare those values before
incrementing timestamp_violations and printing the failure for record rid;
ensure to handle parse errors (e.g., treat an unparseable timestamp as a
violation or skip comparison consistently) and update where prev_timestamp is
set so it stores the normalized/parsed form (or keep the original string but
parse when comparing) to avoid incorrect string-ordering between formats like
"Z" vs "+00:00".
| let result = client.query_audit_events(&filter.to_string()).await?; | ||
| println!("{result}"); | ||
| } |
There was a problem hiding this comment.
List action ignores OutputFormat parameter.
The output parameter is unused for the List action - results are always printed as raw JSON regardless of the specified format. This is inconsistent with other commands that use output_json_or_text and violates the expectation that --output=text provides human-readable output.
♻️ Suggested fix
let result = client.query_audit_events(&filter.to_string()).await?;
- println!("{result}");
+ match output {
+ OutputFormat::Json => println!("{result}"),
+ OutputFormat::Text => {
+ // Parse and format audit events for human readability
+ println!("{result}");
+ }
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/audit.rs` around lines 24 - 26, The List action
currently ignores the OutputFormat and always prints raw JSON; modify the branch
that calls client.query_audit_events(&filter.to_string()).await? and replace the
println!("{result}") with a call that respects the output parameter (e.g., use
the existing output_json_or_text helper or equivalent) so that when `output` is
OutputFormat::Text it renders human-readable text and when OutputFormat::Json it
prints JSON; ensure you pass the obtained `result` (or its serialized form) and
the `output` variable into output_json_or_text (or implement the same formatting
logic) so the List action behaves consistently with other commands.
| /// Verify an IMA manifest signature. | ||
| pub fn cmd_audit_verify(hash_or_path: &str) -> Result<()> { | ||
| let path = if std::path::Path::new(hash_or_path).exists() { | ||
| std::path::PathBuf::from(hash_or_path) | ||
| } else { | ||
| // Try to find the manifest by branch ID hash | ||
| let manifest_dir = std::path::PathBuf::from("/var/lib/puzzled/branches/manifests"); | ||
| let candidate = manifest_dir.join(format!("{}.manifest.yaml", hash_or_path)); | ||
| if candidate.exists() { | ||
| candidate | ||
| } else { | ||
| anyhow::bail!( | ||
| "manifest not found: {} (tried path and /var/lib/puzzled/branches/manifests/)", | ||
| hash_or_path | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| let contents = | ||
| std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; | ||
|
|
||
| println!("Manifest: {}", path.display()); | ||
| println!("{}", contents); | ||
|
|
||
| // T23: Signature verification is not yet implemented — fail explicitly | ||
| // rather than silently succeeding and misleading the operator. | ||
| eprintln!("WARNING: signature verification is not yet implemented."); | ||
| eprintln!( | ||
| "The manifest contents are displayed above but have NOT been cryptographically verified." | ||
| ); | ||
| eprintln!( | ||
| "Use puzzled's attestation bundle (puzzlectl attestation verify-bundle) for verified audit." | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
cmd_audit_verify returns Ok(()) despite performing no verification.
The function name and docstring say "Verify an IMA manifest signature" but it only prints the manifest contents and warnings. Returning Ok(()) could mislead callers/scripts into believing verification succeeded. Consider returning an error or using a distinct exit code.
🛡️ Suggested improvement
eprintln!(
"Use puzzled's attestation bundle (puzzlectl attestation verify-bundle) for verified audit."
);
- Ok(())
+ // T23: Return error to indicate verification was not performed
+ anyhow::bail!("signature verification not implemented - use `puzzlectl attestation verify-bundle`")Alternatively, rename the function to cmd_audit_show if display-only behavior is intentional.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// Verify an IMA manifest signature. | |
| pub fn cmd_audit_verify(hash_or_path: &str) -> Result<()> { | |
| let path = if std::path::Path::new(hash_or_path).exists() { | |
| std::path::PathBuf::from(hash_or_path) | |
| } else { | |
| // Try to find the manifest by branch ID hash | |
| let manifest_dir = std::path::PathBuf::from("/var/lib/puzzled/branches/manifests"); | |
| let candidate = manifest_dir.join(format!("{}.manifest.yaml", hash_or_path)); | |
| if candidate.exists() { | |
| candidate | |
| } else { | |
| anyhow::bail!( | |
| "manifest not found: {} (tried path and /var/lib/puzzled/branches/manifests/)", | |
| hash_or_path | |
| ); | |
| } | |
| }; | |
| let contents = | |
| std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; | |
| println!("Manifest: {}", path.display()); | |
| println!("{}", contents); | |
| // T23: Signature verification is not yet implemented — fail explicitly | |
| // rather than silently succeeding and misleading the operator. | |
| eprintln!("WARNING: signature verification is not yet implemented."); | |
| eprintln!( | |
| "The manifest contents are displayed above but have NOT been cryptographically verified." | |
| ); | |
| eprintln!( | |
| "Use puzzled's attestation bundle (puzzlectl attestation verify-bundle) for verified audit." | |
| ); | |
| Ok(()) | |
| } | |
| /// Verify an IMA manifest signature. | |
| pub fn cmd_audit_verify(hash_or_path: &str) -> Result<()> { | |
| let path = if std::path::Path::new(hash_or_path).exists() { | |
| std::path::PathBuf::from(hash_or_path) | |
| } else { | |
| // Try to find the manifest by branch ID hash | |
| let manifest_dir = std::path::PathBuf::from("/var/lib/puzzled/branches/manifests"); | |
| let candidate = manifest_dir.join(format!("{}.manifest.yaml", hash_or_path)); | |
| if candidate.exists() { | |
| candidate | |
| } else { | |
| anyhow::bail!( | |
| "manifest not found: {} (tried path and /var/lib/puzzled/branches/manifests/)", | |
| hash_or_path | |
| ); | |
| } | |
| }; | |
| let contents = | |
| std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; | |
| println!("Manifest: {}", path.display()); | |
| println!("{}", contents); | |
| // T23: Signature verification is not yet implemented — fail explicitly | |
| // rather than silently succeeding and misleading the operator. | |
| eprintln!("WARNING: signature verification is not yet implemented."); | |
| eprintln!( | |
| "The manifest contents are displayed above but have NOT been cryptographically verified." | |
| ); | |
| eprintln!( | |
| "Use puzzled's attestation bundle (puzzlectl attestation verify-bundle) for verified audit." | |
| ); | |
| // T23: Return error to indicate verification was not performed | |
| anyhow::bail!("signature verification not implemented - use `puzzlectl attestation verify-bundle`") | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/audit.rs` around lines 50 - 85, The function
cmd_audit_verify currently only prints the manifest and warnings but returns
Ok(()), which falsely signals success; either make it fail explicitly or rename
it if display-only is intended. To fix, change cmd_audit_verify to return an
error when verification isn’t implemented (e.g., return an anyhow error with a
clear message like "signature verification not implemented — use attestation
verify-bundle") so callers get a non-zero exit, or alternatively rename the
function to cmd_audit_show and update the docstring and all call sites to
reflect display-only behavior; ensure to update any CLI wiring that references
cmd_audit_verify if you choose to rename it.
| let enc_path = secrets_dir.join(format!("{}.enc", name)); | ||
| std::fs::write(&enc_path, &encrypted)?; | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| std::fs::set_permissions( | ||
| &enc_path, | ||
| std::fs::Permissions::from_mode(0o600), | ||
| )?; | ||
| } |
There was a problem hiding this comment.
Credential name used directly in filename without sanitization — potential path traversal.
If name contains path separators (e.g., ../../../etc/passwd), the encrypted credential could be written outside the intended secrets directory. While unlikely in practice, this should be validated for defense-in-depth.
🛡️ Proposed fix
+ // Sanitize credential name to prevent path traversal
+ if name.contains('/') || name.contains('\\') || name.contains("..") {
+ anyhow::bail!("credential name must not contain path separators or '..'");
+ }
+
let enc_path = secrets_dir.join(format!("{}.enc", name));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/credential.rs` around lines 267 - 276, Validate
and sanitize the credential "name" before building enc_path so it cannot escape
secrets_dir; ensure the code that constructs enc_path (the
secrets_dir.join(format!("{}.enc", name)) usage) rejects or normalizes names
containing path separators, ".." components, or absolute paths and only allows a
safe character set (e.g., alphanumeric, underscore, dash) or explicitly
canonicalize and verify the resulting path is inside secrets_dir; update the
logic that currently calls std::fs::write(&enc_path, &encrypted) to run after
this validation so writing and permission-setting (std::fs::set_permissions)
cannot operate on a traversed path.
| quarantine_sender: { | ||
| // §3.3: Create quarantine channel — receiver freezes | ||
| // the branch cgroup when DLP detects a Quarantine-level violation. | ||
| let (tx, mut rx) = | ||
| tokio::sync::mpsc::channel::<puzzled_types::BranchId>(16); | ||
| let cgroup_path = self.sandboxes.get(id).map(|h| h.cgroup_path.clone()); | ||
| if let Ok(rt_handle) = tokio::runtime::Handle::try_current() { | ||
| rt_handle.spawn(async move { | ||
| while let Some(branch_id) = rx.recv().await { | ||
| if let Some(ref cg_path) = cgroup_path { | ||
| #[cfg(target_os = "linux")] | ||
| if let Err(e) = crate::sandbox::cgroup::CgroupManager::freeze( | ||
| cg_path, | ||
| ) { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| error = %e, | ||
| "§3.3: quarantine via cgroup.freeze failed" | ||
| ); | ||
| } else { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "§3.3: branch quarantined via cgroup.freeze (DLP violation)" | ||
| ); | ||
| } | ||
| #[cfg(not(target_os = "linux"))] | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "§3.3: quarantine requested but cgroup.freeze unavailable (non-Linux)" | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| Some(tx) | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the order of sandbox insertion vs quarantine channel setup
rg -n "sandboxes.insert|sandboxes.get" crates/puzzled/src/branch/activate.rsRepository: LobsterTrap/puzzlepod
Length of output: 269
🏁 Script executed:
#!/bin/bash
# Get the full function context around the quarantine channel setup and sandbox insertion
sed -n '180,460p' crates/puzzled/src/branch/activate.rs | cat -nRepository: LobsterTrap/puzzlepod
Length of output: 18760
Quarantine channel uses unavailable cgroup_path from unregistered sandbox.
At line 194, self.sandboxes.get(id) returns None because the sandbox handle isn't inserted until line 274. The captured cgroup_path will always be None, causing quarantine freezing to silently fail.
Use handle.cgroup_path.clone() directly instead of fetching from self.sandboxes.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/activate.rs` around lines 189 - 224, The
quarantine_sender closure captures a cgroup_path computed from
self.sandboxes.get(id) which is None because the sandbox handle isn't inserted
until later; to fix, grab the sandbox handle's cgroup path directly and capture
that into the spawned task (replace the self.sandboxes.get(id).map(|h|
h.cgroup_path.clone()) expression with handle.cgroup_path.clone()), ensuring the
local handle variable is in scope when creating quarantine_sender so the
rt_handle.spawn async block uses the real cgroup path instead of an always-None
value.
There was a problem hiding this comment.
Actionable comments posted: 14
🧹 Nitpick comments (1)
crates/puzzle-init/src/main.rs (1)
1204-1216: Test coverage missing for V6 ABI variant.The test iterates through V1-V5 but omits the newly added V6 variant. The test at line 1241 has the same omission.
✅ Add V6 to the test cases
#[test] fn test_parse_rules_all_abi_versions() { for (abi_str, expected) in [ ("V1", AbiVersion::V1), ("V2", AbiVersion::V2), ("V3", AbiVersion::V3), ("V4", AbiVersion::V4), ("V5", AbiVersion::V5), + ("V6", AbiVersion::V6), ] {And similarly for
test_parse_rules_string_abi_values.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-init/src/main.rs` around lines 1204 - 1216, The tests test_parse_rules_all_abi_versions and test_parse_rules_string_abi_values iterate expected ABI variants but omit the new AbiVersion::V6; update both test cases to include ("V6", AbiVersion::V6) (and the corresponding string entry) when building the arrays used with parse_rules so the new V6 variant is exercised; locate the arrays in test_parse_rules_all_abi_versions and the similar array in test_parse_rules_string_abi_values and add the V6 tuple/string entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/puzzle-init/src/main.rs`:
- Around line 144-151: The V6-to-V5 downgrade message is using tracing::warn!
which will be silent without a subscriber; change the warning to use eprintln!
instead so users see it in this minimal init binary—replace the tracing::warn!
call inside the AbiVersion::V6 match arm (which returns ABI::V5) with an
eprintln! that conveys the same message about V6-only features (signal scoping
and abstract Unix socket scoping) being unavailable.
- Around line 106-116: The doc comments for connect_tcp_ports and bind_tcp_ports
claim "If empty, all ConnectTcp/BindTcp is denied" but the actual enforcement in
main.rs skips creating the network Landlock ruleset when both Vecs are empty
(resulting in no enforcement); to fix, either update the doc comments to state
that empty lists mean "no network ruleset created (no network enforcement)" or
change the enforcement logic where the network ruleset is constructed so that an
empty connect_tcp_ports or bind_tcp_ports results in creating a Landlock ruleset
that explicitly denies all ConnectTcp/BindTcp (mirror sandbox/landlock.rs
semantics) — update the code path that currently skips ruleset creation and/or
adjust the doc comments for connect_tcp_ports and bind_tcp_ports accordingly.
In `@crates/puzzlectl/src/cli.rs`:
- Line 1: Add the project's standard SPDX license header as the very first lines
of this source file (above the existing use clap::{Parser, Subcommand}; line);
insert the canonical comment such as // SPDX-License-Identifier: Apache-2.0 OR
MIT (or the repository's required SPDX text) so the file includes the required
license header.
In `@crates/puzzlectl/src/commands/policy.rs`:
- Line 1: Add the required SPDX license header as the very first lines of the
file (above the existing `use anyhow::{Context, Result};` line) following the
repository's standard header format (for example "SPDX-License-Identifier:
<LICENSE>") so the pipeline recognizes the license; ensure the header text
exactly matches the project's other Rust files including any
copyright/year/holder lines if applicable.
- Around line 66-77: The Rego input built in the changes mapping omits the
FileChange fields entropy and has_base64_blocks; update the JSON construction
inside the changes.iter().map(...) in policy.rs (the block that creates the
variable input) to include "entropy": c.entropy and "has_base64_blocks":
c.has_base64_blocks (ensuring you handle any Option<> or types the same way
other fields like target are handled) so the Rego input matches production rules
(e.g., deny_high_entropy and deny_base64_blocks).
In `@crates/puzzlectl/src/commands/profile.rs`:
- Line 1: This file is missing the required SPDX license header; add the
project's standard SPDX header comment block at the very top of the file (above
the existing line containing use anyhow::{Context, Result};), matching the exact
format used in other crate source files (e.g., the same SPDX-FileCopyrightText
and SPDX-License-Identifier lines) so the pipeline recognizes the license.
In `@crates/puzzlectl/src/commands/run.rs`:
- Line 1: Add the required SPDX license header to the top of the file to satisfy
CI: insert the line "// SPDX-License-Identifier: Apache-2.0" as the very first
line of crates/puzzlectl/src/commands/run.rs (above the use statement that
imports anyhow::{Context, Result}) so the file begins with the SPDX identifier
before any code or imports.
- Around line 72-134: The function output_run_result_with_governance currently
only treats "Approved" and "Rejected" in the policy_result and misses policy
errors; update it so that any policy_result key "Error" is considered a failed
commit: when parsing commit_json (both OutputFormat::Json and OutputFormat::Text
paths) detect v.get("policy_result").get("Error") and set rejected = true; in
the Text branch print a failure/rolled-back message similar to the Rejected path
(include error count or message details from the Error value) instead of falling
through to the committed message; ensure JSON output still prints the pretty
JSON but also sets rejected when "Error" exists. This change should be applied
inside output_run_result_with_governance using the existing variables branch_id,
commit_json, and changes.
- Around line 202-205: The code currently parses diff_json into change_count
with unwrap_or(0), which hides parse failures; instead, make parsing failures
propagate as an error so malformed diffs don't get treated as zero
changes—replace the unwrap_or(0) usage around
serde_json::from_str::<Vec<serde_json::Value>>(&diff_json) so that errors return
a Err (or use ?/map_err) and stop execution rather than setting change_count to
0; locate the diff_json variable and the change_count computation (from
client.diff_branch) and ensure the function returns a failure on parse error
instead of silently continuing.
- Around line 170-174: After create_branch succeeds but before returning on
activate_branch failure, perform a rollback: call
client.delete_branch(&branch_id).await (or the appropriate delete method) if
client.activate_branch(&branch_id, &command_json).await returns an Err; ensure
you attempt the delete and log or swallow any delete errors, then propagate the
original activate error. Update the block around client.activate_branch and
branch_id to catch the activation error, run the rollback via the delete method,
and then return the original error so the new branch is not left behind.
In `@crates/puzzled-types/src/lib.rs`:
- Around line 116-183: verify_merkle_inclusion currently allows impossible
proofs because it never validates proof.leaf_index and proof.tree_size; add an
upfront check in verify_merkle_inclusion that proof.tree_size > 0 and
proof.leaf_index < proof.tree_size and return Err with a clear message (e.g.
"invalid inclusion proof: tree_size must be > 0" or "invalid inclusion proof:
leaf_index out of range") before calling compute_root_from_inclusion; reference
the InclusionProof struct's leaf_index and tree_size fields and ensure this
validation prevents cases where compute_root_from_inclusion would incorrectly
accept out-of-range indexes.
In `@crates/puzzled-types/src/profile.rs`:
- Line 1: This file is missing the SPDX header required by CI; add the exact
comment line "// SPDX-License-Identifier: Apache-2.0" as the very first line of
the file (before the existing use serde::{Deserialize, Serialize}; import) so
the compiler/CI detects the license header and the file still imports
Deserialize and Serialize as before.
- Around line 160-174: The Profile::validate() method currently omits enforcing
documented numeric ranges for several optional fields; add explicit checks in
validate() for max_open_fds (when Some, ensure 64 <= value <= 65536) and
lifetime_minutes (when Some, ensure 1 <= value <= 1440), returning the same
ValidationError/Err path used elsewhere in validate() with a clear message; also
scan the other validate blocks around the later diff (lines ~218-259) and apply
the same pattern for any other documented field ranges so the struct's
documentation and validation stay in sync.
---
Nitpick comments:
In `@crates/puzzle-init/src/main.rs`:
- Around line 1204-1216: The tests test_parse_rules_all_abi_versions and
test_parse_rules_string_abi_values iterate expected ABI variants but omit the
new AbiVersion::V6; update both test cases to include ("V6", AbiVersion::V6)
(and the corresponding string entry) when building the arrays used with
parse_rules so the new V6 variant is exercised; locate the arrays in
test_parse_rules_all_abi_versions and the similar array in
test_parse_rules_string_abi_values and add the V6 tuple/string entry.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8039c8a1-0feb-48f2-b616-c8b0326e8698
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (64)
crates/puzzle-hook/src/main.rscrates/puzzle-init/src/main.rscrates/puzzle-phase2-demo/src/main.rscrates/puzzle-proxy/src/credentials.rscrates/puzzle-proxy/src/lib.rscrates/puzzle-proxy/src/secure_memory.rscrates/puzzle-proxy/tests/handler_tests.rscrates/puzzlectl/src/cli.rscrates/puzzlectl/src/commands/mod.rscrates/puzzlectl/src/commands/policy.rscrates/puzzlectl/src/commands/profile.rscrates/puzzlectl/src/commands/run.rscrates/puzzlectl/src/compliance.rscrates/puzzlectl/src/lib.rscrates/puzzlectl/src/main.rscrates/puzzlectl/src/sim/batch.rscrates/puzzlectl/src/sim/engine.rscrates/puzzlectl/src/sim/repl.rscrates/puzzled-types/src/lib.rscrates/puzzled-types/src/profile.rscrates/puzzled/benches/branch.rscrates/puzzled/benches/policy.rscrates/puzzled/src/audit_store.rscrates/puzzled/src/commit.rscrates/puzzled/src/config.rscrates/puzzled/src/conflict.rscrates/puzzled/src/dbus/mod.rscrates/puzzled/src/diff.rscrates/puzzled/src/error.rscrates/puzzled/src/ima.rscrates/puzzled/src/landlock_rules.rscrates/puzzled/src/lib.rscrates/puzzled/src/main.rscrates/puzzled/src/policy.rscrates/puzzled/src/profile.rscrates/puzzled/src/provenance.rscrates/puzzled/src/sandbox/bpf_lsm.rscrates/puzzled/src/sandbox/cgroup.rscrates/puzzled/src/sandbox/fanotify.rscrates/puzzled/src/sandbox/landlock.rscrates/puzzled/src/sandbox/overlay.rscrates/puzzled/src/sandbox/quota.rscrates/puzzled/src/sandbox/seccomp/filter.rscrates/puzzled/src/sandbox/seccomp/mod.rscrates/puzzled/src/sandbox/seccomp/notif.rscrates/puzzled/src/seccomp_handler.rscrates/puzzled/src/seccomp_profile.rscrates/puzzled/src/test_helpers.rscrates/puzzled/src/trust.rscrates/puzzled/src/wal.rscrates/puzzled/tests/branch_lifecycle.rscrates/puzzled/tests/concurrent_branches.rscrates/puzzled/tests/crash_recovery.rscrates/puzzled/tests/e2e_adversarial.rscrates/puzzled/tests/e2e_governance_lifecycle.rscrates/puzzled/tests/e2e_scenarios.rscrates/puzzled/tests/ima_integration.rscrates/puzzled/tests/phase2_features.rscrates/puzzled/tests/podman_native_integration.rscrates/puzzled/tests/policy_evaluation.rscrates/puzzled/tests/sandbox_containment.rscrates/puzzled/tests/security_hardening.rsdocs/security-guide.mddocs/technical-design.md
💤 Files with no reviewable changes (1)
- crates/puzzlectl/src/sim/batch.rs
✅ Files skipped from review due to trivial changes (1)
- crates/puzzlectl/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (10)
- crates/puzzled/benches/branch.rs
- crates/puzzle-phase2-demo/src/main.rs
- crates/puzzlectl/src/commands/mod.rs
- crates/puzzlectl/src/sim/engine.rs
- crates/puzzle-proxy/src/credentials.rs
- crates/puzzle-proxy/tests/handler_tests.rs
- crates/puzzlectl/src/sim/repl.rs
- crates/puzzle-proxy/src/lib.rs
- crates/puzzle-hook/src/main.rs
- crates/puzzle-proxy/src/secure_memory.rs
|
|
||
| /// C-1/M-2: TCP ports the agent is allowed to connect to. | ||
| /// Landlock ABI v4+ (kernel 6.7+). If empty, all ConnectTcp is denied. | ||
| /// Mirrors sandbox/landlock.rs network ruleset. | ||
| #[serde(default)] | ||
| pub connect_tcp_ports: Vec<u16>, | ||
|
|
||
| /// C-1/M-2: TCP ports the agent is allowed to bind to. | ||
| /// Landlock ABI v4+ (kernel 6.7+). If empty, all BindTcp is denied. | ||
| #[serde(default)] | ||
| pub bind_tcp_ports: Vec<u16>, |
There was a problem hiding this comment.
Doc comments contradict actual enforcement behavior.
Lines 108 and 114 state "If empty, all ConnectTcp/BindTcp is denied", but the code at line 271 skips network ruleset creation entirely when both lists are empty — meaning no enforcement occurs (fail-open, not deny-all).
Either update the docs to reflect actual behavior, or (per the existing review comment) change the enforcement logic to match the documented deny-all semantics.
📝 If keeping current behavior, update docs to reflect it
- /// C-1/M-2: TCP ports the agent is allowed to connect to.
- /// Landlock ABI v4+ (kernel 6.7+). If empty, all ConnectTcp is denied.
+ /// C-1/M-2: TCP ports the agent is allowed to connect to.
+ /// Landlock ABI v4+ (kernel 6.7+). If empty, no ConnectTcp restriction is applied.
/// Mirrors sandbox/landlock.rs network ruleset.
#[serde(default)]
pub connect_tcp_ports: Vec<u16>,
- /// C-1/M-2: TCP ports the agent is allowed to bind to.
- /// Landlock ABI v4+ (kernel 6.7+). If empty, all BindTcp is denied.
+ /// C-1/M-2: TCP ports the agent is allowed to bind to.
+ /// Landlock ABI v4+ (kernel 6.7+). If empty, no BindTcp restriction is applied.
#[serde(default)]
pub bind_tcp_ports: Vec<u16>,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-init/src/main.rs` around lines 106 - 116, The doc comments for
connect_tcp_ports and bind_tcp_ports claim "If empty, all ConnectTcp/BindTcp is
denied" but the actual enforcement in main.rs skips creating the network
Landlock ruleset when both Vecs are empty (resulting in no enforcement); to fix,
either update the doc comments to state that empty lists mean "no network
ruleset created (no network enforcement)" or change the enforcement logic where
the network ruleset is constructed so that an empty connect_tcp_ports or
bind_tcp_ports results in creating a Landlock ruleset that explicitly denies all
ConnectTcp/BindTcp (mirror sandbox/landlock.rs semantics) — update the code path
that currently skips ruleset creation and/or adjust the doc comments for
connect_tcp_ports and bind_tcp_ports accordingly.
| // M22: V6 maps to crate ABI V5; warn that V6-only features are unavailable. | ||
| AbiVersion::V6 => { | ||
| tracing::warn!( | ||
| "Landlock ABI V6 requested but crate only supports V5 — \ | ||
| signal scoping and abstract Unix socket scoping are unavailable" | ||
| ); | ||
| ABI::V5 | ||
| } |
There was a problem hiding this comment.
tracing::warn! will be silently discarded without a subscriber.
This file explicitly avoids async/tokio and uses eprintln! for all other status messages (e.g., lines 300-304, 329-332). Without a tracing subscriber configured in this minimal init binary, the V6 downgrade warning will never reach users.
🔧 Proposed fix: use eprintln! for consistency
AbiVersion::V5 => ABI::V5,
// M22: V6 maps to crate ABI V5; warn that V6-only features are unavailable.
AbiVersion::V6 => {
- tracing::warn!(
- "Landlock ABI V6 requested but crate only supports V5 — \
- signal scoping and abstract Unix socket scoping are unavailable"
+ eprintln!(
+ "puzzle-init: Landlock ABI V6 requested but crate only supports V5 — \
+ signal scoping and abstract Unix socket scoping unavailable"
);
ABI::V5
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // M22: V6 maps to crate ABI V5; warn that V6-only features are unavailable. | |
| AbiVersion::V6 => { | |
| tracing::warn!( | |
| "Landlock ABI V6 requested but crate only supports V5 — \ | |
| signal scoping and abstract Unix socket scoping are unavailable" | |
| ); | |
| ABI::V5 | |
| } | |
| // M22: V6 maps to crate ABI V5; warn that V6-only features are unavailable. | |
| AbiVersion::V6 => { | |
| eprintln!( | |
| "puzzle-init: Landlock ABI V6 requested but crate only supports V5 — \ | |
| signal scoping and abstract Unix socket scoping unavailable" | |
| ); | |
| ABI::V5 | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-init/src/main.rs` around lines 144 - 151, The V6-to-V5
downgrade message is using tracing::warn! which will be silent without a
subscriber; change the warning to use eprintln! instead so users see it in this
minimal init binary—replace the tracing::warn! call inside the AbiVersion::V6
match arm (which returns ABI::V5) with an eprintln! that conveys the same
message about V6-only features (signal scoping and abstract Unix socket scoping)
being unavailable.
| let input = serde_json::json!({ | ||
| "changes": changes.iter().map(|c| { | ||
| serde_json::json!({ | ||
| "path": c.path.to_string_lossy(), | ||
| "kind": format!("{:?}", c.kind), | ||
| "size": c.size, | ||
| "checksum": &c.checksum, | ||
| "target": c.target.as_deref().unwrap_or(""), | ||
| "new_mode": c.new_mode, | ||
| }) | ||
| }).collect::<Vec<_>>() | ||
| }); |
There was a problem hiding this comment.
Rego input is missing entropy and has_base64_blocks fields, causing test/production divergence.
The offline policy test builds a Rego input that omits entropy and has_base64_blocks fields from FileChange. However, production Rego rules like deny_high_entropy and deny_base64_blocks in policies/rules/commit.rego (lines 618-652) reference these fields when content_inspection_enabled is true.
This means a changeset could pass puzzlectl policy test but be rejected by the live daemon, or vice versa.
Proposed fix
let input = serde_json::json!({
"changes": changes.iter().map(|c| {
serde_json::json!({
"path": c.path.to_string_lossy(),
"kind": format!("{:?}", c.kind),
"size": c.size,
"checksum": &c.checksum,
"target": c.target.as_deref().unwrap_or(""),
"new_mode": c.new_mode,
+ "old_mode": c.old_mode,
+ "entropy": c.entropy,
+ "has_base64_blocks": c.has_base64_blocks,
})
}).collect::<Vec<_>>()
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let input = serde_json::json!({ | |
| "changes": changes.iter().map(|c| { | |
| serde_json::json!({ | |
| "path": c.path.to_string_lossy(), | |
| "kind": format!("{:?}", c.kind), | |
| "size": c.size, | |
| "checksum": &c.checksum, | |
| "target": c.target.as_deref().unwrap_or(""), | |
| "new_mode": c.new_mode, | |
| }) | |
| }).collect::<Vec<_>>() | |
| }); | |
| let input = serde_json::json!({ | |
| "changes": changes.iter().map(|c| { | |
| serde_json::json!({ | |
| "path": c.path.to_string_lossy(), | |
| "kind": format!("{:?}", c.kind), | |
| "size": c.size, | |
| "checksum": &c.checksum, | |
| "target": c.target.as_deref().unwrap_or(""), | |
| "new_mode": c.new_mode, | |
| "old_mode": c.old_mode, | |
| "entropy": c.entropy, | |
| "has_base64_blocks": c.has_base64_blocks, | |
| }) | |
| }).collect::<Vec<_>>() | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/policy.rs` around lines 66 - 77, The Rego input
built in the changes mapping omits the FileChange fields entropy and
has_base64_blocks; update the JSON construction inside the
changes.iter().map(...) in policy.rs (the block that creates the variable input)
to include "entropy": c.entropy and "has_base64_blocks": c.has_base64_blocks
(ensuring you handle any Option<> or types the same way other fields like target
are handled) so the Rego input matches production rules (e.g., deny_high_entropy
and deny_base64_blocks).
| // Step 2: Activate branch | ||
| if matches!(output, OutputFormat::Text) { | ||
| eprintln!("[run] branch {branch_id} created, activating..."); | ||
| } | ||
| client.activate_branch(&branch_id, &command_json).await?; |
There was a problem hiding this comment.
Rollback the branch if activation fails.
Once create_branch succeeds, an activate_branch error returns early and leaves the new branch behind. Clean it up before bubbling the error.
Suggested fix
- client.activate_branch(&branch_id, &command_json).await?;
+ if let Err(err) = client.activate_branch(&branch_id, &command_json).await {
+ let _ = client.rollback_branch(&branch_id, "activation failed").await;
+ anyhow::bail!("activating branch {branch_id}: {err}");
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/run.rs` around lines 170 - 174, After
create_branch succeeds but before returning on activate_branch failure, perform
a rollback: call client.delete_branch(&branch_id).await (or the appropriate
delete method) if client.activate_branch(&branch_id, &command_json).await
returns an Err; ensure you attempt the delete and log or swallow any delete
errors, then propagate the original activate error. Update the block around
client.activate_branch and branch_id to catch the activation error, run the
rollback via the delete method, and then return the original error so the new
branch is not left behind.
| let diff_json = client.diff_branch(&branch_id).await?; | ||
| let change_count = serde_json::from_str::<Vec<serde_json::Value>>(&diff_json) | ||
| .map(|v| v.len()) | ||
| .unwrap_or(0); |
There was a problem hiding this comment.
Don't turn a diff parse failure into “no changes”.
unwrap_or(0) conflates a malformed/unexpected diff payload with an empty diff. Lines 208-214 then roll the branch back and report success, which can silently discard real changes.
Suggested fix
- let change_count = serde_json::from_str::<Vec<serde_json::Value>>(&diff_json)
- .map(|v| v.len())
- .unwrap_or(0);
+ let change_count = serde_json::from_str::<Vec<serde_json::Value>>(&diff_json)
+ .with_context(|| format!("parsing diff for branch {branch_id}"))?
+ .len();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/run.rs` around lines 202 - 205, The code
currently parses diff_json into change_count with unwrap_or(0), which hides
parse failures; instead, make parsing failures propagate as an error so
malformed diffs don't get treated as zero changes—replace the unwrap_or(0) usage
around serde_json::from_str::<Vec<serde_json::Value>>(&diff_json) so that errors
return a Err (or use ?/map_err) and stop execution rather than setting
change_count to 0; locate the diff_json variable and the change_count
computation (from client.diff_branch) and ensure the function returns a failure
on parse error instead of silently continuing.
| pub fn compute_root_from_inclusion( | ||
| leaf_index: u64, | ||
| tree_size: u64, | ||
| leaf_hash: &[u8; 32], | ||
| proof: &[[u8; 32]], | ||
| pos: &mut usize, | ||
| ) -> Option<[u8; 32]> { | ||
| if tree_size <= 1 { | ||
| return Some(*leaf_hash); | ||
| } | ||
| // J69: storage_quota_mb upper bound | ||
| if self.storage_quota_mb > Self::MAX_STORAGE_QUOTA_MB { | ||
| errors.push(format!( | ||
| "J69: storage_quota_mb {} exceeds maximum {} (1 PiB)", | ||
| self.storage_quota_mb, | ||
| Self::MAX_STORAGE_QUOTA_MB | ||
| )); | ||
| let k = largest_power_of_2_less_than(tree_size); | ||
| if leaf_index < k { | ||
| let left = compute_root_from_inclusion(leaf_index, k, leaf_hash, proof, pos)?; | ||
| let right = *proof.get(*pos)?; | ||
| *pos += 1; | ||
| Some(hash_node(&left, &right)) | ||
| } else { | ||
| let right = | ||
| compute_root_from_inclusion(leaf_index - k, tree_size - k, leaf_hash, proof, pos)?; | ||
| let left = *proof.get(*pos)?; | ||
| *pos += 1; | ||
| Some(hash_node(&left, &right)) | ||
| } | ||
| errors | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct NetworkConfig { | ||
| pub mode: NetworkMode, | ||
| /// Allowed domains (only for Gated mode). | ||
| pub allowed_domains: Vec<String>, | ||
| /// Data residency configuration (§3.3). If None, no geographic enforcement. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub data_residency: Option<DataResidencyConfig>, | ||
| /// DLP content inspection rules file path (§3.3). If None, no content inspection. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub dlp_rules_path: Option<String>, | ||
| } | ||
|
|
||
| /// Data residency configuration for geographic enforcement (§3.3). | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct DataResidencyConfig { | ||
| /// Allowed geographic regions (ISO 3166-1 alpha-2 codes or aliases: "EU", "EEA", "US", "APAC"). | ||
| pub allowed_regions: Vec<String>, | ||
| /// Enforcement mode. | ||
| #[serde(default)] | ||
| pub geo_enforcement: GeoEnforcement, | ||
| /// Verify that DNS-resolved IPs match the claimed geographic region. | ||
| #[serde(default)] | ||
| pub dns_verification: bool, | ||
| /// Path to MaxMind GeoLite2-Country database (.mmdb). | ||
| #[serde(default = "default_geo_database")] | ||
| pub geo_database: String, | ||
| /// Domain exceptions (allowed regardless of region). | ||
| #[serde(default)] | ||
| pub exceptions: Vec<GeoException>, | ||
| } | ||
|
|
||
| fn default_geo_database() -> String { | ||
| "/usr/share/GeoIP/GeoLite2-Country.mmdb".to_string() | ||
| } | ||
|
|
||
| /// Geographic enforcement mode. | ||
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "lowercase")] | ||
| pub enum GeoEnforcement { | ||
| #[default] | ||
| Strict, | ||
| Permissive, | ||
| } | ||
|
|
||
| /// Domain exception for data residency rules. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct GeoException { | ||
| pub domain: String, | ||
| pub reason: String, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub approved_by: Option<String>, | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub expires: Option<String>, | ||
| } | ||
|
|
||
| /// Network access mode for an agent sandbox. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] | ||
| pub enum NetworkMode { | ||
| /// No network access (isolated network namespace with no interfaces). | ||
| Blocked, | ||
| /// Network access gated through puzzled proxy with domain allowlist. | ||
| Gated, | ||
| /// Full network access with logging. | ||
| Monitored, | ||
| /// Unrestricted network access. | ||
| Unrestricted, | ||
| } | ||
| /// Verify a Merkle inclusion proof against an expected root hash. | ||
| /// | ||
| /// Returns `Ok(true)` if the proof is valid, `Ok(false)` if the computed root | ||
| /// doesn't match, or `Err` if the proof is malformed. | ||
| pub fn verify_merkle_inclusion( | ||
| leaf_hash: &[u8; 32], | ||
| proof: &super::InclusionProof, | ||
| expected_root: &[u8; 32], | ||
| ) -> Result<bool, String> { | ||
| let proof_hashes: Vec<[u8; 32]> = proof | ||
| .proof_hashes | ||
| .iter() | ||
| .map(|h| { | ||
| let bytes = hex_decode(h)?; | ||
| if bytes.len() != 32 { | ||
| return Err(format!("proof hash must be 32 bytes, got {}", bytes.len())); | ||
| } | ||
| let mut arr = [0u8; 32]; | ||
| arr.copy_from_slice(&bytes); | ||
| Ok(arr) | ||
| }) | ||
| .collect::<Result<Vec<_>, _>>()?; | ||
|
|
||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct BehavioralConfig { | ||
| /// Maximum number of files deleted in a single branch session. | ||
| pub max_deletions: u32, | ||
| /// Maximum number of files read per minute. | ||
| pub max_reads_per_minute: u32, | ||
| /// Trigger on access to credential-like paths. | ||
| pub credential_access_alert: bool, | ||
| /// §3.4 G28: Phantom token prefixes to detect in file writes. | ||
| /// When a file write contains any of these prefixes, a | ||
| /// `PhantomTokenLeakage` behavioral trigger is fired. | ||
| #[serde(default)] | ||
| pub phantom_token_prefixes: Vec<String>, | ||
| } | ||
| let mut pos = 0; | ||
| let computed = compute_root_from_inclusion( | ||
| proof.leaf_index, | ||
| proof.tree_size, | ||
| leaf_hash, | ||
| &proof_hashes, | ||
| &mut pos, | ||
| ) | ||
| .ok_or_else(|| "malformed inclusion proof: insufficient proof hashes".to_string())?; | ||
|
|
||
| impl Default for BehavioralConfig { | ||
| fn default() -> Self { | ||
| Self { | ||
| max_deletions: 50, | ||
| max_reads_per_minute: 1000, | ||
| credential_access_alert: true, | ||
| phantom_token_prefixes: Vec::new(), | ||
| // RFC 6962: all proof hashes must be consumed | ||
| if pos != proof_hashes.len() { | ||
| return Err(format!( | ||
| "malformed inclusion proof: {} extra hashes", | ||
| proof_hashes.len() - pos | ||
| )); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Fail modes (safety-critical deployments) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// Fail mode for safety-critical deployments (IEC 61508 / ISO 26262). | ||
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | ||
| pub enum FailMode { | ||
| /// Default: reject commit, rollback branch. | ||
| #[default] | ||
| FailClosed, | ||
| /// Hold last safe state (do not commit, do not rollback). | ||
| FailSilent, | ||
| /// Continue with reduced capability (apply subset of changes). | ||
| FailOperational, | ||
| /// Controlled stop / return to base. | ||
| FailSafeState, | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Conflict detection | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// A conflict between concurrent branches. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct Conflict { | ||
| /// The conflicting file path. | ||
| pub path: PathBuf, | ||
| /// Branch IDs that modified this path. | ||
| pub conflicting_branches: Vec<BranchId>, | ||
| /// Type of conflict. | ||
| pub kind: ConflictKind, | ||
| } | ||
|
|
||
| /// Type of cross-branch conflict. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] | ||
| pub enum ConflictKind { | ||
| /// Both branches modified the same file. | ||
| BothModified, | ||
| /// One branch modified, another deleted. | ||
| ModifiedAndDeleted, | ||
| /// Both branches created the same new file. | ||
| BothCreated, | ||
| } | ||
|
|
||
| /// Strategy for resolving cross-branch conflicts. | ||
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | ||
| pub enum ConflictResolution { | ||
| /// Default: reject the commit. | ||
| #[default] | ||
| Reject, | ||
| /// Last writer wins (overwrite silently). | ||
| LastWriterWins, | ||
| /// Three-way merge for text files, reject for binary. | ||
| MergeIfText, | ||
| /// Non-overlapping path prefixes per branch. | ||
| ScopePartition, | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Budget / adaptive escalation | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// Budget tier for adaptive resource allocation. | ||
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | ||
| pub enum BudgetTier { | ||
| /// Minimal resources, strict limits. | ||
| #[default] | ||
| Restricted, | ||
| /// Standard allocation after proven clean commits. | ||
| Standard, | ||
| /// Extended allocation for established agents. | ||
| Extended, | ||
| } | ||
|
|
||
| /// Budget status for a branch. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct BudgetStatus { | ||
| pub branch_id: BranchId, | ||
| pub tier: BudgetTier, | ||
| pub clean_commits: u32, | ||
| pub violations: u32, | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Behavioral triggers (fanotify) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// A behavioral trigger fired by the fanotify monitor. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub enum BehavioralTrigger { | ||
| MassDeletion { | ||
| count: u32, | ||
| threshold: u32, | ||
| }, | ||
| ExcessiveReads { | ||
| rate: u32, | ||
| threshold: u32, | ||
| }, | ||
| CredentialAccess { | ||
| path: String, | ||
| }, | ||
| /// Fanotify event queue overflowed — incremental tracking is incomplete. | ||
| /// The diff engine must fall back to a full upper-dir walk for this branch. | ||
| QueueOverflow, | ||
| /// §3.4 G28: Phantom token detected in file write — potential credential leak. | ||
| /// Fired when fanotify detects a write containing `pt_puzzled_*` patterns. | ||
| PhantomTokenLeakage { | ||
| /// File path where the phantom token was written. | ||
| file_path: String, | ||
| /// The phantom token prefix detected (first 16 chars max). | ||
| token_prefix: String, | ||
| }, | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Audit (persistent storage) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// Filter for querying audit events. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct AuditFilter { | ||
| /// Filter by branch ID. | ||
| pub branch_id: Option<String>, | ||
| /// Filter by event type. | ||
| pub event_type: Option<String>, | ||
| /// Filter events since this timestamp (RFC 3339). | ||
| pub since: Option<String>, | ||
| /// Maximum number of events to return. | ||
| pub limit: Option<u32>, | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Attestation (§3.1 — Cryptographic Attestation of Governance) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /// Identity of the agent that produced a governance event. | ||
| /// | ||
| /// Included in attestation records for third-party verifiability. | ||
| /// Contains only metadata (UID, profile, SELinux context) — no PII. | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct AgentIdentity { | ||
| /// POSIX UID of the agent process. | ||
| pub uid: u32, | ||
| /// Agent profile name (e.g., "restricted", "standard"). | ||
| pub profile: String, | ||
| /// SELinux context if available (e.g., "puzzlepod_t:s0:c42,c99"). | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub selinux_context: Option<String>, | ||
| /// Agent framework if reported by SDK (e.g., "langchain", "crewai"). | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub framework: Option<String>, | ||
| } | ||
|
|
||
| /// Governance decision recorded in an attestation record. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum GovernanceDecision { | ||
| Approved, | ||
| Rejected, | ||
| Rollback, | ||
| Violation, | ||
| Escape, | ||
| Killed, | ||
| Created, | ||
| } | ||
|
|
||
| impl std::fmt::Display for GovernanceDecision { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| match self { | ||
| Self::Approved => write!(f, "approved"), | ||
| Self::Rejected => write!(f, "rejected"), | ||
| Self::Rollback => write!(f, "rollback"), | ||
| Self::Violation => write!(f, "violation"), | ||
| Self::Escape => write!(f, "escape"), | ||
| Self::Killed => write!(f, "killed"), | ||
| Self::Created => write!(f, "created"), | ||
| } | ||
| Ok(computed == *expected_root) | ||
| } |
There was a problem hiding this comment.
Reject impossible inclusion proofs up front.
verify_merkle_inclusion() never checks tree_size > 0 or leaf_index < tree_size. Because compute_root_from_inclusion() returns leaf_hash for tree_size <= 1, a proof with tree_size == 1 and an out-of-range leaf_index can still verify.
Suggested fix
pub fn verify_merkle_inclusion(
leaf_hash: &[u8; 32],
proof: &super::InclusionProof,
expected_root: &[u8; 32],
) -> Result<bool, String> {
+ if proof.tree_size == 0 {
+ return Err("malformed inclusion proof: tree_size must be > 0".to_string());
+ }
+ if proof.leaf_index >= proof.tree_size {
+ return Err(format!(
+ "malformed inclusion proof: leaf_index {} out of range for tree_size {}",
+ proof.leaf_index, proof.tree_size
+ ));
+ }
+
let proof_hashes: Vec<[u8; 32]> = proof
.proof_hashes
.iter()📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub fn compute_root_from_inclusion( | |
| leaf_index: u64, | |
| tree_size: u64, | |
| leaf_hash: &[u8; 32], | |
| proof: &[[u8; 32]], | |
| pos: &mut usize, | |
| ) -> Option<[u8; 32]> { | |
| if tree_size <= 1 { | |
| return Some(*leaf_hash); | |
| } | |
| // J69: storage_quota_mb upper bound | |
| if self.storage_quota_mb > Self::MAX_STORAGE_QUOTA_MB { | |
| errors.push(format!( | |
| "J69: storage_quota_mb {} exceeds maximum {} (1 PiB)", | |
| self.storage_quota_mb, | |
| Self::MAX_STORAGE_QUOTA_MB | |
| )); | |
| let k = largest_power_of_2_less_than(tree_size); | |
| if leaf_index < k { | |
| let left = compute_root_from_inclusion(leaf_index, k, leaf_hash, proof, pos)?; | |
| let right = *proof.get(*pos)?; | |
| *pos += 1; | |
| Some(hash_node(&left, &right)) | |
| } else { | |
| let right = | |
| compute_root_from_inclusion(leaf_index - k, tree_size - k, leaf_hash, proof, pos)?; | |
| let left = *proof.get(*pos)?; | |
| *pos += 1; | |
| Some(hash_node(&left, &right)) | |
| } | |
| errors | |
| } | |
| } | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct NetworkConfig { | |
| pub mode: NetworkMode, | |
| /// Allowed domains (only for Gated mode). | |
| pub allowed_domains: Vec<String>, | |
| /// Data residency configuration (§3.3). If None, no geographic enforcement. | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub data_residency: Option<DataResidencyConfig>, | |
| /// DLP content inspection rules file path (§3.3). If None, no content inspection. | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub dlp_rules_path: Option<String>, | |
| } | |
| /// Data residency configuration for geographic enforcement (§3.3). | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct DataResidencyConfig { | |
| /// Allowed geographic regions (ISO 3166-1 alpha-2 codes or aliases: "EU", "EEA", "US", "APAC"). | |
| pub allowed_regions: Vec<String>, | |
| /// Enforcement mode. | |
| #[serde(default)] | |
| pub geo_enforcement: GeoEnforcement, | |
| /// Verify that DNS-resolved IPs match the claimed geographic region. | |
| #[serde(default)] | |
| pub dns_verification: bool, | |
| /// Path to MaxMind GeoLite2-Country database (.mmdb). | |
| #[serde(default = "default_geo_database")] | |
| pub geo_database: String, | |
| /// Domain exceptions (allowed regardless of region). | |
| #[serde(default)] | |
| pub exceptions: Vec<GeoException>, | |
| } | |
| fn default_geo_database() -> String { | |
| "/usr/share/GeoIP/GeoLite2-Country.mmdb".to_string() | |
| } | |
| /// Geographic enforcement mode. | |
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| #[serde(rename_all = "lowercase")] | |
| pub enum GeoEnforcement { | |
| #[default] | |
| Strict, | |
| Permissive, | |
| } | |
| /// Domain exception for data residency rules. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct GeoException { | |
| pub domain: String, | |
| pub reason: String, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub approved_by: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub expires: Option<String>, | |
| } | |
| /// Network access mode for an agent sandbox. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] | |
| pub enum NetworkMode { | |
| /// No network access (isolated network namespace with no interfaces). | |
| Blocked, | |
| /// Network access gated through puzzled proxy with domain allowlist. | |
| Gated, | |
| /// Full network access with logging. | |
| Monitored, | |
| /// Unrestricted network access. | |
| Unrestricted, | |
| } | |
| /// Verify a Merkle inclusion proof against an expected root hash. | |
| /// | |
| /// Returns `Ok(true)` if the proof is valid, `Ok(false)` if the computed root | |
| /// doesn't match, or `Err` if the proof is malformed. | |
| pub fn verify_merkle_inclusion( | |
| leaf_hash: &[u8; 32], | |
| proof: &super::InclusionProof, | |
| expected_root: &[u8; 32], | |
| ) -> Result<bool, String> { | |
| let proof_hashes: Vec<[u8; 32]> = proof | |
| .proof_hashes | |
| .iter() | |
| .map(|h| { | |
| let bytes = hex_decode(h)?; | |
| if bytes.len() != 32 { | |
| return Err(format!("proof hash must be 32 bytes, got {}", bytes.len())); | |
| } | |
| let mut arr = [0u8; 32]; | |
| arr.copy_from_slice(&bytes); | |
| Ok(arr) | |
| }) | |
| .collect::<Result<Vec<_>, _>>()?; | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct BehavioralConfig { | |
| /// Maximum number of files deleted in a single branch session. | |
| pub max_deletions: u32, | |
| /// Maximum number of files read per minute. | |
| pub max_reads_per_minute: u32, | |
| /// Trigger on access to credential-like paths. | |
| pub credential_access_alert: bool, | |
| /// §3.4 G28: Phantom token prefixes to detect in file writes. | |
| /// When a file write contains any of these prefixes, a | |
| /// `PhantomTokenLeakage` behavioral trigger is fired. | |
| #[serde(default)] | |
| pub phantom_token_prefixes: Vec<String>, | |
| } | |
| let mut pos = 0; | |
| let computed = compute_root_from_inclusion( | |
| proof.leaf_index, | |
| proof.tree_size, | |
| leaf_hash, | |
| &proof_hashes, | |
| &mut pos, | |
| ) | |
| .ok_or_else(|| "malformed inclusion proof: insufficient proof hashes".to_string())?; | |
| impl Default for BehavioralConfig { | |
| fn default() -> Self { | |
| Self { | |
| max_deletions: 50, | |
| max_reads_per_minute: 1000, | |
| credential_access_alert: true, | |
| phantom_token_prefixes: Vec::new(), | |
| // RFC 6962: all proof hashes must be consumed | |
| if pos != proof_hashes.len() { | |
| return Err(format!( | |
| "malformed inclusion proof: {} extra hashes", | |
| proof_hashes.len() - pos | |
| )); | |
| } | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Fail modes (safety-critical deployments) | |
| // --------------------------------------------------------------------------- | |
| /// Fail mode for safety-critical deployments (IEC 61508 / ISO 26262). | |
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | |
| pub enum FailMode { | |
| /// Default: reject commit, rollback branch. | |
| #[default] | |
| FailClosed, | |
| /// Hold last safe state (do not commit, do not rollback). | |
| FailSilent, | |
| /// Continue with reduced capability (apply subset of changes). | |
| FailOperational, | |
| /// Controlled stop / return to base. | |
| FailSafeState, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Conflict detection | |
| // --------------------------------------------------------------------------- | |
| /// A conflict between concurrent branches. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct Conflict { | |
| /// The conflicting file path. | |
| pub path: PathBuf, | |
| /// Branch IDs that modified this path. | |
| pub conflicting_branches: Vec<BranchId>, | |
| /// Type of conflict. | |
| pub kind: ConflictKind, | |
| } | |
| /// Type of cross-branch conflict. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] | |
| pub enum ConflictKind { | |
| /// Both branches modified the same file. | |
| BothModified, | |
| /// One branch modified, another deleted. | |
| ModifiedAndDeleted, | |
| /// Both branches created the same new file. | |
| BothCreated, | |
| } | |
| /// Strategy for resolving cross-branch conflicts. | |
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | |
| pub enum ConflictResolution { | |
| /// Default: reject the commit. | |
| #[default] | |
| Reject, | |
| /// Last writer wins (overwrite silently). | |
| LastWriterWins, | |
| /// Three-way merge for text files, reject for binary. | |
| MergeIfText, | |
| /// Non-overlapping path prefixes per branch. | |
| ScopePartition, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Budget / adaptive escalation | |
| // --------------------------------------------------------------------------- | |
| /// Budget tier for adaptive resource allocation. | |
| #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] | |
| pub enum BudgetTier { | |
| /// Minimal resources, strict limits. | |
| #[default] | |
| Restricted, | |
| /// Standard allocation after proven clean commits. | |
| Standard, | |
| /// Extended allocation for established agents. | |
| Extended, | |
| } | |
| /// Budget status for a branch. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct BudgetStatus { | |
| pub branch_id: BranchId, | |
| pub tier: BudgetTier, | |
| pub clean_commits: u32, | |
| pub violations: u32, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Behavioral triggers (fanotify) | |
| // --------------------------------------------------------------------------- | |
| /// A behavioral trigger fired by the fanotify monitor. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub enum BehavioralTrigger { | |
| MassDeletion { | |
| count: u32, | |
| threshold: u32, | |
| }, | |
| ExcessiveReads { | |
| rate: u32, | |
| threshold: u32, | |
| }, | |
| CredentialAccess { | |
| path: String, | |
| }, | |
| /// Fanotify event queue overflowed — incremental tracking is incomplete. | |
| /// The diff engine must fall back to a full upper-dir walk for this branch. | |
| QueueOverflow, | |
| /// §3.4 G28: Phantom token detected in file write — potential credential leak. | |
| /// Fired when fanotify detects a write containing `pt_puzzled_*` patterns. | |
| PhantomTokenLeakage { | |
| /// File path where the phantom token was written. | |
| file_path: String, | |
| /// The phantom token prefix detected (first 16 chars max). | |
| token_prefix: String, | |
| }, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Audit (persistent storage) | |
| // --------------------------------------------------------------------------- | |
| /// Filter for querying audit events. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct AuditFilter { | |
| /// Filter by branch ID. | |
| pub branch_id: Option<String>, | |
| /// Filter by event type. | |
| pub event_type: Option<String>, | |
| /// Filter events since this timestamp (RFC 3339). | |
| pub since: Option<String>, | |
| /// Maximum number of events to return. | |
| pub limit: Option<u32>, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Attestation (§3.1 — Cryptographic Attestation of Governance) | |
| // --------------------------------------------------------------------------- | |
| /// Identity of the agent that produced a governance event. | |
| /// | |
| /// Included in attestation records for third-party verifiability. | |
| /// Contains only metadata (UID, profile, SELinux context) — no PII. | |
| #[derive(Debug, Clone, Serialize, Deserialize)] | |
| pub struct AgentIdentity { | |
| /// POSIX UID of the agent process. | |
| pub uid: u32, | |
| /// Agent profile name (e.g., "restricted", "standard"). | |
| pub profile: String, | |
| /// SELinux context if available (e.g., "puzzlepod_t:s0:c42,c99"). | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub selinux_context: Option<String>, | |
| /// Agent framework if reported by SDK (e.g., "langchain", "crewai"). | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| pub framework: Option<String>, | |
| } | |
| /// Governance decision recorded in an attestation record. | |
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| #[serde(rename_all = "snake_case")] | |
| pub enum GovernanceDecision { | |
| Approved, | |
| Rejected, | |
| Rollback, | |
| Violation, | |
| Escape, | |
| Killed, | |
| Created, | |
| } | |
| impl std::fmt::Display for GovernanceDecision { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| match self { | |
| Self::Approved => write!(f, "approved"), | |
| Self::Rejected => write!(f, "rejected"), | |
| Self::Rollback => write!(f, "rollback"), | |
| Self::Violation => write!(f, "violation"), | |
| Self::Escape => write!(f, "escape"), | |
| Self::Killed => write!(f, "killed"), | |
| Self::Created => write!(f, "created"), | |
| } | |
| Ok(computed == *expected_root) | |
| } | |
| pub fn compute_root_from_inclusion( | |
| leaf_index: u64, | |
| tree_size: u64, | |
| leaf_hash: &[u8; 32], | |
| proof: &[[u8; 32]], | |
| pos: &mut usize, | |
| ) -> Option<[u8; 32]> { | |
| if tree_size <= 1 { | |
| return Some(*leaf_hash); | |
| } | |
| let k = largest_power_of_2_less_than(tree_size); | |
| if leaf_index < k { | |
| let left = compute_root_from_inclusion(leaf_index, k, leaf_hash, proof, pos)?; | |
| let right = *proof.get(*pos)?; | |
| *pos += 1; | |
| Some(hash_node(&left, &right)) | |
| } else { | |
| let right = | |
| compute_root_from_inclusion(leaf_index - k, tree_size - k, leaf_hash, proof, pos)?; | |
| let left = *proof.get(*pos)?; | |
| *pos += 1; | |
| Some(hash_node(&left, &right)) | |
| } | |
| } | |
| /// Verify a Merkle inclusion proof against an expected root hash. | |
| /// | |
| /// Returns `Ok(true)` if the proof is valid, `Ok(false)` if the computed root | |
| /// doesn't match, or `Err` if the proof is malformed. | |
| pub fn verify_merkle_inclusion( | |
| leaf_hash: &[u8; 32], | |
| proof: &super::InclusionProof, | |
| expected_root: &[u8; 32], | |
| ) -> Result<bool, String> { | |
| if proof.tree_size == 0 { | |
| return Err("malformed inclusion proof: tree_size must be > 0".to_string()); | |
| } | |
| if proof.leaf_index >= proof.tree_size { | |
| return Err(format!( | |
| "malformed inclusion proof: leaf_index {} out of range for tree_size {}", | |
| proof.leaf_index, proof.tree_size | |
| )); | |
| } | |
| let proof_hashes: Vec<[u8; 32]> = proof | |
| .proof_hashes | |
| .iter() | |
| .map(|h| { | |
| let bytes = hex_decode(h)?; | |
| if bytes.len() != 32 { | |
| return Err(format!("proof hash must be 32 bytes, got {}", bytes.len())); | |
| } | |
| let mut arr = [0u8; 32]; | |
| arr.copy_from_slice(&bytes); | |
| Ok(arr) | |
| }) | |
| .collect::<Result<Vec<_>, _>>()?; | |
| let mut pos = 0; | |
| let computed = compute_root_from_inclusion( | |
| proof.leaf_index, | |
| proof.tree_size, | |
| leaf_hash, | |
| &proof_hashes, | |
| &mut pos, | |
| ) | |
| .ok_or_else(|| "malformed inclusion proof: insufficient proof hashes".to_string())?; | |
| // RFC 6962: all proof hashes must be consumed | |
| if pos != proof_hashes.len() { | |
| return Err(format!( | |
| "malformed inclusion proof: {} extra hashes", | |
| proof_hashes.len() - pos | |
| )); | |
| } | |
| Ok(computed == *expected_root) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled-types/src/lib.rs` around lines 116 - 183,
verify_merkle_inclusion currently allows impossible proofs because it never
validates proof.leaf_index and proof.tree_size; add an upfront check in
verify_merkle_inclusion that proof.tree_size > 0 and proof.leaf_index <
proof.tree_size and return Err with a clear message (e.g. "invalid inclusion
proof: tree_size must be > 0" or "invalid inclusion proof: leaf_index out of
range") before calling compute_root_from_inclusion; reference the InclusionProof
struct's leaf_index and tree_size fields and ensure this validation prevents
cases where compute_root_from_inclusion would incorrectly accept out-of-range
indexes.
| /// M2: Maximum exec calls allowed per branch lifetime (default: max_pids * 10). | ||
| #[serde(default)] | ||
| pub max_exec_calls: Option<u32>, | ||
| /// M2: Maximum open file descriptors (default 1024, range 64-65536). | ||
| #[serde(default)] | ||
| pub max_open_fds: Option<u32>, | ||
| /// M2: Maximum files deleted per branch lifetime. | ||
| #[serde(default)] | ||
| pub max_files_deleted: Option<u64>, | ||
| /// M2: Maximum total write volume in MB. | ||
| #[serde(default)] | ||
| pub max_total_write_mb: Option<u32>, | ||
| /// M4: Branch lifetime in minutes (default 60, range 1-1440). | ||
| #[serde(default)] | ||
| pub lifetime_minutes: Option<u32>, |
There was a problem hiding this comment.
validate() misses ranges that this struct already documents.
max_open_fds is documented as 64-65536 and lifetime_minutes as 1-1440, but both currently pass validate() unchanged. That lets obviously invalid profiles deserialize and look valid to callers.
Suggested fix
if self.storage_quota_mb > Self::MAX_STORAGE_QUOTA_MB {
errors.push(format!(
"J69: storage_quota_mb {} exceeds maximum {} (1 PiB)",
self.storage_quota_mb,
Self::MAX_STORAGE_QUOTA_MB
));
}
+ if let Some(max_open_fds) = self.max_open_fds {
+ if !(64..=65_536).contains(&max_open_fds) {
+ errors.push(format!(
+ "max_open_fds must be 64-65536, got {}",
+ max_open_fds
+ ));
+ }
+ }
+ if let Some(lifetime_minutes) = self.lifetime_minutes {
+ if !(1..=1_440).contains(&lifetime_minutes) {
+ errors.push(format!(
+ "lifetime_minutes must be 1-1440, got {}",
+ lifetime_minutes
+ ));
+ }
+ }
errors
}
}Also applies to: 218-259
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled-types/src/profile.rs` around lines 160 - 174, The
Profile::validate() method currently omits enforcing documented numeric ranges
for several optional fields; add explicit checks in validate() for max_open_fds
(when Some, ensure 64 <= value <= 65536) and lifetime_minutes (when Some, ensure
1 <= value <= 1440), returning the same ValidationError/Err path used elsewhere
in validate() with a clear message; also scan the other validate blocks around
the later diff (lines ~218-259) and apply the same pattern for any other
documented field ranges so the struct's documentation and validation stay in
sync.
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
crates/puzzlectl/src/sim/repl.rs (2)
206-225:⚠️ Potential issue | 🟠 MajorRollback active branch before
reset_with_modeinset mode.If
set mode ...runs afterstart, Line 212/Line 221 clears branch state without rollback, which can orphan an active branch/worker.🛠️ Proposed fix
cmd if cmd.starts_with("set mode ") => { let mode_str = cmd.strip_prefix("set mode ").unwrap().trim(); + if engine.is_started() { + eprintln!("[orchestrator] Rejecting current branch first..."); + engine.reject(client).await?; + } match mode_str { "direct" => { // V50: Preserve loaded scenario when switching modes let current_scenario = engine.scenario().cloned(); engine.reset_with_mode(SimMode::Direct);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/repl.rs` around lines 206 - 225, When switching modes in the "set mode" handler, the code calls engine.reset_with_mode(...) after capturing engine.scenario() and before engine.load(...), which can clear branch/worker state and orphan an active branch if the simulator was started; before calling engine.reset_with_mode(SimMode::Direct) or engine.reset_with_mode(SimMode::Sandbox) invoke the appropriate rollback on the engine (e.g., engine.rollback_active_branch() / engine.rollback_active_worker() or the existing API that cleanly terminates the active branch) to ensure any active branch/worker is rolled back/stopped, then proceed to reset_with_mode(...) and re-load the preserved scenario via engine.load(...).
341-350:⚠️ Potential issue | 🟡 Minor
executemode override is not restored.Line 341 stores
saved_mode, but after a direct-modeexecutesuccess, the engine remains in sandbox mode.🛠️ Proposed fix
let result = engine.approve(client).await?; match result.deny_reason { Some(reason) => { println!("[puzzled] DENIED: {}", reason); println!("Branch rolled back."); } None => { println!("[puzzled] APPROVED: changes committed"); } } + + if saved_mode == SimMode::Direct { + let current_scenario = engine.scenario().cloned(); + engine.reset_with_mode(SimMode::Direct); + if let Some(scenario) = current_scenario { + engine.load(scenario); + } + } }Also applies to: 363-377
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/repl.rs` around lines 341 - 350, The code saves saved_mode but never restores it, leaving the engine stuck in Sandbox after execute; update the execute flow so that after the temporary sandboxing (the branch that checks engine.mode() == SimMode::Direct where you call engine.reset_with_mode(SimMode::Sandbox) and possibly engine.load(current_scenario)), you restore the original mode by calling engine.reset_with_mode(saved_mode) and reloading the scenario if needed (e.g., reuse current_scenario) once the execute completes or on error; apply the same restore logic to the similar block around the 363-377 region so both temporary mode switches return the engine to saved_mode.
♻️ Duplicate comments (9)
crates/puzzled-types/src/lib.rs (1)
145-182:⚠️ Potential issue | 🟠 MajorReject impossible Merkle proofs up front.
compute_root_from_inclusion()returnsleaf_hashfortree_size <= 1, so a proof withtree_size == 1andleaf_index > 0can still verify ifexpected_root == leaf_hash. Validatetree_size > 0andleaf_index < tree_sizebefore recomputing the root.Suggested fix
pub fn verify_merkle_inclusion( leaf_hash: &[u8; 32], proof: &super::InclusionProof, expected_root: &[u8; 32], ) -> Result<bool, String> { + if proof.tree_size == 0 { + return Err("malformed inclusion proof: tree_size must be > 0".to_string()); + } + if proof.leaf_index >= proof.tree_size { + return Err(format!( + "malformed inclusion proof: leaf_index {} out of range for tree_size {}", + proof.leaf_index, proof.tree_size + )); + } + let proof_hashes: Vec<[u8; 32]> = proof .proof_hashes .iter()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/lib.rs` around lines 145 - 182, The verify_merkle_inclusion function must validate the proof bounds before calling compute_root_from_inclusion: check that proof.tree_size > 0 and proof.leaf_index < proof.tree_size and return an Err with a clear message if these checks fail (e.g., "malformed inclusion proof: invalid tree_size" or "malformed inclusion proof: leaf_index out of range") to reject impossible proofs up front; perform these validations using the proof.tree_size and proof.leaf_index fields immediately before building proof_hashes or before calling compute_root_from_inclusion so you never accept a proof with tree_size == 0 or leaf_index >= tree_size.crates/puzzlectl/src/commands/policy.rs (1)
64-75:⚠️ Potential issue | 🟠 MajorOffline policy tests still omit daemon-visible
FileChangefields.The Rego input excludes
old_mode,entropy, andhas_base64_blocks, sopuzzlectl policy testcan approve a changeset that the daemon rejects once rules inspect mode transitions or content-analysis fields.Suggested fix
let input = serde_json::json!({ "changes": changes.iter().map(|c| { serde_json::json!({ "path": c.path.to_string_lossy(), "kind": format!("{:?}", c.kind), "size": c.size, "checksum": &c.checksum, "target": c.target.as_deref().unwrap_or(""), + "old_mode": c.old_mode, "new_mode": c.new_mode, + "entropy": c.entropy, + "has_base64_blocks": c.has_base64_blocks, }) }).collect::<Vec<_>>() });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/policy.rs` around lines 64 - 75, The Rego input construction (variable input) omits daemon-visible FileChange fields causing mismatches; update the JSON produced in the mapping over changes (the closure referencing c) to include the missing keys "old_mode", "entropy", and "has_base64_blocks" using the corresponding values from each FileChange (e.g., c.old_mode, c.entropy, c.has_base64_blocks or their accessor methods), so the input matches what the daemon sees when running puzzlectl policy test.crates/puzzlectl/src/commands/profile.rs (1)
378-412:⚠️ Potential issue | 🟠 Major
profile teststill ignoresread_denylistandwrite_denylist.Daemon-side enforcement consults those lists, but this loop only checks
denylistandwrite_allowlist. A changeset can pass here and still be rejected at runtime.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 378 - 412, The loop currently only enforces profile.filesystem.denylist and profile.filesystem.write_allowlist; extend this to also consult profile.filesystem.read_denylist for read-type accesses and profile.filesystem.write_denylist for write-type accesses so the local `profile test` behavior matches daemon enforcement. Concretely: after computing path (Path::new(path_str.as_ref())) and before allowing the change, add checks that use Path::starts_with against each entry in profile.filesystem.read_denylist when the change.kind corresponds to reads (and similarly check profile.filesystem.write_denylist for write kinds like Added|Modified), and when a match is found set blocked = true and set reason to a descriptive message (e.g., "matches read_denylist pattern '...'" or "matches write_denylist pattern '...'"); keep using the existing puzzled_types::FileChangeKind and change.kind to decide which denylist to consult.crates/puzzled-types/src/profile.rs (1)
219-261:⚠️ Potential issue | 🟡 Minor
validate()omits documented range checks formax_open_fdsandlifetime_minutes.The comments at lines 164-165 document
max_open_fdsas range 64-65536 and line 173 documentslifetime_minutesas range 1-1440, butvalidate()doesn't enforce these constraints. Invalid values will pass validation and reach sandbox setup code.🔧 Proposed fix
if self.storage_quota_mb > Self::MAX_STORAGE_QUOTA_MB { errors.push(format!( "J69: storage_quota_mb {} exceeds maximum {} (1 PiB)", self.storage_quota_mb, Self::MAX_STORAGE_QUOTA_MB )); } + if let Some(max_open_fds) = self.max_open_fds { + if !(64..=65_536).contains(&max_open_fds) { + errors.push(format!( + "max_open_fds must be 64-65536, got {}", + max_open_fds + )); + } + } + if let Some(lifetime_minutes) = self.lifetime_minutes { + if !(1..=1_440).contains(&lifetime_minutes) { + errors.push(format!( + "lifetime_minutes must be 1-1440, got {}", + lifetime_minutes + )); + } + } errors }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/profile.rs` around lines 219 - 261, The validate() method in impl for Profile is missing checks for max_open_fds and lifetime_minutes; add range validations analogous to the other fields: enforce max_open_fds between 64 and 65536 and lifetime_minutes between 1 and 1440, and push descriptive error strings when out of range (use the same style as existing messages, e.g., "max_open_fds must be 64-65536, got {}" and "lifetime_minutes must be 1-1440, got {}") inside the validate() function so invalid values are rejected before sandbox setup.crates/puzzled/src/branch/activate.rs (3)
190-225:⚠️ Potential issue | 🟠 MajorQuarantine channel captures
Nonecgroup_path because sandbox isn't registered yet.At line 195,
self.sandboxes.get(id)returnsNonebecause the sandbox handle isn't inserted until line 454. The spawned task will always seecgroup_path = None, causing quarantine freezing to silently fail.Use
handle.cgroup_path.clone()directly instead of fetching from the map.🔧 Proposed fix
quarantine_sender: { let (tx, mut rx) = tokio::sync::mpsc::channel::<puzzled_types::BranchId>(16); - let cgroup_path = self.sandboxes.get(id).map(|h| h.cgroup_path.clone()); + let cgroup_path = Some(handle.cgroup_path.clone()); if let Ok(rt_handle) = tokio::runtime::Handle::try_current() { rt_handle.spawn(async move {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 190 - 225, The quarantine task currently captures cgroup_path by calling self.sandboxes.get(id) (so cgroup_path is None because the sandbox is inserted later); instead, capture the sandbox handle's cgroup_path at activation time and move that into the spawned task: when building quarantine_sender, clone handle.cgroup_path (the handle you're inserting for this branch) into a local variable and move that into the async closure used in rt_handle.spawn, replacing the self.sandboxes.get(id) lookup so the task sees the real cgroup_path rather than None; update references in the closure that use cgroup_path to use this captured clone.
131-135:⚠️ Potential issue | 🔴 CriticalCredential provisioning occurs after sandbox is unblocked, leaving a live sandbox on failure.
send_seccomp_ack()at line 135 unblocks the child process, and the branch state is set toActiveat line 421. However,issue_for_branch()at lines 477-484 runs after these operations. If credential provisioning fails, the function returnsErrbut the sandbox is already running and marked Active.Either provision credentials before calling
send_seccomp_ack(), or implement rollback cleanup (kill the sandbox, revert state) in the error path.🔧 Sketch of fix: move credential provisioning earlier
let mut handle = builder.build(&branch_id_str)?; + // §3.4 Gap 7: Issue phantom tokens BEFORE unblocking child + // to ensure credentials are available when sandbox starts + if let Some(ref cred_config) = profile.credentials { + if cred_config.is_phantom_enabled() { + if let Some(ref ptm) = self.phantom_token_manager { + // ... issue_for_branch logic here ... + // Return Err on failure BEFORE send_seccomp_ack + } + } + } + // Register seccomp notify fd with the handler if let Some(notify_fd) = handle.seccomp_notify_fd { // ... } // SH1: Send seccomp ACK to child AFTER credentials provisioned crate::sandbox::send_seccomp_ack(&mut handle)?;Also applies to: 460-504
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 131 - 135, The sandbox is unblocked by crate::sandbox::send_seccomp_ack before credentials are provisioned by issue_for_branch, so a provisioning failure leaves an Active sandbox; either move the credential provisioning call (issue_for_branch) to occur before send_seccomp_ack so the child is only unblocked after credentials are applied, or add a rollback path immediately after send_seccomp_ack/error from issue_for_branch that cleanly kills the sandbox and reverts the branch state from Active (ensure you call the same teardown used for normal shutdown). Reference send_seccomp_ack and issue_for_branch when making the change and update the error handling path to always perform sandbox cleanup and state revert on provisioning failure.
83-127:⚠️ Potential issue | 🟠 MajorInvalid
proxy_listen_addrreturnsNoneinstead of failing branch activation.When
proxy_listen_addr.parse()fails (line 97-105), the closure returnsNone, causinghandler.register()to proceed without aCredentialProxyContext. This silently disables the credential-proxy bypass defense rather than failing closed. Per defense-in-depth principles, an invalid configuration should abort activation.🔧 Proposed fix
Move the parse outside the closure and return
Erron failure:+ // M-9: Fail-closed — invalid proxy_listen_addr must abort activation + let gateway_ip: std::net::IpAddr = self + .config + .network + .proxy_listen_addr + .parse() + .map_err(|e| PuzzledError::Config(format!( + "M-9: credential proxy requires valid proxy_listen_addr: {}", e + )))?; + let credential_proxy = profile.credentials.as_ref().and_then(|cred_config| { if !cred_config.proxy.enabled { return None; } - let gateway_ip = match self - .config - .network - .proxy_listen_addr - .parse::<std::net::IpAddr>() - { - Ok(ip) => ip, - Err(e) => { - tracing::error!(...); - return None; - } - }; // ... rest of credential_proxy construction + Some(crate::seccomp_handler::CredentialProxyContext { + enabled: true, + proxy_gateway_ip: gateway_ip, + // ... + }) });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 83 - 127, The closure building credential_proxy currently swallows parse errors for self.config.network.proxy_listen_addr and returns None, which lets handler.register proceed without a CredentialProxyContext; instead, parse proxy_listen_addr before constructing credential_proxy (outside the profile.credentials closure), and if parsing fails while the credential proxy is enabled return Err to abort branch activation. Specifically, move the parse of self.config.network.proxy_listen_addr out of the closure that builds credential_proxy (referencing self.config.network.proxy_listen_addr and profile.credentials), and on parse failure return an Err from the surrounding activate flow (not None); then construct the CredentialProxyContext (crate::seccomp_handler::CredentialProxyContext) with the parsed gateway_ip and continue to call handler.register only when credential_proxy is valid.crates/puzzlectl/src/commands/attestation.rs (1)
677-689:⚠️ Potential issue | 🟡 MinorTimestamp comparison uses string ordering instead of RFC3339-aware parsing.
At line 680,
ts < prev_ts.as_str()uses lexicographic string comparison, which fails for equivalent RFC3339 timestamps like2024-01-01T00:00:00Zvs2024-01-01T00:00:00+00:00(since'Z' > '+'lexicographically). Useparse_rfc3339_approx()fromcompliance.rsorchrono::DateTime::parse_from_rfc3339for proper temporal comparison.🔧 Proposed fix using chrono
+use chrono::DateTime; + // 4. §3.1.8: Verify timestamp ordering (timestamp >= parent's timestamp) if let Some(ts) = record["timestamp"].as_str() { if let Some(ref prev_ts) = prev_timestamp { - if ts < prev_ts.as_str() { + let ts_parsed = DateTime::parse_from_rfc3339(ts); + let prev_parsed = DateTime::parse_from_rfc3339(prev_ts); + let is_regression = match (ts_parsed, prev_parsed) { + (Ok(t), Ok(p)) => t < p, + _ => ts < prev_ts.as_str(), // Fallback to string comparison + }; + if is_regression { timestamp_violations += 1; eprintln!( " FAIL: timestamp regression — record {} has {} < parent {}", rid, ts, prev_ts ); } } prev_timestamp = Some(ts.to_string()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/attestation.rs` around lines 677 - 689, The current timestamp check compares record["timestamp"] strings lexicographically (ts < prev_ts.as_str()), which is incorrect for RFC3339 variants; change it to parse both ts and prev_timestamp into a DateTime and compare chronologically (use parse_rfc3339_approx() from compliance.rs or chrono::DateTime::parse_from_rfc3339), update prev_timestamp to store the parsed DateTime (or keep the original string but also store the last parsed DateTime), and on parse failures increment timestamp_violations or log an error consistently; adjust the branch around record["timestamp"], prev_timestamp, and timestamp_violations to use the parsed DateTime comparison and handle parse errors gracefully.crates/puzzlectl/src/cli.rs (1)
405-424:⚠️ Potential issue | 🟠 Major
conflicts_with = "audit_dir"will always trigger due toaudit_dir'sdefault_value.In clap v4, arguments with
default_valueare considered "used" during validation. Sinceaudit_dir(line 407-408) hasdefault_value, theconflicts_with = "audit_dir"onbundle(line 422) will error even whenaudit_dirisn't explicitly provided.Change
audit_dirtoOption<PathBuf>withoutdefault_value, then apply the default in the handler code.🔧 Proposed fix
In cli.rs:
Verify { /// Path to the audit store directory (used when --bundle is not provided) - #[arg(long, default_value = "/var/lib/puzzled/branches/audit")] - audit_dir: PathBuf, + #[arg(long)] + audit_dir: Option<PathBuf>,In the handler:
- let audit_path = &args.audit_dir; + let audit_path = args.audit_dir + .unwrap_or_else(|| PathBuf::from("/var/lib/puzzled/branches/audit"));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/cli.rs` around lines 405 - 424, The Verify variant's audit_dir uses default_value so clap always treats it as present, causing conflicts_with on bundle to always trigger; change the Verify field audit_dir from a PathBuf with default_value to Option<PathBuf> (remove default_value) and keep the arg attribute, then in the command handler where Verify is matched (the Verify branch/handler logic) apply the default path "/var/lib/puzzled/branches/audit" when audit_dir is None before using it; keep bundle as Option<PathBuf> and leave conflicts_with = "audit_dir" so explicit bundle vs audit selection works as intended.
🧹 Nitpick comments (14)
crates/puzzlectl/src/sim/engine.rs (1)
494-502: Avoid reset-state drift betweenresetandreset_with_mode.This duplicates reset fields already handled by
reset(). Centralizing shared reset state reduces future divergence risk.♻️ Proposed refactor
pub fn reset_with_mode(&mut self, mode: SimMode) { - self.scenario = None; - self.profile_override = None; - self.cursor = 0; - self.branch_id = None; - self.merge_path = None; - self.history.clear(); + self.reset(); + self.scenario = None; + self.profile_override = None; self.mode = mode; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/sim/engine.rs` around lines 494 - 502, reset_with_mode duplicates the same field resets as reset, risking drift; modify reset_with_mode to delegate to the existing reset() implementation (or extract the shared reset logic into a private helper like do_reset() used by both) and then only set self.mode = mode, ensuring reset() and reset_with_mode remain consistent; update functions reset_with_mode and reset (or create do_reset) so shared fields (scenario, profile_override, cursor, branch_id, merge_path, history) are only cleared in one place.crates/puzzled-types/src/policy.rs (1)
91-101: Consider usingBranchIdtype for consistency.
AuditFilter.branch_idisOption<String>while the crate defines aBranchIdnewtype. UsingOption<BranchId>would provide type safety and consistency with other filter types in the codebase.Similarly,
sincecould use a structured timestamp type rather than rawStringto enforce RFC 3339 format at the type level.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/policy.rs` around lines 91 - 101, AuditFilter currently uses raw Option<String> for branch_id and since; replace branch_id: Option<String> with branch_id: Option<BranchId> (the crate newtype) and change since to a structured timestamp type (e.g., chrono::DateTime<FixedOffset> or DateTime<Utc>) to enforce RFC3339 parsing; update AuditFilter's serde attributes/imports as needed so serialization/deserialization remains compatible (or add serde with/from string helpers), adjust any usages/tests that construct AuditFilter or parse its fields, and ensure BranchId is imported where AuditFilter is defined.crates/puzzle-proxy/src/handler/connect.rs (1)
50-117: Spawned tunnel task is detached — tunnels outlive connection context.The
tokio::spawnat Line 50 creates a detached task that continues running even if the parent connection handler completes. While this is likely intentional for long-lived HTTPS tunnels, be aware that:
- Active tunnels continue until
REQUEST_TIMEOUTor byte limits, regardless of server shutdown- The
branch_idcontext is moved into the task, so logging continues to workIf graceful shutdown with tunnel draining is needed later, consider tracking the
JoinHandle.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/connect.rs` around lines 50 - 117, The spawned tunnel task created with tokio::spawn currently detaches and can outlive the connection handler; capture the returned tokio::task::JoinHandle from the tokio::spawn call (instead of discarding it) and register or return that handle so the server can await or abort tunnels during graceful shutdown or drain; specifically, update the code around tokio::spawn that encloses the REQUEST_TIMEOUT/MAX_TUNNEL_BYTES tunnel logic to assign the spawn result to a JoinHandle, and add it to your process-wide task registry or shutdown controller (or return it to the caller) so tunnels can be awaited/aborted on shutdown while preserving existing logging context like target_with_port/branch_id.crates/puzzle-proxy/src/handler/journal.rs (1)
232-243: Journal mutex held across await point — potential contention.The
journal.lock().awaitholds the lock whilejournal.append(entry).awaitexecutes. If append is slow (disk I/O), other requests to this branch will queue. This may be intentional for ordering guarantees.Consider whether the append could be moved to a dedicated writer task to reduce lock contention, though this adds complexity and may not be necessary if append is fast.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/journal.rs` around lines 232 - 243, The current code holds the mutex across the await by calling journal.lock().await and then awaiting journal.append(entry).await, which can cause contention; change the flow so the lock is not held during the async append: acquire the lock only to extract/clone the minimal state needed (or to enqueue the entry) and then release it before calling append. Concretely, in the handler that uses journal.lock().await and journal.append(entry).await, either 1) move the append into a dedicated writer task and replace the in-place call with sending the entry (e.g., via an mpsc channel) while the handler only holds the lock briefly to enqueue, or 2) copy/clone the entry and any small metadata under the lock then drop the guard and call journal.append(...) outside the locked section; preserve error handling and logging (branch_id and error reporting) so failures still return the same INTERNAL_SERVER_ERROR response.crates/puzzled-types/src/credential.rs (2)
22-47: credential_mappings() only extracts the first Env exposure per credential.The
find_mapon line 29-36 takes only the firstEnvvariant, ignoring subsequent ones. If a credential has multiple Env exposures, only the first is included in phantom token mapping. This may be intentional (one phantom token per credential) but should be documented.Consider adding a doc comment clarifying this behavior:
/// Derive (domain, credential_name, env_var, required) tuples from secrets for - /// phantom token issuance. Each credential's first Env exposure is used. + /// phantom token issuance. Only the first Env exposure per credential is used; + /// subsequent Env exposures are ignored for phantom token mapping. /// M-4: Includes the `required` field from `CredentialSpec`.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/credential.rs` around lines 22 - 47, The credential_mappings method on CredentialConfig currently uses the find_map pattern over each spec.expose to pick only the first CredentialExposure::Env var and ignores any subsequent Env exposures; update the doc comment for credential_mappings to explicitly state that only the first Env exposure per CredentialSpec is used (i.e., one env var per credential is emitted for each domain) so callers understand the intended behavior, and mention the relevant symbols (CredentialConfig::credential_mappings, CredentialExposure::Env, and the spec.expose find_map usage); if the desired behavior is to include all Env exposures instead, replace the find_map-based extraction with iterating all Env variants and emit a mapping per env var (but at minimum add the clarifying doc comment).
113-124: Default functions usepub(crate)inconsistently.
default_true_valispub(crate)but other default functions are private. Ifdefault_true_valis needed outside this module within the crate, this is fine, but the inconsistency is notable.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/credential.rs` around lines 113 - 124, default_true_val is declared pub(crate) while default_ttl, default_swap_headers, and default_max_credential_size are private, causing inconsistent visibility; either make default_true_val private (remove pub(crate)) to match the others or mark the other functions pub(crate) if they are required elsewhere in the crate; locate the functions default_true_val, default_ttl, default_swap_headers, and default_max_credential_size in credential.rs and adjust their visibility accordingly, then run cargo build/tests to ensure no visibility errors remain.crates/puzzle-proxy/src/handler/tls.rs (1)
188-213: Upstream TLS connection uses webpki-roots correctly.The use of
webpki_roots::TLS_SERVER_ROOTSprovides Mozilla's root CA bundle. However, the comment mentions "system root CA store" which is inaccurate — this uses the bundled webpki-roots, not the system store.📝 Fix misleading comment
-/// Connect to an upstream server with TLS, using the system root CA store. +/// Connect to an upstream server with TLS, using the bundled Mozilla root CA store (webpki-roots).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/tls.rs` around lines 188 - 213, The doc comment for connect_upstream_tls is misleading — it says "using the system root CA store" but the code actually loads webpki_roots::TLS_SERVER_ROOTS (Mozilla's bundled roots); update the comment/summary above the connect_upstream_tls function to state it uses the webpki_roots / Mozilla root CA bundle (or simply "Mozilla's root CA store") instead of the system root store so the comment accurately reflects the implementation.crates/puzzlectl/src/output.rs (1)
110-117: Credential stdin reading is secure but creates unnecessary intermediate allocation.The function creates two
Zeroizing<String>instances: one for the read and one for the trimmed result. The intermediatevalueis zeroized whentrim_end().to_string()creates a new string, which is good, but the pattern could be cleaner.Minor:
context(context_msg.to_string())creates an owned String even when the error path isn't taken. Usingwith_context(|| context_msg.to_string())would be lazier.♻️ Minor optimization
std::io::Read::read_to_string( &mut std::io::Read::take(std::io::stdin(), MAX_CREDENTIAL_SIZE), &mut value, ) - .context(context_msg.to_string())?; + .with_context(|| context_msg.to_string())?;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/output.rs` around lines 110 - 117, The function read_credential_stdin currently creates two Zeroizing<String> instances and eagerly allocates the context message; instead, read directly into a single zeroize::Zeroizing<String> (the existing value), trim in place (e.g., remove trailing whitespace by truncating/popping from value) so you can return that same Zeroizing<String> without creating a second String, and replace context(context_msg.to_string()) with with_context(|| context_msg.to_string()) to make the context allocation lazy; keep references to MAX_CREDENTIAL_SIZE and zeroize::Zeroizing to locate the code to change.crates/puzzlectl/src/commands/credential.rs (1)
217-223: Passphrase prompt writes to stderr but reads from stdin — potential UX issue.When stdin is a pipe (e.g.,
echo "value" | puzzlectl credential add --passphrase), the prompt on line 217 may appear after stdin is exhausted, or stdin may contain both value and passphrase. The sequential reads (value on line 208, passphrase on line 219) assume stdin contains both, which may not be the intended UX.Consider documenting this behavior or using a separate TTY for passphrase prompts when stdin is not interactive.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/credential.rs` around lines 217 - 223, The prompt currently writes to stderr then reads the passphrase from stdin via read_credential_stdin, which breaks when stdin is a pipe; change read behavior to read from the controlling TTY when available (instead of raw stdin) so prompts and input are consistent: detect interactive input with atty::is(atty::Stream::Stdin) or attempt to open /dev/tty and use a TTY-aware reader (e.g., rpassword::read_password_from_tty or read from the opened tty) for the passphrase read invoked where eprint!("Enter passphrase: ") and read_credential_stdin("reading passphrase from stdin") are used, and fall back to reading stdin only when no TTY is available; optionally add a short comment documenting the fallback behavior.crates/puzzle-proxy/src/handler/util.rs (1)
96-104: Function namechrono_nowis misleading — no chrono crate is used.The function uses
std::time::SystemTime, not thechronocrate. Consider renaming totimestamp_noworunix_timestamp_nowfor clarity.♻️ Suggested rename
-/// Get current timestamp as string. -pub(super) fn chrono_now() -> String { +/// Get current Unix timestamp as string. +pub(super) fn unix_timestamp_now() -> String {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/util.rs` around lines 96 - 104, The function name chrono_now is misleading because it doesn't use the chrono crate; rename it to a descriptive name like unix_timestamp_now or timestamp_now (e.g., change pub(super) fn chrono_now() -> String to pub(super) fn unix_timestamp_now() -> String), update the doc comment to reflect it returns a Unix timestamp string, and update all references/call sites to call the new function name; keep the implementation, visibility (pub(super)), and error-handling semantics unchanged.crates/puzzle-proxy/src/handler/credentials.rs (2)
717-732: Missing length checks for base64 and URL-encoded credential variants beforewindows()call.The code checks
scan_bytes.len() >= cred_bytes.len()andscan_bytes.len() >= cred_html_entity.len(), but doesn't verify lengths before callingwindows()oncred_base64andcred_url_encoded. Whilewindows()on an empty slice or whensize > lenreturns an empty iterator (not a panic), the check forcred_html_entitysuggests the intent was to guard all variants. For consistency and clarity, add similar guards.♻️ Suggested fix for consistency
let leaked = !cred_bytes.is_empty() && scan_bytes.len() >= cred_bytes.len() && (scan_bytes .windows(cred_bytes.len()) .any(|w| w == cred_bytes) - || scan_bytes + || (scan_bytes.len() >= cred_base64.len() + && scan_bytes .windows(cred_base64.len()) - .any(|w| w == cred_base64.as_bytes()) - || scan_bytes + .any(|w| w == cred_base64.as_bytes())) + || (scan_bytes.len() >= cred_url_encoded.len() + && scan_bytes .windows(cred_url_encoded.len()) - .any(|w| w == cred_url_encoded.as_bytes()) + .any(|w| w == cred_url_encoded.as_bytes())) || (!cred_html_entity.is_empty() && scan_bytes.len() >= cred_html_entity.len() && scan_bytes .windows(cred_html_entity.len()) .any(|w| w == cred_html_entity.as_bytes())));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 717 - 732, The leaked computation is missing explicit length guards for cred_base64 and cred_url_encoded before calling windows(), causing inconsistency with the cred_html_entity guard; update the leaked boolean expression (where leaked is computed) to also verify scan_bytes.len() >= cred_base64.len() and scan_bytes.len() >= cred_url_encoded.len() before invoking scan_bytes.windows(...) for those variants (similar to the existing checks around cred_bytes and cred_html_entity) so all credential variants are guarded consistently.
421-426: RedundantRequest::from_partsreconstruction when no headers stripped.When
strippedis false at line 421, the code returns a newly-constructed request from parts that were just destructured at line 409. This creates unnecessary overhead. Consider returning early before destructuring if no phantom token was found.♻️ Suggested optimization
+ // M-4: When no phantom token is found in any header, strip all credential-bearing + // headers as defense-in-depth (PRD §3.4.8 Step 5). The agent should only use + // phantom tokens; any raw credential header is fabricated or leaked. + let needs_stripping = ["x-api-key", "proxy-authorization"] + .iter() + .any(|h| req.headers().contains_key(*h)); + + if !needs_stripping { + return Ok((req, None)); + } + let (mut parts, body) = req.into_parts(); - let mut stripped = false; for header_name in &["x-api-key", "proxy-authorization"] { if parts.headers.remove(*header_name).is_some() { tracing::warn!( branch = %branch_id, header = %header_name, "M-4/§3.4: stripping non-phantom credential header (no phantom token found)" ); - stripped = true; } } - if stripped { - return Ok((Request::from_parts(parts, body), None)); - } - - Ok((Request::from_parts(parts, body), None)) + Ok((Request::from_parts(parts, body), None))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 421 - 426, The code reconstructs the Request with Request::from_parts even when stripped is false, causing unnecessary allocation; instead, check the stripped flag before destructuring the incoming request (i.e., before splitting into parts/body) and return the original Request when no phantom token was found, or otherwise only reconstruct when stripped is true; update the branch so that when stripped == false you return Ok((original_request, None)) and remove the redundant final Request::from_parts reconstruction.crates/puzzlectl/src/cli.rs (1)
569-591:--from-envand--from-fileshould be mutually exclusive.The
Addcommand allows both--from-envand--from-fileto be specified simultaneously, but only one source can be used. Addconflicts_withto prevent ambiguous input.🔧 Proposed fix
/// Read value from environment variable instead of stdin - #[arg(long)] + #[arg(long, conflicts_with = "from_file")] from_env: Option<String>, /// Read value from file instead of stdin - #[arg(long)] + #[arg(long, conflicts_with = "from_env")] from_file: Option<String>,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/cli.rs` around lines 569 - 591, The Add command currently allows both --from-env and --from-file simultaneously causing ambiguity; update the Add variant in the enum to declare these fields as mutually exclusive by adding Arg clap metadata: add #[arg(conflicts_with = "from_file")] to the from_env field and #[arg(conflicts_with = "from_env")] to the from_file field so the clap parser will prevent specifying both; target the Add variant and the fields named from_env and from_file in cli.rs.crates/puzzlectl/src/commands/attestation.rs (1)
932-948: Hardcoded legacy path/var/lib/puzzled/branches/manifests/may not match deployment.The legacy manifest path at line 939 is hardcoded, but the primary
attestation_diris a parameter. If the deployment uses a non-standard base path, the legacy fallback will look in the wrong location. Consider deriving the legacy path from a common base or making it configurable.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/attestation.rs` around lines 932 - 948, The function load_commit_manifest currently checks a hardcoded legacy_path ("/var/lib/puzzled/branches/manifests") which can be wrong for nonstandard deployments; change the fallback to derive the legacy location from the provided attestation_dir or a configurable base instead of a hardcoded string. Concretely, replace the hardcoded Path::new("/var/lib/puzzled/branches/manifests") with a derived path built from attestation_dir (e.g., attestation_dir.ancestors() or attestation_dir.parent() traversal to locate a common base and then .join("branches").join("manifests")), or read a configurable base directory (env/config) and join("branches").join("manifests"); keep using legacy_path.exists() and load_manifest_file(&legacy_path) as before. Ensure you update the variable legacy_path and related checks in load_commit_manifest accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1b63370d-14a9-4254-befe-39d3e5194a04
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (115)
.cursor/.gitignorecrates/puzzle-hook/Cargo.tomlcrates/puzzle-hook/src/main.rscrates/puzzle-init/Cargo.tomlcrates/puzzle-init/src/main.rscrates/puzzle-phase2-demo/src/main.rscrates/puzzle-proxy/src/credentials.rscrates/puzzle-proxy/src/handler.rscrates/puzzle-proxy/src/handler/connect.rscrates/puzzle-proxy/src/handler/credentials.rscrates/puzzle-proxy/src/handler/dlp_inspect.rscrates/puzzle-proxy/src/handler/forward.rscrates/puzzle-proxy/src/handler/intercept.rscrates/puzzle-proxy/src/handler/journal.rscrates/puzzle-proxy/src/handler/mod.rscrates/puzzle-proxy/src/handler/routing.rscrates/puzzle-proxy/src/handler/tls.rscrates/puzzle-proxy/src/handler/transparent.rscrates/puzzle-proxy/src/handler/util.rscrates/puzzle-proxy/src/lib.rscrates/puzzle-proxy/src/secure_memory.rscrates/puzzle-proxy/tests/handler_tests.rscrates/puzzlectl/src/cli.rscrates/puzzlectl/src/commands/agent.rscrates/puzzlectl/src/commands/attestation.rscrates/puzzlectl/src/commands/audit.rscrates/puzzlectl/src/commands/branch.rscrates/puzzlectl/src/commands/compliance_cmd.rscrates/puzzlectl/src/commands/credential.rscrates/puzzlectl/src/commands/mod.rscrates/puzzlectl/src/commands/policy.rscrates/puzzlectl/src/commands/profile.rscrates/puzzlectl/src/commands/run.rscrates/puzzlectl/src/commands/status.rscrates/puzzlectl/src/compliance.rscrates/puzzlectl/src/lib.rscrates/puzzlectl/src/main.rscrates/puzzlectl/src/output.rscrates/puzzlectl/src/sim/batch.rscrates/puzzlectl/src/sim/engine.rscrates/puzzlectl/src/sim/repl.rscrates/puzzled-types/Cargo.tomlcrates/puzzled-types/src/attestation.rscrates/puzzled-types/src/audit.rscrates/puzzled-types/src/behavioral.rscrates/puzzled-types/src/branch.rscrates/puzzled-types/src/change.rscrates/puzzled-types/src/credential.rscrates/puzzled-types/src/identity.rscrates/puzzled-types/src/lib.rscrates/puzzled-types/src/policy.rscrates/puzzled-types/src/profile.rscrates/puzzled-types/src/provenance.rscrates/puzzled-types/src/trust.rscrates/puzzled/Cargo.tomlcrates/puzzled/benches/branch.rscrates/puzzled/benches/policy.rscrates/puzzled/src/audit_store.rscrates/puzzled/src/branch.rscrates/puzzled/src/branch/activate.rscrates/puzzled/src/branch/cleanup.rscrates/puzzled/src/branch/commit_flow.rscrates/puzzled/src/branch/mod.rscrates/puzzled/src/branch/tests.rscrates/puzzled/src/commit.rscrates/puzzled/src/config.rscrates/puzzled/src/conflict.rscrates/puzzled/src/dbus/helpers.rscrates/puzzled/src/dbus/mod.rscrates/puzzled/src/dbus/tests.rscrates/puzzled/src/diff.rscrates/puzzled/src/error.rscrates/puzzled/src/ima.rscrates/puzzled/src/landlock_rules.rscrates/puzzled/src/lib.rscrates/puzzled/src/main.rscrates/puzzled/src/policy.rscrates/puzzled/src/profile.rscrates/puzzled/src/provenance.rscrates/puzzled/src/sandbox/bpf_lsm.rscrates/puzzled/src/sandbox/cgroup.rscrates/puzzled/src/sandbox/fanotify.rscrates/puzzled/src/sandbox/landlock.rscrates/puzzled/src/sandbox/overlay.rscrates/puzzled/src/sandbox/quota.rscrates/puzzled/src/sandbox/seccomp/filter.rscrates/puzzled/src/sandbox/seccomp/mod.rscrates/puzzled/src/sandbox/seccomp/notif.rscrates/puzzled/src/seccomp_handler.rscrates/puzzled/src/seccomp_profile.rscrates/puzzled/src/sync_util.rscrates/puzzled/src/test_helpers.rscrates/puzzled/src/trust.rscrates/puzzled/src/wal.rscrates/puzzled/tests/branch_lifecycle.rscrates/puzzled/tests/common/mod.rscrates/puzzled/tests/concurrent_branches.rscrates/puzzled/tests/crash_recovery.rscrates/puzzled/tests/e2e_adversarial.rscrates/puzzled/tests/e2e_governance_lifecycle.rscrates/puzzled/tests/e2e_scenarios.rscrates/puzzled/tests/ima_integration.rscrates/puzzled/tests/phase2_features.rscrates/puzzled/tests/podman_native_integration.rscrates/puzzled/tests/policy_evaluation.rscrates/puzzled/tests/sandbox_containment.rscrates/puzzled/tests/security_hardening.rsdocs/compliance/eu-ai-act.mddocs/compliance/nist-ai-rmf.mddocs/security-guide.mddocs/technical-design.mdpolicies/profiles/restricted.yamlpolicies/profiles/standard.yamlpolicies/rules/commit.regopolicies/schemas/profile.schema.json
💤 Files with no reviewable changes (1)
- crates/puzzlectl/src/sim/batch.rs
✅ Files skipped from review due to trivial changes (21)
- .cursor/.gitignore
- crates/puzzle-hook/Cargo.toml
- crates/puzzled/Cargo.toml
- crates/puzzle-phase2-demo/src/main.rs
- crates/puzzlectl/src/commands/mod.rs
- crates/puzzlectl/src/lib.rs
- crates/puzzlectl/src/commands/run.rs
- crates/puzzled-types/src/provenance.rs
- crates/puzzle-proxy/src/secure_memory.rs
- crates/puzzled/src/audit_store.rs
- crates/puzzled-types/src/identity.rs
- crates/puzzled-types/src/attestation.rs
- crates/puzzle-init/Cargo.toml
- crates/puzzled-types/src/audit.rs
- crates/puzzled-types/src/behavioral.rs
- crates/puzzled/benches/branch.rs
- crates/puzzle-proxy/src/handler/intercept.rs
- crates/puzzled-types/src/trust.rs
- crates/puzzlectl/src/commands/compliance_cmd.rs
- crates/puzzled/src/branch/mod.rs
- crates/puzzle-hook/src/main.rs
🚧 Files skipped from review as they are similar to previous changes (9)
- crates/puzzled-types/Cargo.toml
- crates/puzzle-proxy/src/credentials.rs
- crates/puzzlectl/src/commands/agent.rs
- crates/puzzlectl/src/commands/audit.rs
- crates/puzzle-proxy/tests/handler_tests.rs
- crates/puzzled-types/src/branch.rs
- crates/puzzle-init/src/main.rs
- crates/puzzle-proxy/src/handler/dlp_inspect.rs
- crates/puzzled/benches/policy.rs
| if let Some(addrs) = resolved_addrs { | ||
| if let Some(addr) = addrs.first() { | ||
| let port = uri.port_u16().unwrap_or(match uri.scheme_str() { | ||
| Some("https") => 443, | ||
| _ => 80, | ||
| }); | ||
| let target_addr = std::net::SocketAddr::new(addr.ip(), port); | ||
|
|
||
| return match tokio::time::timeout(REQUEST_TIMEOUT, async { | ||
| // Connect to the pinned IP address directly | ||
| let tcp_stream = tokio::net::TcpStream::connect(target_addr).await | ||
| .map_err(|e| { | ||
| tracing::error!(uri = %uri, addr = %target_addr, error = %e, "failed to connect to pinned IP"); | ||
| e | ||
| }); | ||
|
|
||
| let tcp_stream = match tcp_stream { | ||
| Ok(s) => s, | ||
| Err(_e) => { | ||
| return Ok(Ok(Response::builder() | ||
| .status(StatusCode::BAD_GATEWAY) | ||
| // K41: Omit internal IP and error details from response | ||
| .body(Full::new(Bytes::from("Failed to connect to upstream\n"))) | ||
| .unwrap())); | ||
| } | ||
| }; | ||
|
|
||
| // Use the TCP stream with hyper's handshake for HTTP/1.1 | ||
| let io = hyper_util::rt::TokioIo::new(tcp_stream); | ||
| let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await { |
There was a problem hiding this comment.
Pinned-IP forwarding still downgrades https:// requests to plaintext HTTP.
The fallback path rejects absolute-form https:// URIs, but this branch connects to port 443 and immediately runs an HTTP/1.1 handshake on the raw TcpStream. Any request that reaches the pinned-IP path sends headers and body in cleartext.
Suggested fix
let uri = req.uri().clone();
tracing::debug!(uri = %uri, "forwarding request");
+ if uri.scheme_str() == Some("https") {
+ tracing::error!(uri = %uri, "refusing to forward https:// URI over plaintext HTTP client");
+ return Ok(Response::builder()
+ .status(StatusCode::BAD_GATEWAY)
+ .body(Full::new(Bytes::from("TLS required for https:// URIs\n")))
+ .unwrap());
+ }
+
// H-3: If we have pre-resolved addresses, connect directly to the pinned IP
// instead of letting the HTTP client re-resolve DNS (TOCTOU protection).
if let Some(addrs) = resolved_addrs {| if dlp_engine.is_some() && req.headers().get(hyper::header::CONTENT_ENCODING).is_some() { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| "D-C1: blocking request with Content-Encoding — DLP inspection cannot inspect compressed bodies" | ||
| ); | ||
| return Ok(Response::builder() | ||
| .status(StatusCode::FORBIDDEN) | ||
| .body(Full::new(Bytes::from( | ||
| "Content-Encoding blocked: DLP inspection is active\n", | ||
| ))) | ||
| .unwrap()); | ||
| } |
There was a problem hiding this comment.
Don't reject Content-Encoding: identity.
This branch blocks any request that merely has a Content-Encoding header. identity is still plain bytes, so DLP can inspect it; rejecting it turns a no-op encoding into a false 403.
Suggested fix
- if dlp_engine.is_some() && req.headers().get(hyper::header::CONTENT_ENCODING).is_some() {
+ if dlp_engine.is_some()
+ && req
+ .headers()
+ .get(hyper::header::CONTENT_ENCODING)
+ .and_then(|v| v.to_str().ok())
+ .map(|v| !v.trim().eq_ignore_ascii_case("identity"))
+ .unwrap_or(true)
+ {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if dlp_engine.is_some() && req.headers().get(hyper::header::CONTENT_ENCODING).is_some() { | |
| tracing::warn!( | |
| branch = %branch_id, | |
| "D-C1: blocking request with Content-Encoding — DLP inspection cannot inspect compressed bodies" | |
| ); | |
| return Ok(Response::builder() | |
| .status(StatusCode::FORBIDDEN) | |
| .body(Full::new(Bytes::from( | |
| "Content-Encoding blocked: DLP inspection is active\n", | |
| ))) | |
| .unwrap()); | |
| } | |
| if dlp_engine.is_some() | |
| && req | |
| .headers() | |
| .get(hyper::header::CONTENT_ENCODING) | |
| .and_then(|v| v.to_str().ok()) | |
| .map(|v| !v.trim().eq_ignore_ascii_case("identity")) | |
| .unwrap_or(false) | |
| { | |
| tracing::warn!( | |
| branch = %branch_id, | |
| "D-C1: blocking request with Content-Encoding — DLP inspection cannot inspect compressed bodies" | |
| ); | |
| return Ok(Response::builder() | |
| .status(StatusCode::FORBIDDEN) | |
| .body(Full::new(Bytes::from( | |
| "Content-Encoding blocked: DLP inspection is active\n", | |
| ))) | |
| .unwrap()); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 533 - 544, The current
check blocks any request with a CONTENT_ENCODING header even when it is
"identity"; update the condition around dlp_engine and
req.headers().get(hyper::header::CONTENT_ENCODING) to read the header value(s)
and only treat the request as forbidden if any encoding value is present and not
equal to "identity" (case-insensitive, handling comma-separated values and
trimming whitespace). Modify the branch that logs and returns the FORBIDDEN
response (the block referencing dlp_engine, req.headers(), CONTENT_ENCODING, and
branch_id) so it allows requests whose Content-Encoding is absent or consists
solely of "identity" and only triggers the warning/403 when a non-identity
encoding is detected.
| let server_name = rustls::pki_types::ServerName::try_from(domain.to_string()) | ||
| .map_err(|e| format!("invalid server name '{}': {}", domain, e))?; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if there's handling for IP address targets elsewhere in the codebase
rg -n "ServerName" crates/puzzle-proxy/src/ -A3 -B3 | head -60Repository: LobsterTrap/puzzlepod
Length of output: 680
🏁 Script executed:
#!/bin/bash
# Read the complete function containing the ServerName conversion
sed -n '180,230p' crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 1816
🏁 Script executed:
#!/bin/bash
# Search for the function definition to understand parameters
rg -n "fn.*domain" crates/puzzle-proxy/src/handler/tls.rs -B2 -A10Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Look for any IP address handling in the file
rg -n "ip\|IP\|address\|IpAddr" crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Search for calls to this TLS handler function to understand inputs
rg -n "tls\|TLS" crates/puzzle-proxy/src/ --type rust | grep -i "call\|invoke\|connect" | head -20Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Find all calls to connect_upstream_tls
rg -n "connect_upstream_tls" crates/puzzle-proxy/src/ -B5 -A5Repository: LobsterTrap/puzzlepod
Length of output: 3521
🏁 Script executed:
#!/bin/bash
# Search for domain extraction/parsing to understand where domain comes from
rg -n "domain|host" crates/puzzle-proxy/src/handler/ --type rust | head -40Repository: LobsterTrap/puzzlepod
Length of output: 4075
🏁 Script executed:
#!/bin/bash
# Look for routing validation calls before connect_upstream_tls
rg -n "validate_routing|dns_rebinding" crates/puzzle-proxy/src/handler/ -B5 -A5Repository: LobsterTrap/puzzlepod
Length of output: 10174
🏁 Script executed:
#!/bin/bash
# Check the full context around connect_upstream_tls calls in tls.rs to see what happens before
sed -n '40,135p' crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 3324
🏁 Script executed:
#!/bin/bash
# Check CONNECT request parsing to see how domain is extracted
sed -n '40,70p' crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 877
🏁 Script executed:
#!/bin/bash
# Get full context of CONNECT handler to see if routing validation happens before connect_upstream_tls
sed -n '1,140p' crates/puzzle-proxy/src/handler/tls.rs | tail -100Repository: LobsterTrap/puzzlepod
Length of output: 3503
🏁 Script executed:
#!/bin/bash
# Check if connect_upstream function (line 102 in tls.rs) validates the address
rg -n "pub async fn connect_upstream" crates/puzzle-proxy/src/ -A15Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Search for connect_upstream function definition
rg -n "fn connect_upstream" crates/puzzle-proxy/src/handler/ -A20Repository: LobsterTrap/puzzlepod
Length of output: 3922
🏁 Script executed:
#!/bin/bash
# Look at the broader context - where does resolved_addrs come from in the CONNECT handler
sed -n '20,110p' crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 3466
🏁 Script executed:
#!/bin/bash
# Check if there's any explicit validation or documentation about IP addresses in the codebase
rg -n "ip.*address|IP.*address|literal" crates/puzzle-proxy/src/handler/tls.rsRepository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
#!/bin/bash
# Look for any tests that verify ServerName behavior with different inputs
rg -n "ServerName|try_from" crates/puzzle-proxy/src/ -B2 -A2 | grep -i "test\|ip\|addr"Repository: LobsterTrap/puzzlepod
Length of output: 443
Add explicit IP address validation before ServerName conversion.
SNI does not support IP addresses. While ServerName::try_from() will correctly reject them, the error message doesn't clearly indicate why. Add explicit validation to reject IP address literals with a clear error message before calling try_from(), or add a prefixed comment tag (e.g., DC: SNI does not support IP addresses) explaining this design limitation. This improves clarity for maintainers and provides better error diagnostics.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/tls.rs` around lines 206 - 207, Before
calling rustls::pki_types::ServerName::try_from for the variable server_name,
explicitly check if domain is an IP literal (e.g.,
domain.parse::<std::net::IpAddr>()) and return a clear Err like "invalid server
name '<domain>': SNI does not support IP addresses" if it parses; then proceed
to call ServerName::try_from(domain.to_string()) for non-IP inputs. Also add a
short inline comment (DC: SNI does not support IP addresses) above this
validation to document the design limitation for maintainers.
| /// Escape a string for embedding in a Rego double-quoted string literal. | ||
| fn escape_rego_string(s: &str) -> String { | ||
| s.replace('\\', "\\\\").replace('"', "\\\"") | ||
| } |
There was a problem hiding this comment.
Escape control characters before interpolating CLI strings into Rego.
escape_rego_string() only handles \ and ". A message or pattern containing \n, \r, or \t will emit invalid Rego, and raw newlines let CLI input reshape the generated rule text. Use a real string-literal encoder here, e.g. serde_json::to_string().
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/policy.rs` around lines 117 - 120, Replace the
ad-hoc escaping in escape_rego_string with a proper JSON string encoder to
handle control characters; call serde_json::to_string(s) to produce a safely
escaped double-quoted JSON string, then remove the surrounding quotes (e.g.
strip_prefix('"')/strip_suffix('"') or slice off first/last char) and return the
inner escaped content so it can be embedded in a Rego double-quoted literal;
ensure you add serde_json to dependencies if missing and handle the to_string
error (unwrap_or fallback or propagate) as appropriate.
| for entry in std::fs::read_dir(dir_path).context("reading profiles directory")? { | ||
| let entry = entry?; | ||
| let path = entry.path(); | ||
| if path.extension().and_then(|e| e.to_str()) == Some("yaml") { |
There was a problem hiding this comment.
Bare-name resolution should accept .yml profiles too.
These paths only discover or construct .yaml files. A valid foo.yml profile is skipped by list and not found by show/test unless the caller passes the full path. Based on learnings "Applies to policies/profiles/**/*.{yaml,yml} : Define agent profiles in YAML format and validate them against JSON schema".
Also applies to: 84-85, 347-350
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/profile.rs` around lines 18 - 21, The directory
scan and bare-name resolution only accept ".yaml" files; update the extension
checks used in the read_dir loop (the extension().and_then(|e| e.to_str()) ==
Some("yaml") check) and any places that construct profile paths so they accept
both "yaml" and "yml" (e.g., change to check ext == "yaml" || ext == "yml" or
normalize to_lowercase and match against a set). Also update the bare-name
resolver used by the show/test logic to try both "<name>.yaml" and "<name>.yml"
(or prefer one then fallback to the other) so profiles named foo.yml are
discovered by list, show, and test. Ensure you update every occurrence of the
extension check/construct (the read_dir loop and the path-building branches
referenced in the diff).
| if let Some(ref ptm) = self.phantom_token_manager { | ||
| // Must guarantee revocation — use blocking write to avoid silently skipping | ||
| let mut ptm_guard = tokio::task::block_in_place(|| { | ||
| tokio::runtime::Handle::current().block_on(ptm.write()) | ||
| }); | ||
| ptm_guard.revoke_branch(id); |
There was a problem hiding this comment.
Token revocation should not depend on already being inside a Tokio runtime.
shutdown_all() and apply_fail_mode() are synchronous paths. If they call this without a Tokio context, block_in_place(|| Handle::current().block_on(...)) panics before cleanup completes. Prefer a runtime-independent blocking path here, e.g. RwLock::blocking_write() or a try_current() fallback.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/cleanup.rs` around lines 75 - 80, The current token
revocation uses tokio::task::block_in_place +
tokio::runtime::Handle::current().block_on when calling ptm.write(), which
panics if invoked outside a Tokio runtime (e.g., from shutdown_all() or
apply_fail_mode()); change this to a runtime-independent blocking path by using
the underlying lock's blocking API (e.g., call RwLock::blocking_write() on
phantom_token_manager or use ptm.write().blocking_* equivalent) or implement a
fallback that attempts tokio::runtime::Handle::try_current() and only uses
block_in_place when a runtime is available — ensure you keep the
revoke_branch(id) call on the acquired write guard (ptm_guard.revoke_branch) and
update code references to phantom_token_manager, ptm.write(), revoke_branch,
shutdown_all(), and apply_fail_mode accordingly.
| let diff_start = std::time::Instant::now(); | ||
| let changes = | ||
| self.diff_engine | ||
| .generate(&upper_dir, &base_path, cgroup_path_for_diff.as_deref())?; |
There was a problem hiding this comment.
Don't ? out after freezing the branch.
Once the state is Frozen, both generate() and evaluate_full() can return early via ?. That bypasses thaw/rollback and even skips the PolicyDecision::Error fail-closed path, leaving the branch wedged in Frozen. Based on learnings "Fail closed: if governance cannot be determined, rollback instead of commit".
Also applies to: 174-180
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/commit_flow.rs` around lines 124 - 127, You
currently call diff_engine.generate(...) and later evaluate_full(...) with the
try-operator after setting the branch state to Frozen, which allows early
returns (via ?) and bypasses the thaw/rollback and PolicyDecision::Error
fail-closed handling; instead, change both call sites (the diff generation and
the evaluate_full invocation) to not use ? directly—capture their Result, map
any Err into the same fail-closed path (e.g., treat as PolicyDecision::Error or
an internal error variant), ensure you explicitly call the thaw/rollback logic
on error, and then return/propagate after rollback; locate the calls to
diff_engine.generate and to evaluate_full in commit_flow.rs and replace ? with
explicit match/if let handling that triggers rollback/thaw on Err.
| if changes.is_empty() { | ||
| // No changes to commit — clean up resources before returning | ||
| self.transition(id, BranchState::Committed)?; | ||
| #[cfg(target_os = "linux")] | ||
| self.cleanup_sandbox_resources(id); | ||
| unlock_poisoned(self.conflict_detector.lock()).unregister_branch(id); | ||
| self.branches.remove(id); | ||
| return Ok(CommitResult { | ||
| branch_id: id.clone(), | ||
| files_committed: 0, | ||
| bytes_committed: 0, | ||
| policy_result: PolicyDecision::Approved, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Empty filesystem diffs can still have journaled side effects.
This fast path skips replay_network_journal() and branch cleanup. A branch that only staged outbound POST/PUT/PATCH work will report success with files_committed = 0, but its journaled side effects are never replayed and the proxy/journal entries stay live.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/commit_flow.rs` around lines 143 - 156, The
early-return path in the commit flow currently skips replay_network_journal()
and other cleanup, so branches with only journaled network side effects (staged
outbound POST/PUT/PATCH) never have their journal replayed and leave
proxy/journal entries live; modify the branch in commit_flow.rs (the block that
calls self.transition(...), #[cfg(...)] self.cleanup_sandbox_resources(id),
unlock_poisoned(...).unregister_branch(id), and self.branches.remove(id)) so
that before returning you call the existing replay_network_journal() for the
branch and perform any branch-specific cleanup/teardown (including
unregister_branch and removing from self.branches) — i.e., ensure
replay_network_journal() runs even when changes.is_empty() and then proceed with
the same transition/cleanup/unregister/remove logic.
| PolicyDecision::Error(ref msg) => { | ||
| tracing::error!(branch = %id, error = %msg, "policy evaluation error"); | ||
| // M9: Wire metrics: include error context in outcome label | ||
| if let Some(m) = self.get_metrics() { | ||
| m.commit_outcomes | ||
| .get_or_create(&crate::metrics::OutcomeLabels { | ||
| outcome: "error".to_string(), // T1: fixed label to prevent unbounded metric cardinality | ||
| }) | ||
| .inc(); | ||
| } | ||
| self.apply_fail_mode(id); | ||
| Ok(CommitResult { | ||
| branch_id: id.clone(), | ||
| files_committed: 0, | ||
| bytes_committed: 0, | ||
| policy_result: decision, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd crates/puzzled/src/branch && head -330 commit_flow.rs | tail -40Repository: LobsterTrap/puzzlepod
Length of output: 1801
🏁 Script executed:
find . -name "*.rs" -type f | xargs rg "enum PolicyDecision|struct PolicyDecision" -A 5Repository: LobsterTrap/puzzlepod
Length of output: 378
🏁 Script executed:
rg "fn.*match.*decision|let.*decision.*=" crates/puzzled/src/branch/commit_flow.rs -B 5 -A 15Repository: LobsterTrap/puzzlepod
Length of output: 1993
🏁 Script executed:
rg "policy_result:" crates/puzzled/src/branch/commit_flow.rs -B 2 -A 2Repository: LobsterTrap/puzzlepod
Length of output: 1537
🏁 Script executed:
cd crates/puzzled && grep -r "PolicyDecision" --include="*.rs" | grep -i "derive\|copy" | head -20Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
# Check if the code compiles by looking for any recent modifications or errors
git log --oneline -10 -- crates/puzzled/src/branch/commit_flow.rs 2>/dev/null || echo "Git not available"Repository: LobsterTrap/puzzlepod
Length of output: 125
🏁 Script executed:
# Get the full context around the match statement
sed -n '270,330p' crates/puzzled/src/branch/commit_flow.rsRepository: LobsterTrap/puzzlepod
Length of output: 2747
🏁 Script executed:
# Check the exact match context - what is being matched?
sed -n '240,265p' crates/puzzled/src/branch/commit_flow.rsRepository: LobsterTrap/puzzlepod
Length of output: 1319
🏁 Script executed:
# Verify compilation status
cargo check --manifest-path crates/puzzled/Cargo.toml 2>&1 | head -50Repository: LobsterTrap/puzzlepod
Length of output: 311
🏁 Script executed:
# Get more context above - find the match statement start
sed -n '200,270p' crates/puzzled/src/branch/commit_flow.rsRepository: LobsterTrap/puzzlepod
Length of output: 3541
🏁 Script executed:
# Get the full function to understand the context better
sed -n '150,280p' crates/puzzled/src/branch/commit_flow.rs | head -80Repository: LobsterTrap/puzzlepod
Length of output: 3853
🏁 Script executed:
# Check if there are any recent test failures or compilation errors mentioned
grep -r "decision" crates/puzzled/src/branch/commit_flow.rs | grep -i "error\|warn" | head -10Repository: LobsterTrap/puzzlepod
Length of output: 47
🏁 Script executed:
# Look at the exact match statement - is it `match decision` or `match &decision`?
sed -n '260,330p' crates/puzzled/src/branch/commit_flow.rs | grep -A 70 "match decision"Repository: LobsterTrap/puzzlepod
Length of output: 2852
Use d @ PolicyDecision::Error(ref msg) to bind the entire matched enum before using it as policy_result.
The match decision statement (line 261) moves the decision value. The pattern PolicyDecision::Error(ref msg) borrows the inner String but still consumes the outer enum. In the Error arm, policy_result: decision reuses the moved value and won't compile. Use the @ binding to capture the full enum: change the pattern to d @ PolicyDecision::Error(ref msg) and use policy_result: d.
Suggested fix
- PolicyDecision::Error(ref msg) => {
+ d @ PolicyDecision::Error(ref msg) => {
tracing::error!(branch = %id, error = %msg, "policy evaluation error");
// M9: Wire metrics: include error context in outcome label
if let Some(m) = self.get_metrics() {
m.commit_outcomes
.get_or_create(&crate::metrics::OutcomeLabels {
outcome: "error".to_string(), // T1: fixed label to prevent unbounded metric cardinality
})
.inc();
}
self.apply_fail_mode(id);
Ok(CommitResult {
branch_id: id.clone(),
files_committed: 0,
bytes_committed: 0,
- policy_result: decision,
+ policy_result: d,
})
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/commit_flow.rs` around lines 304 - 319, The match
arm currently patterns as PolicyDecision::Error(ref msg) which moves `decision`
and prevents reusing it for the CommitResult.policy_result; change the pattern
to bind the whole enum (e.g. d @ PolicyDecision::Error(ref msg)) and then set
policy_result: d so the complete PolicyDecision is passed through; update the
Error arm where tracing::error!, metrics update, apply_fail_mode(id), and
CommitResult construction occur to use the new binding name (d) for
policy_result while keeping msg for logging.
| let events_path = cgroup_path.join("cgroup.events"); | ||
| if let Ok(contents) = std::fs::read_to_string(&events_path) { | ||
| if !contents.lines().any(|l| l.trim() == "frozen 1") { | ||
| tracing::error!(branch = %id, "cgroup no longer frozen before WAL commit — aborting"); | ||
| self.rollback_internal("cgroup unfrozen before WAL commit (TOCTOU)", id)?; | ||
| return Err(PuzzledError::Sandbox( | ||
| "cgroup was unfrozen between governance and WAL commit".to_string(), | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
Treat unreadable cgroup.events as verification failure.
If that read fails, this silently skips the pre-WAL freeze check and continues with the commit. That turns a security-critical TOCTOU guard into best-effort precisely when the sandbox state is uncertain.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled/src/branch/commit_flow.rs` around lines 370 - 379, The read of
cgroup.events currently skips verification on error; change the code around
events_path/cgroup_path to treat any std::fs::read_to_string(&events_path) Err
as a verification failure: log an error including branch id (same as
tracing::error!(branch = %id, ...)), call self.rollback_internal with a
TOCTOU/frozen-check message (as used elsewhere), and return
Err(PuzzledError::Sandbox(...)) so the commit is aborted; keep the same
success-path check that requires "frozen 1" in contents.lines().any(...) and
reuse the same error text style and identifiers (events_path, cgroup_path,
self.rollback_internal, PuzzledError::Sandbox).
Part 1: Rigorous consistency review (6 CRITICAL + 24 MAJOR + 17 MINOR) - BPF LSM attach_btf_id resolution from kernel BTF - Document connect/bind sockaddr TOCTOU as residual risk - Add 2s per-notification EPERM deadline to seccomp handler - Restore branches as Degraded on daemon restart - Fail-closed on symlink escape in commit path - Fix rootless config panic in default_for_user() - Implement fuse-overlayfs fallback, landlock fail-closed, cgroup resolution, fanotify degradation, mandatory Landlock network, and 20 additional findings across docs/schema/functionality Part 2: Comprehensive refactoring across 7 phases Phase 1 - Foundation types: - Add PartialEq/Default derives to 18+ types in puzzled-types - Add FileChange::new() constructor and Default impl - Replace 7 stringly-typed config fields with enums (BusType, FsType, LogLevel, DefaultAction, LogTarget, PolicyEngineType, NetworkDefaultMode) - Add SerdeJson, SerdeYaml, WalkDir error variants with #[from] - Unify duplicated AuditEventRecord between crates - Replace glob re-exports with explicit named exports for 50+ types Phase 2 - Module decomposition: - Split puzzlectl/main.rs (4695 lines) into cli.rs, output.rs, and 10 command modules under commands/ - Split puzzled/dbus.rs (5225 lines) into dbus/mod.rs, helpers.rs, tests.rs Phase 3 - DRY fixes: - Extract WAL backup_filename() helper (3 duplicate sites) - Unify is_denylisted into single impl AsRef<Path> function - Remove dead run_one/run_all sim wrappers - Deduplicate attestation verification with shared types and helpers Phase 4 - Idiomatic Rust: - Replace std::process::exit() with proper error propagation - Change validate_branch_id to return BranchId Phase 5 - Test infrastructure: - Create shared tests/common/mod.rs eliminating ~317 lines of duplication - Fix puzzle-proxy tests re-implementing production functions with different semantics (3 divergences fixed) Phase 6 - Technical correctness: - Fix 6 TOCTOU race conditions in wal.rs - Fix report_version hardcoded as 1.0 - Fix format_bytes precision loss - Add 30s D-Bus timeout in puzzle-hook - Fix WAL bookkeeping ordering in commit.rs Phase 7 - Cleanup: - Move serde_yaml to dev-dependencies in puzzled-types - Remove unused _branch_id parameter from PhantomTokenManager - Add SPDX-License-Identifier headers to all source files - Apply cargo fmt Signed-off-by: Adam Miller <admiller@redhat.com>
There was a problem hiding this comment.
Actionable comments posted: 12
♻️ Duplicate comments (16)
crates/puzzlectl/src/commands/credential.rs (1)
248-249:⚠️ Potential issue | 🔴 CriticalValidate
namebefore joining it intosecrets_dir.
join(format!("{}.enc", name))still honors..and absolute prefixes, so a crafted credential name can write outside the secrets directory.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/credential.rs` around lines 248 - 249, The code currently builds enc_path using join(format!("{}.enc", name)) which allows names like "../foo" or absolute paths to escape secrets_dir; validate and sanitize name before joining by ensuring it is a plain filename (no path separators, no components like "..", and not an absolute path) — e.g., check that Path::new(name).components() contains exactly a single Normal component or use file_name() to extract a basename and reject or error on invalid inputs, optionally restricting to a safe charset (alphanumeric, '-', '_'); then use that safe_name in secrets_dir.join(format!("{}.enc", safe_name)) and return an error if validation fails.crates/puzzlectl/src/commands/profile.rs (3)
18-21:⚠️ Potential issue | 🟡 MinorAccept
.ymlprofile names too.Bare-name resolution still only scans/builds
*.yaml, so a validfoo.ymlprofile is skipped unless the caller passes the full path. Based on learnings Applies to policies/profiles/**/*.{yaml,yml} : Define agent profiles in YAML format and validate them against JSON schema.Also applies to: 84-85, 347-350
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 18 - 21, The directory scan currently only matches ".yaml" files and should also accept ".yml"; update the file-extension checks used in the profile directory reader (the loop that calls std::fs::read_dir and checks path.extension()), plus the other similar checks around the same module (the checks at the locations corresponding to lines 84-85 and 347-350 in this diff) to treat both "yaml" and "yml" as valid extensions—implement this by converting extension to string (as done with and_then(|e| e.to_str())) and comparing against a set or ORing comparisons for Some("yaml") or Some("yml") so bare-name resolution includes foo.yml.
112-139:⚠️ Potential issue | 🟠 Major
profile validatestill skips schema validation.This only checks deserialization plus a few handwritten guards, so schema-level drift can still be reported as valid. Based on learnings Applies to policies/profiles/**/*.{yaml,yml} : Define agent profiles in YAML format and validate them against JSON schema.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 112 - 139, The cmd_profile_validate function currently only deserializes into AgentProfile and runs resource_limits.validate(); add an explicit JSON Schema check after parsing to catch schema drift by converting the parsed YAML to JSON (or re-parsing with serde_json) and validating it against the canonical agent profile schema exposed by the puzzled-types crate (e.g., reference a schema constant or function in puzzled_types such as AGENT_PROFILE_SCHEMA or a get_agent_profile_schema() helper), and if the validator returns errors, fail with a clear message similar to the existing resource_limits error handling; keep existing checks (profile.name, resource_limits.validate()) but run the schema validator before printing "Profile ... is valid".
378-412:⚠️ Potential issue | 🟠 MajorMatch
profile testto the daemon's denylist rules.This helper still ignores
read_denylistandwrite_denylist, so it can PASS a changeset that the daemon will reject.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/profile.rs` around lines 378 - 412, The helper currently checks only filesystem.denylist and write_allowlist; update the matching logic in the profile test helper (the block that iterates profile.filesystem.denylist and the subsequent write allowlist check) to also enforce profile.filesystem.read_denylist and profile.filesystem.write_denylist using Path::starts_with() for component-aware matching; specifically, treat read_denylist entries as denying any read-like changes (e.g., when change.kind is a read access) and treat write_denylist like denylist for Added/Modified kinds, and set blocked and reason consistently (e.g., "matches read_denylist pattern ..." or "matches write_denylist pattern ...") so the helper's behavior matches the daemon's denylist/write_denylist rules.crates/puzzlectl/src/commands/run.rs (3)
203-206:⚠️ Potential issue | 🟠 MajorDon't turn a bad diff payload into
no_changes.
unwrap_or(0)conflates malformed diff JSON with an empty diff, and the next block rolls the branch back as if nothing changed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/run.rs` around lines 203 - 206, The code treats a malformed diff JSON as zero changes by using unwrap_or(0); instead, propagate a parsing error so malformed payloads don't trigger a silent rollback. Replace the unwrap_or(0) on the serde_json::from_str call (where diff_json is set from client.diff_branch and assigned to change_count) with proper error propagation (e.g., use ? or map_err to return/throw a parsing error with context) so the caller sees a failure instead of treating the diff as empty.
171-175:⚠️ Potential issue | 🟠 MajorRollback the created branch when activation fails.
Once
create_branchsucceeds, anactivate_brancherror returns immediately and leaves the new branch behind.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/run.rs` around lines 171 - 175, After create_branch succeeds but client.activate_branch(&branch_id, &command_json).await? may fail, ensure you roll back the created branch: wrap the activate_branch call to catch errors, call client.delete_branch(&branch_id).await (or the appropriate branch-deletion method) to remove the newly created branch, log the rollback (e.g., when OutputFormat::Text print a message), and then return the original activation error; reference the created branch_id, client.activate_branch, and the create_branch result so the deletion targets the correct branch and does not swallow the activation error.
90-97:⚠️ Potential issue | 🟠 MajorTreat
policy_result.Erroras a failed commit.The JSON and text branches only mark
Rejected;{"Error": ...}still falls through to the committed path here, socmd_runcan exit successfully even when governance failed. Based on learnings Fail closed: if governance cannot be determined, rollback instead of commit.Also applies to: 100-128
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/run.rs` around lines 90 - 97, The JSON branch currently only checks for result.get("Rejected") and lets messages with a policy_result.Error fall through as successful; update the check that parses commit_json (the serde_json::from_str::<serde_json::Value> block that inspects v.get("policy_result")) to also treat result.get("Error").is_some() as a rejection by setting rejected = true. Likewise, in the plain-text branch that inspects the commit output (the code that looks for "Rejected" in the textual policy_result), also detect "Error" (e.g., "Error:" or "policy_result: Error") and set rejected = true so any governance Error causes a rollback. Ensure you only change the policy_result checks (commit_json / policy_result handling) and mirror the same logic in the text parsing branch.crates/puzzle-proxy/src/handler/mod.rs (1)
533-544:⚠️ Potential issue | 🟡 MinorDon't reject
Content-Encoding: identity.This branch still blocks any
Content-Encodingheader, even thoughidentityis just the unencoded body and the module already treats it as pass-through elsewhere.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/mod.rs` around lines 533 - 544, The current check blocks any request with a Content-Encoding header even when it's "identity"; change the condition so we only reject when Content-Encoding exists AND its value is not "identity" (case-insensitive). Locate the branch using dlp_engine.is_some() and req.headers().get(hyper::header::CONTENT_ENCODING) (the block that logs with branch = %branch_id) and update it to read the header value (via to_str or header.as_bytes()) and compare against "identity" before returning FORBIDDEN; leave requests with Content-Encoding: identity untouched.crates/puzzlectl/src/commands/policy.rs (2)
64-75:⚠️ Potential issue | 🟠 MajorKeep the Rego test input schema aligned with
FileChange.This mapping still drops
old_mode,entropy, andhas_base64_blocks, sopuzzlectl policy testcan approve a changeset that daemon-side evaluation rejects, or vice versa.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/policy.rs` around lines 64 - 75, The Rego test input currently built in the `input` variable omits fields present on `FileChange` causing schema drift; update the mapping inside the `changes.iter().map(|c| { ... })` block to include `old_mode` (from `c.old_mode`), `entropy` (from `c.entropy`), and `has_base64_blocks` (from `c.has_base64_blocks`) alongside the existing `path`, `kind`, `size`, `checksum`, `target`, and `new_mode` so the JSON matches the `FileChange` struct used by daemon-side evaluation.
117-120:⚠️ Potential issue | 🟠 MajorEscape full Rego string syntax, not just quotes and backslashes.
A user-supplied newline, tab, or carriage return in
messageor the generated patterns will produce invalid rule text here and can reshape the emitted Rego. Use a real string-literal encoder before interpolation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/policy.rs` around lines 117 - 120, escape_rego_string currently only handles backslashes and double-quotes, so newline, tab, carriage return and other control characters in user data can break emitted Rego; update escape_rego_string to produce a valid Rego double-quoted string literal by escaping all control characters (e.g. \n, \r, \t, \b, \f), Unicode-escaping other non-printable codepoints as \uXXXX, and preserving/backslash-escaping backslash and quote characters so that any s: &str becomes safe for interpolation into rule text; locate and modify the escape_rego_string function to implement a full string-literal encoder (or call a library encoder) ensuring returned String is valid Rego syntax.crates/puzzled/src/branch/activate.rs (3)
131-145:⚠️ Potential issue | 🔴 CriticalDon't unblock the sandbox before the remaining fallible setup succeeds.
send_seccomp_ack()happens before network setup, branch-state/resource insertion, and phantom-token provisioning. Any later?/Errleaves a live sandbox behind the failure path, and by the time credential provisioning fails the branch is already markedActiveand stored inself.sandboxes.Also applies to: 419-458, 460-505
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 131 - 145, The code currently calls crate::sandbox::send_seccomp_ack(...) before running remaining fallible setup (e.g., NetworkSetup::configure(...), branch-state/resource insertion, and phantom-token provisioning), which can leave a live sandbox active on error; move the send_seccomp_ack call so it happens only after all fallible operations succeed and after the branch is fully recorded in self.sandboxes and any credential/phantom-token provisioning is complete (i.e., perform NetworkSetup::configure(...), insert branch state/resources into the store, finish phantom-token provisioning, then call send_seccomp_ack), ensuring any ?/Err prior to the ACK cleans up without an active sandbox.
190-225:⚠️ Potential issue | 🟠 MajorCapture the new sandbox's cgroup path directly.
self.sandboxes.get(id)is stillNoneat this point, so the quarantine task closes overNoneandcgroup.freezenever runs for this branch.🛠️ Suggested fix
- let cgroup_path = self.sandboxes.get(id).map(|h| h.cgroup_path.clone()); + let cgroup_path = Some(handle.cgroup_path.clone());🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 190 - 225, The quarantine task is closing over self.sandboxes.get(id) which is None at this time; instead capture the new sandbox's cgroup path when the sandbox is created and move that into the spawned task. Replace the lookup using self.sandboxes.get(id).map(|h| h.cgroup_path.clone()) with a clone of the cgroup path from the newly-created sandbox handle (e.g. the local sandbox/sandbox_handle variable) and pass that cloned String/PathBuf into the async move closure so CgroupManager::freeze(cg_path) runs for this branch.
83-126:⚠️ Potential issue | 🔴 CriticalFail activation when
proxy_listen_addris invalid.Returning
Nonehere disables the credential-proxy guard and still lets activation continue, which is the opposite of the fail-closed comment above it.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/activate.rs` around lines 83 - 126, The current closure building credential_proxy silently returns None on an invalid self.config.network.proxy_listen_addr which disables the credential-proxy guard; instead make the activation fail-fast by propagating an error from the activation function when proxy_listen_addr cannot be parsed. Replace the "return None" branch inside the credential_proxy creation with logic that returns an Err from the surrounding activation function (or otherwise stops activation), including a descriptive error mentioning proxy_listen_addr parsing failure and referencing the branch being activated; keep the rest of the CredentialProxyContext construction (enabled, proxy_gateway_ip, proxy_port, proxied_ports, global_port_range) unchanged so that validate_connect logic still applies when parsing succeeds.crates/puzzled-types/src/lib.rs (1)
145-172:⚠️ Potential issue | 🟠 MajorReject impossible inclusion proofs up front.
compute_root_from_inclusion()returnsleaf_hashfortree_size <= 1, so a proof withtree_size == 0orleaf_index >= tree_sizecan still verify if the expected root matches that leaf. Add explicit validation before recomputing the root.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/lib.rs` around lines 145 - 172, In verify_merkle_inclusion add explicit validation before calling compute_root_from_inclusion: ensure proof.tree_size > 0 and proof.leaf_index < proof.tree_size and return an Err with a clear message (e.g., "invalid inclusion proof: tree_size must be > 0" or "invalid inclusion proof: leaf_index out of range") if these checks fail; modify the function verify_merkle_inclusion to perform these checks (referencing proof.tree_size and proof.leaf_index) immediately before building proof_hashes/computing the root so malformed proofs cannot falsely succeed when compute_root_from_inclusion returns the leaf for trivial tree sizes.crates/puzzlectl/src/commands/branch.rs (1)
103-123:⚠️ Potential issue | 🟠 MajorMake
--no-notiffail if stripping the generated profile fails.Any read/parse/write failure in this block currently falls through and the command still succeeds, which can leave a notify-enabled profile on disk contrary to the flag.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/commands/branch.rs` around lines 103 - 123, The --no-notif path currently swallows read/parse/write errors in the block that reads path, parses JSON into profile, removes listener fields and SCMP_ACT_NOTIFY rules, and writes back; change this so any failure (std::fs::read_to_string, serde_json::from_str, serde_json::to_string_pretty, or std::fs::write) makes the command return an Err (or propagate the error) instead of silently succeeding. Concretely, in the block around read_to_string(&path), serde_json::from_str::<serde_json::Value>(&content), serde_json::to_string_pretty(&profile).unwrap_or(content) and std::fs::write(&path).ok(), replace the unwrap_or/.ok() swallowing with proper error handling (use ? or map_err with a descriptive message referencing path and operation) and propagate the error from the function that implements the --no-notif behavior so the command fails when stripping fails.crates/puzzle-proxy/src/handler/intercept.rs (1)
351-548:⚠️ Potential issue | 🔴 CriticalFail closed when Phantom mode has no token manager.
This TLS path still does nothing when
credential_mode == Phantomandphantom_token_managerisNone, so rawAuthorization,Proxy-Authorization, andX-Api-Keyheaders can be forwarded upstream unmodified.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/intercept.rs` around lines 351 - 548, When credential_mode == puzzled_types::CredentialMode::Phantom and phantom_token_manager is None, fail-closed by stripping any credential-bearing headers (Authorization, Proxy-Authorization, X-Api-Key) and logging/warning instead of letting them flow upstream; update the branch that currently only executes when let Some(ref ptm) = phantom_token_manager to include an else case that calls headers.retain(...) to remove those header names (use the same case-insensitive checks as the existing retain), emit a tracing::warn!(branch = %branch_id, ...) about failing closed due to missing phantom_token_manager, and optionally return an error response to the agent via agent_tls if you prefer explicit rejection—make the change adjacent to the existing credential_mode / phantom_token_manager handling so it integrates with inject_header_idx, resolved_idx and the subsequent logic.
🧹 Nitpick comments (10)
crates/puzzle-init/src/main.rs (2)
1012-1014: Consider defining named constants forPR_CAP_AMBIENTmagic numbers.The magic numbers
47and4representPR_CAP_AMBIENTandPR_CAP_AMBIENT_CLEAR_ALLrespectively. While documented in the comment, named constants would improve readability and maintainability.💡 Proposed fix
+ // PR_CAP_AMBIENT constants (not always available in libc) + const PR_CAP_AMBIENT: libc::c_int = 47; + const PR_CAP_AMBIENT_CLEAR_ALL: libc::c_ulong = 4; + // SAFETY: prctl with constant args. Non-fatal on older kernels without // PR_CAP_AMBIENT support. PR_CAP_AMBIENT = 47, PR_CAP_AMBIENT_CLEAR_ALL = 4 - let ret = unsafe { libc::prctl(47, 4, 0, 0, 0) }; + let ret = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-init/src/main.rs` around lines 1012 - 1014, Replace the magic numbers used in the unsafe prctl call with descriptive constants: define PR_CAP_AMBIENT and PR_CAP_AMBIENT_CLEAR_ALL (used around the unsafe libc::prctl(47, 4, 0, 0, 0) invocation) and use those constants in the call to improve readability and maintainability; ensure the constants are documented (e.g., as const PR_CAP_AMBIENT: libc::c_int = 47; const PR_CAP_AMBIENT_CLEAR_ALL: libc::c_int = 4) and update the prctl invocation to use them so the existing SAFETY comment still applies.
639-641: Consider allowing TCP DNS in addition to UDP DNS.Line 641 allows
udp dport 53but DNS can also use TCP for responses exceeding 512 bytes (EDNS0) or when UDP is blocked. If agents make DNS queries that require TCP fallback, they will be silently dropped.💡 Proposed fix to add TCP DNS
add rule ip puzzlepod_filter output ip daddr {gateway} tcp dport {proxy_port} accept\n\ add rule ip puzzlepod_filter output ip daddr {gateway} udp dport 53 accept\n\ + add rule ip puzzlepod_filter output ip daddr {gateway} tcp dport 53 accept\n\ add rule ip puzzlepod_filter output ct state established,related accept\n\🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-init/src/main.rs` around lines 639 - 641, The current egress nft rule that permits DNS only for UDP ("add rule ip puzzlepod_filter output ip daddr {gateway} udp dport 53 accept") will drop TCP-based DNS fallbacks; add a corresponding rule to allow TCP port 53 as well (i.e., add a line permitting "tcp dport 53") or modify the rule set to accept both protocols so that functions generating DNS queries in main.rs (the lines adding rules to puzzlepod_filter) permit TCP DNS traffic in addition to UDP.crates/puzzled-types/src/branch.rs (1)
68-78: The..check is redundant given the character validation.Since line 68 already rejects any character that is not alphanumeric,
-, or_, and.(dot) is none of those, thecontains("..")check on line 75 can never trigger. The character validation will already reject any string containing dots before reaching this point.This is harmless but could be removed for clarity, or the logic could be reordered if you want to provide a more specific error message for path traversal attempts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/branch.rs` around lines 68 - 78, The contains("..") check is redundant because the character loop in the BranchId validation already rejects '.'; remove the if s.contains("..") { return Err("BranchId must not contain '..'".to_string()); } block from the BranchId validation function (the impl/constructor that does the character validation and returns Ok(Self(s))) so the code relies on the existing alphanumeric/'-/'_' check, or alternatively move the ".." check before the character loop if you prefer a specific path-traversal error message.crates/puzzled-types/src/credential.rs (1)
113-124: Inconsistent visibility on default helper functions.
default_true_valispub(crate)while the other default functions (default_ttl,default_swap_headers,default_max_credential_size) are private. Ifdefault_true_valneeds crate-wide access, consider documenting why; otherwise, make it private for consistency.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/credential.rs` around lines 113 - 124, The helper function default_true_val has broader visibility (pub(crate)) while the other defaults (default_ttl, default_swap_headers, default_max_credential_size) are private; make default_true_val private for consistency by removing pub(crate) unless crate-wide access is required—if it is required, add a short doc comment explaining why default_true_val must be pub(crate) and keep the other helpers private. Locate the functions default_true_val, default_ttl, default_swap_headers, and default_max_credential_size in credential.rs and apply the visibility change or add the explanatory doc comment accordingly.crates/puzzled/src/branch/commit_flow.rs (1)
734-750: Blockingthread::sleepin synchronousapply_fail_mode.The
FailSafeStateverification loop on lines 734-750 usesstd::thread::sleepin a tight polling loop. While the comment acknowledges this limitation (async not feasible here), consider whether this could block the thread pool during high-contention scenarios. The 10ms sleep mitigates CPU burn, but the total 3s wait could still be problematic.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled/src/branch/commit_flow.rs` around lines 734 - 750, The loop in apply_fail_mode that polls procs_path inside FailSafeState blocks the current thread with repeated std::thread::sleep calls for up to 3s; move that blocking work off the caller thread by spawning a dedicated background thread (e.g., spawn a thread named wait_for_cgroup_removal) which performs the same read_to_string/polling until deadline, logs the error with branch_id_clone if the timeout elapses, and then exits so the apply_fail_mode path doesn't block the caller or a thread-pool worker; keep references to procs_path, deadline, and branch_id_clone so the new thread can perform the same checks and logging.crates/puzzled-types/src/provenance.rs (1)
66-74: Reuse typed policy data in provenance.
Governanceflattens decision and violation data down toString/Vec<String>, which loses structure the rest ofpuzzled-typesalready models and makes this schema easier to drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzled-types/src/provenance.rs` around lines 66 - 74, The Governance variant is flattening rich policy data into plain strings which loses type safety; replace the String/Vec<String> fields with the existing typed policy models from puzzled_types::policy (e.g. change decision_id: String -> decision: policy::Decision or policy::DecisionId, change_ids: Vec<String> -> changes: Vec<policy::Change> or Vec<policy::ChangeId>, policy_version: String -> policy_version: policy::PolicyVersion, result: String -> result: policy::DecisionResult, violations: Vec<String> -> violations: Vec<policy::Violation>), import those types at top of provenance.rs, update serde attributes if needed (flatten/rename/skip_serializing_if) and adjust any code using Governance to the new field names/types.crates/puzzlectl/src/cli.rs (1)
570-591: Consider adding mutual exclusivity for--from-envand--from-file.The
Addcommand allows--from-envand--from-filewithoutconflicts_with. If a user provides both, the behavior is ambiguous. Consider making them mutually exclusive:/// Read value from environment variable instead of stdin - #[arg(long)] + #[arg(long, conflicts_with = "from_file")] from_env: Option<String>, /// Read value from file instead of stdin #[arg(long)] from_file: Option<String>,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzlectl/src/cli.rs` around lines 570 - 591, The Add command accepts both from_env and from_file which must be mutually exclusive; update the argument definitions for the Add variant so that the --from-env (from_env) and --from-file (from_file) args declare a conflict with each other (e.g., use clap's conflicts_with/conflicts_with_all attributes or the #[arg(conflicts_with = "from_file")] / #[arg(conflicts_with = "from_env")] on the from_env/from_file fields) so the CLI prevents supplying both simultaneously and surfaces a clear error to the user.crates/puzzle-proxy/src/handler/credentials.rs (3)
779-794: DRY:BearerHeaderandBasicHeaderbranches are identical.These two match arms have exactly the same implementation. Consider combining them:
- InjectionMethod::BearerHeader => { - // N4: credential value is briefly held as plain String in headers vec - headers[phantom_header_idx] = ( - "authorization".to_string(), - (*resolved.auth_header_value).clone(), - ); - Some(resolved.auth_header_value.clone()) - } - InjectionMethod::BasicHeader => { - // N4: credential value is briefly held as plain String in headers vec - headers[phantom_header_idx] = ( - "authorization".to_string(), - (*resolved.auth_header_value).clone(), - ); - Some(resolved.auth_header_value.clone()) - } + InjectionMethod::BearerHeader | InjectionMethod::BasicHeader => { + // N4: credential value is briefly held as plain String in headers vec + headers[phantom_header_idx] = ( + "authorization".to_string(), + (*resolved.auth_header_value).clone(), + ); + Some(resolved.auth_header_value.clone()) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 779 - 794, The two match arms InjectionMethod::BearerHeader and InjectionMethod::BasicHeader are identical; collapse them into a single arm (e.g. InjectionMethod::BearerHeader | InjectionMethod::BasicHeader) to remove duplication while preserving behavior: assign headers[phantom_header_idx] = ("authorization".to_string(), (*resolved.auth_header_value).clone()) and return Some(resolved.auth_header_value.clone())—keeping the same use of headers, phantom_header_idx, and resolved.auth_header_value.
717-732: Inconsistent length guards beforewindows()calls.The
cred_html_entitycheck includesscan_bytes.len() >= cred_html_entity.len()(line 729) but thecred_base64andcred_url_encodedchecks don't have equivalent guards (lines 722-727). Whilewindows()safely returns an empty iterator when the window size exceeds slice length, the inconsistency suggests this behavior may not be intentional. Consider either adding length checks for all variants for clarity, or removing the redundant check on line 729.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 717 - 732, The leaked detection uses windows() inconsistently: add explicit length guards for the cred_base64 and cred_url_encoded checks to match the cred_html_entity guard so all branches first verify scan_bytes.len() >= pattern.len() before calling windows(); specifically, update the expressions that compare windows against cred_base64.as_bytes() and cred_url_encoded.as_bytes() to include scan_bytes.len() >= cred_base64.len() and scan_bytes.len() >= cred_url_encoded.len() respectively (keeping the existing checks for cred_bytes and cred_html_entity) so the logic is consistent and clear while still using windows().
86-117: DRY: Domain and profile validation logic is duplicated.The domain scope validation (lines 86-117) and profile validation (lines 119-150) are nearly identical between the Authorization header path and the custom header path (lines 272-324). Consider extracting a helper function to reduce duplication and ensure consistent behavior.
♻️ Suggested refactor
fn validate_credential_scope( resolved: &crate::credentials::ResolvedCredential, host: Option<&str>, profile: Option<&str>, branch_id: &BranchId, audit_sender: Option<&tokio::sync::mpsc::Sender<crate::ProxyAuditEvent>>, ) -> Result<(), Response<BoxBody>> { // Domain validation let domain_ok = match host { Some(h) => resolved.target_domains.iter() .any(|d| crate::credentials::domain_matches(h, d)), None => false, }; if !domain_ok { // ... emit audit and return 403 } // Profile validation if let Some(prof) = profile { let profile_ok = resolved.allowed_profiles.iter() .any(|p| p == "*" || p == prof); if !profile_ok { // ... emit audit and return 403 } } Ok(()) }Also applies to: 272-297
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 86 - 117, Extract the repeated domain and profile validation into a helper function (e.g., validate_credential_scope) that takes &crate::credentials::ResolvedCredential, host: Option<&str>, profile: Option<&str>, branch_id: &BranchId and audit_sender: Option<&tokio::sync::mpsc::Sender<crate::ProxyAuditEvent>> and returns Result<(), Response<BoxBody>>; implement the domain check using crate::credentials::domain_matches and the profile check against resolved.allowed_profiles (treating "*" as wildcard), emit the same tracing::warn and send_audit calls (using resolved.credential_name and the same reason strings like "domain_mismatch"/"profile_mismatch") and return the identical 403 Response on failure, then replace the duplicated blocks around the Authorization header path (the code using resolved, domain_ok, profile_ok) and the custom header path with calls to this helper to ensure consistent behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/puzzle-init/src/main.rs`:
- Line 44: Add AbiVersion::V6 to the test iteration in
test_parse_rules_all_abi_versions so the new variant is exercised by the
deserializer; locate the enum variant AbiVersion::V6 and update the loop in the
test_parse_rules_all_abi_versions test to include V6 alongside V1..V5 (and any
helper that constructs the serialized form) so the deserialization assertion
runs for the V6 case as well.
In `@crates/puzzle-proxy/src/handler/credentials.rs`:
- Around line 178-214: The Authorization-path branch injects the resolved
credential and strips phantom headers but omits the URI leakage check; replicate
the same URI leakage check used in the custom-header path after the phantom
header removal and before returning (i.e. after parts/body are reconstructed and
before Request::from_parts/injected_value is returned). Specifically, call the
same routine or inline logic that scans parts.uri() for the phantom prefix (use
mgr.phantom_prefix() for the prefix) and handle detection the same way as the
custom-header path (strip or block according to that logic, logging with
branch_id); ensure this runs after inject_resolved_credential and the
headers_to_remove loop and before Ok((Request::from_parts(parts, body),
Some(injected_value))).
- Around line 533-546: The AwsSigV4 match arm currently only logs a warning and
then the function returns Ok(()) which lets requests proceed unauthenticated;
modify the InjectionMethod::AwsSigV4 arm in the credentials injection function
(the match that references InjectionMethod::AwsSigV4 and
resolved.credential_name) to return an Err describing "AwsSigV4 not implemented"
(including resolved.credential_name) so the handler fails closed, or make this
behavior configurable via a flag and return Err unless an explicit
allow-unsafe-permissive config is set; ensure you use the function's existing
error type (or propagate with anyhow::bail/Err) instead of falling through to
the final Ok(()).
In `@crates/puzzle-proxy/src/handler/intercept.rs`:
- Around line 1275-1310: The code splices result.modified_body (the
decompressed/redacted bytes) back into response_buf without honoring
resp_content_encoding, which yields an already-decoded body with original
Content-Encoding header; fix by detecting when resp_content_encoding is
Some(...) and either (a) recompress the modified bytes using the same encoding
before inserting them (and update Content-Length accordingly) or (b)
strip/update the Content-Encoding header (remove it or set to identity) and then
set the new Content-Length to modified.len(); update the header-manipulation
logic around response_buf, new_cl_line and the branch that builds
final_resp/new_resp to apply the chosen path and ensure Content-Encoding and
Content-Length remain consistent with the body you send.
- Around line 292-301: The loop that reads the request body using agent_tls
should detect an unexpected EOF (n == 0) before body.len() reaches
content_length and treat it as an error instead of breaking; update the read
loop in intercept.rs (the code using agent_tls.read, tmp_buf, body, and
content_length) to return an error (or otherwise abort processing) when a short
read occurs so the request is rejected and not forwarded or journaled, and
ensure any caller/path that would have forwarded or acknowledged uses that error
to stop further processing.
- Around line 873-912: The current logic relays any bytes from upstream during
Expect: 100-continue and then always sends the request body; change it to parse
the upstream response bytes read into continue_buf (or accumulate reads if
needed) and inspect the HTTP status code from the response status line (extract
the first token after "HTTP/... " and parse the three-digit code). Only treat
the response as a 100 Continue when that parsed code == 100: in that case relay
the bytes to agent_tls and proceed to send the body; if the parsed code is a
final status (e.g., 401, 417, etc.) write that response bytes to agent_tls,
flush, and stop further request-body transmission (return/short-circuit the
request path rather than sending the body). Keep the existing timeout behavior
(send body on timeout) and reuse upstream_tls.read / agent_tls.write_all /
agent_tls.flush symbols in the adjusted control flow.
In `@crates/puzzle-proxy/src/handler/journal.rs`:
- Around line 52-65: When rebuilding the URI after stripping the query in the
journal redaction block (the code that checks uri.query(), cred_val and builds
redacted), do not leave the original uri unchanged on redacted.parse() failure;
instead fail closed: either return an Err from the surrounding handler so
nothing with the original credentials is journaled, or replace uri with a
guaranteed-safe sanitized placeholder (for example a path-only or explicit
"[REDACTED]" URI) before proceeding. Ensure this behavior is applied where uri,
redacted, cred_val are used so any Err from redacted.parse() cannot result in
writing the original credential-bearing query string to disk.
In `@crates/puzzle-proxy/src/handler/routing.rs`:
- Around line 278-295: The CONNECT host parsing in check_connect_host_match
currently uses naive split(':') which breaks bracketed IPv6 addresses (e.g.,
“[2001:db8::1]:443”) — update the code to use a bracket-aware parser or the URI
authority parser instead: replace the connect_target/connect_host extraction and
the Host header port-stripping logic (variables connect_target, connect_host,
header_host) with a helper that, if the string starts with '[', finds the
matching ']' and takes the bracketed content as the host, otherwise splits on
the last ':' (or split_once from the right) to separate host and port; use that
helper in both places (and in the analogous code referenced later) so IPv6
addresses are preserved correctly for CONNECT validation and downstream checks.
In `@crates/puzzlectl/src/commands/branch.rs`:
- Around line 126-149: The seccomp and landlock match arms currently print raw
paths/content; update BranchAction::SeccompRules and BranchAction::LandlockRules
to respect the output format flag by emitting machine-parseable JSON like the
other actions: after validate_branch_id(&id) and calling
client.generate_landlock_rules(&id) / client.generate_seccomp_rules(&id), check
the output format (the format variable) and when JSON is requested serialize an
object (e.g. {"path": path} or {"content": content}) via
serde_json::to_string/print, otherwise preserve the existing "path" and
raw-content behaviors; ensure you reuse the same pattern and helpers used by
other branches to keep output consistent.
In `@crates/puzzlectl/src/commands/run.rs`:
- Around line 257-259: The interactive diff path currently prints plain text via
print_diff_text(&diff_json) inside Decision::ShowDiff, which breaks machine JSON
output when --output=json is used; update this branch to respect the CLI output
mode (the --output/json flag) by not writing human-readable text to stdout when
JSON output is requested—either skip print_diff_text and attach the diff into
the final JSON result (e.g., include diff_json in the emitted JSON structure) or
send interactive prompts to stderr only; ensure Decision::ShowDiff checks the
output flag before calling print_diff_text so stdout remains valid JSON.
- Around line 30-34: The code currently masks parse errors by using
parse_json_field(&info, "state").unwrap_or_else(|_| "unknown".into()), causing
malformed inspect_branch payloads to be treated as a valid "unknown" state;
change this to fail fast by propagating the parse error instead of
defaulting—replace the unwrap_or_else usage with error propagation (e.g., using
? or map_err to add context) so that parse_json_field(&info, "state") returns a
Result that is handled and an Err is returned from the surrounding function (the
run command) when parsing fails; refer to client.inspect_branch,
parse_json_field, the state variable, and branch_id to locate the change.
In `@crates/puzzled-types/src/credential.rs`:
- Around line 26-47: The credential_mappings function currently emits tuples
with an empty env_var when a CredentialSpec has only File exposures; update
credential_mappings to skip specs that lack a CredentialExposure::Env instead of
using unwrap_or_default. In practice, inside credential_mappings iterate over
self.secrets, attempt to find the Env variant on spec.expose
(CredentialExposure::Env { var, .. }) and if none is found continue to the next
spec; for specs that do have an Env, push (domain.clone(), spec.name.clone(),
var.clone(), spec.required) for each domain. This change affects the
credential_mappings method and references CredentialExposure::Env, spec.expose,
spec.domains, spec.name and spec.required.
---
Duplicate comments:
In `@crates/puzzle-proxy/src/handler/intercept.rs`:
- Around line 351-548: When credential_mode ==
puzzled_types::CredentialMode::Phantom and phantom_token_manager is None,
fail-closed by stripping any credential-bearing headers (Authorization,
Proxy-Authorization, X-Api-Key) and logging/warning instead of letting them flow
upstream; update the branch that currently only executes when let Some(ref ptm)
= phantom_token_manager to include an else case that calls headers.retain(...)
to remove those header names (use the same case-insensitive checks as the
existing retain), emit a tracing::warn!(branch = %branch_id, ...) about failing
closed due to missing phantom_token_manager, and optionally return an error
response to the agent via agent_tls if you prefer explicit rejection—make the
change adjacent to the existing credential_mode / phantom_token_manager handling
so it integrates with inject_header_idx, resolved_idx and the subsequent logic.
In `@crates/puzzle-proxy/src/handler/mod.rs`:
- Around line 533-544: The current check blocks any request with a
Content-Encoding header even when it's "identity"; change the condition so we
only reject when Content-Encoding exists AND its value is not "identity"
(case-insensitive). Locate the branch using dlp_engine.is_some() and
req.headers().get(hyper::header::CONTENT_ENCODING) (the block that logs with
branch = %branch_id) and update it to read the header value (via to_str or
header.as_bytes()) and compare against "identity" before returning FORBIDDEN;
leave requests with Content-Encoding: identity untouched.
In `@crates/puzzlectl/src/commands/branch.rs`:
- Around line 103-123: The --no-notif path currently swallows read/parse/write
errors in the block that reads path, parses JSON into profile, removes listener
fields and SCMP_ACT_NOTIFY rules, and writes back; change this so any failure
(std::fs::read_to_string, serde_json::from_str, serde_json::to_string_pretty, or
std::fs::write) makes the command return an Err (or propagate the error) instead
of silently succeeding. Concretely, in the block around read_to_string(&path),
serde_json::from_str::<serde_json::Value>(&content),
serde_json::to_string_pretty(&profile).unwrap_or(content) and
std::fs::write(&path).ok(), replace the unwrap_or/.ok() swallowing with proper
error handling (use ? or map_err with a descriptive message referencing path and
operation) and propagate the error from the function that implements the
--no-notif behavior so the command fails when stripping fails.
In `@crates/puzzlectl/src/commands/credential.rs`:
- Around line 248-249: The code currently builds enc_path using
join(format!("{}.enc", name)) which allows names like "../foo" or absolute paths
to escape secrets_dir; validate and sanitize name before joining by ensuring it
is a plain filename (no path separators, no components like "..", and not an
absolute path) — e.g., check that Path::new(name).components() contains exactly
a single Normal component or use file_name() to extract a basename and reject or
error on invalid inputs, optionally restricting to a safe charset (alphanumeric,
'-', '_'); then use that safe_name in secrets_dir.join(format!("{}.enc",
safe_name)) and return an error if validation fails.
In `@crates/puzzlectl/src/commands/policy.rs`:
- Around line 64-75: The Rego test input currently built in the `input` variable
omits fields present on `FileChange` causing schema drift; update the mapping
inside the `changes.iter().map(|c| { ... })` block to include `old_mode` (from
`c.old_mode`), `entropy` (from `c.entropy`), and `has_base64_blocks` (from
`c.has_base64_blocks`) alongside the existing `path`, `kind`, `size`,
`checksum`, `target`, and `new_mode` so the JSON matches the `FileChange` struct
used by daemon-side evaluation.
- Around line 117-120: escape_rego_string currently only handles backslashes and
double-quotes, so newline, tab, carriage return and other control characters in
user data can break emitted Rego; update escape_rego_string to produce a valid
Rego double-quoted string literal by escaping all control characters (e.g. \n,
\r, \t, \b, \f), Unicode-escaping other non-printable codepoints as \uXXXX, and
preserving/backslash-escaping backslash and quote characters so that any s: &str
becomes safe for interpolation into rule text; locate and modify the
escape_rego_string function to implement a full string-literal encoder (or call
a library encoder) ensuring returned String is valid Rego syntax.
In `@crates/puzzlectl/src/commands/profile.rs`:
- Around line 18-21: The directory scan currently only matches ".yaml" files and
should also accept ".yml"; update the file-extension checks used in the profile
directory reader (the loop that calls std::fs::read_dir and checks
path.extension()), plus the other similar checks around the same module (the
checks at the locations corresponding to lines 84-85 and 347-350 in this diff)
to treat both "yaml" and "yml" as valid extensions—implement this by converting
extension to string (as done with and_then(|e| e.to_str())) and comparing
against a set or ORing comparisons for Some("yaml") or Some("yml") so bare-name
resolution includes foo.yml.
- Around line 112-139: The cmd_profile_validate function currently only
deserializes into AgentProfile and runs resource_limits.validate(); add an
explicit JSON Schema check after parsing to catch schema drift by converting the
parsed YAML to JSON (or re-parsing with serde_json) and validating it against
the canonical agent profile schema exposed by the puzzled-types crate (e.g.,
reference a schema constant or function in puzzled_types such as
AGENT_PROFILE_SCHEMA or a get_agent_profile_schema() helper), and if the
validator returns errors, fail with a clear message similar to the existing
resource_limits error handling; keep existing checks (profile.name,
resource_limits.validate()) but run the schema validator before printing
"Profile ... is valid".
- Around line 378-412: The helper currently checks only filesystem.denylist and
write_allowlist; update the matching logic in the profile test helper (the block
that iterates profile.filesystem.denylist and the subsequent write allowlist
check) to also enforce profile.filesystem.read_denylist and
profile.filesystem.write_denylist using Path::starts_with() for component-aware
matching; specifically, treat read_denylist entries as denying any read-like
changes (e.g., when change.kind is a read access) and treat write_denylist like
denylist for Added/Modified kinds, and set blocked and reason consistently
(e.g., "matches read_denylist pattern ..." or "matches write_denylist pattern
...") so the helper's behavior matches the daemon's denylist/write_denylist
rules.
In `@crates/puzzlectl/src/commands/run.rs`:
- Around line 203-206: The code treats a malformed diff JSON as zero changes by
using unwrap_or(0); instead, propagate a parsing error so malformed payloads
don't trigger a silent rollback. Replace the unwrap_or(0) on the
serde_json::from_str call (where diff_json is set from client.diff_branch and
assigned to change_count) with proper error propagation (e.g., use ? or map_err
to return/throw a parsing error with context) so the caller sees a failure
instead of treating the diff as empty.
- Around line 171-175: After create_branch succeeds but
client.activate_branch(&branch_id, &command_json).await? may fail, ensure you
roll back the created branch: wrap the activate_branch call to catch errors,
call client.delete_branch(&branch_id).await (or the appropriate branch-deletion
method) to remove the newly created branch, log the rollback (e.g., when
OutputFormat::Text print a message), and then return the original activation
error; reference the created branch_id, client.activate_branch, and the
create_branch result so the deletion targets the correct branch and does not
swallow the activation error.
- Around line 90-97: The JSON branch currently only checks for
result.get("Rejected") and lets messages with a policy_result.Error fall through
as successful; update the check that parses commit_json (the
serde_json::from_str::<serde_json::Value> block that inspects
v.get("policy_result")) to also treat result.get("Error").is_some() as a
rejection by setting rejected = true. Likewise, in the plain-text branch that
inspects the commit output (the code that looks for "Rejected" in the textual
policy_result), also detect "Error" (e.g., "Error:" or "policy_result: Error")
and set rejected = true so any governance Error causes a rollback. Ensure you
only change the policy_result checks (commit_json / policy_result handling) and
mirror the same logic in the text parsing branch.
In `@crates/puzzled-types/src/lib.rs`:
- Around line 145-172: In verify_merkle_inclusion add explicit validation before
calling compute_root_from_inclusion: ensure proof.tree_size > 0 and
proof.leaf_index < proof.tree_size and return an Err with a clear message (e.g.,
"invalid inclusion proof: tree_size must be > 0" or "invalid inclusion proof:
leaf_index out of range") if these checks fail; modify the function
verify_merkle_inclusion to perform these checks (referencing proof.tree_size and
proof.leaf_index) immediately before building proof_hashes/computing the root so
malformed proofs cannot falsely succeed when compute_root_from_inclusion returns
the leaf for trivial tree sizes.
In `@crates/puzzled/src/branch/activate.rs`:
- Around line 131-145: The code currently calls
crate::sandbox::send_seccomp_ack(...) before running remaining fallible setup
(e.g., NetworkSetup::configure(...), branch-state/resource insertion, and
phantom-token provisioning), which can leave a live sandbox active on error;
move the send_seccomp_ack call so it happens only after all fallible operations
succeed and after the branch is fully recorded in self.sandboxes and any
credential/phantom-token provisioning is complete (i.e., perform
NetworkSetup::configure(...), insert branch state/resources into the store,
finish phantom-token provisioning, then call send_seccomp_ack), ensuring any
?/Err prior to the ACK cleans up without an active sandbox.
- Around line 190-225: The quarantine task is closing over
self.sandboxes.get(id) which is None at this time; instead capture the new
sandbox's cgroup path when the sandbox is created and move that into the spawned
task. Replace the lookup using self.sandboxes.get(id).map(|h|
h.cgroup_path.clone()) with a clone of the cgroup path from the newly-created
sandbox handle (e.g. the local sandbox/sandbox_handle variable) and pass that
cloned String/PathBuf into the async move closure so
CgroupManager::freeze(cg_path) runs for this branch.
- Around line 83-126: The current closure building credential_proxy silently
returns None on an invalid self.config.network.proxy_listen_addr which disables
the credential-proxy guard; instead make the activation fail-fast by propagating
an error from the activation function when proxy_listen_addr cannot be parsed.
Replace the "return None" branch inside the credential_proxy creation with logic
that returns an Err from the surrounding activation function (or otherwise stops
activation), including a descriptive error mentioning proxy_listen_addr parsing
failure and referencing the branch being activated; keep the rest of the
CredentialProxyContext construction (enabled, proxy_gateway_ip, proxy_port,
proxied_ports, global_port_range) unchanged so that validate_connect logic still
applies when parsing succeeds.
---
Nitpick comments:
In `@crates/puzzle-init/src/main.rs`:
- Around line 1012-1014: Replace the magic numbers used in the unsafe prctl call
with descriptive constants: define PR_CAP_AMBIENT and PR_CAP_AMBIENT_CLEAR_ALL
(used around the unsafe libc::prctl(47, 4, 0, 0, 0) invocation) and use those
constants in the call to improve readability and maintainability; ensure the
constants are documented (e.g., as const PR_CAP_AMBIENT: libc::c_int = 47; const
PR_CAP_AMBIENT_CLEAR_ALL: libc::c_int = 4) and update the prctl invocation to
use them so the existing SAFETY comment still applies.
- Around line 639-641: The current egress nft rule that permits DNS only for UDP
("add rule ip puzzlepod_filter output ip daddr {gateway} udp dport 53 accept")
will drop TCP-based DNS fallbacks; add a corresponding rule to allow TCP port 53
as well (i.e., add a line permitting "tcp dport 53") or modify the rule set to
accept both protocols so that functions generating DNS queries in main.rs (the
lines adding rules to puzzlepod_filter) permit TCP DNS traffic in addition to
UDP.
In `@crates/puzzle-proxy/src/handler/credentials.rs`:
- Around line 779-794: The two match arms InjectionMethod::BearerHeader and
InjectionMethod::BasicHeader are identical; collapse them into a single arm
(e.g. InjectionMethod::BearerHeader | InjectionMethod::BasicHeader) to remove
duplication while preserving behavior: assign headers[phantom_header_idx] =
("authorization".to_string(), (*resolved.auth_header_value).clone()) and return
Some(resolved.auth_header_value.clone())—keeping the same use of headers,
phantom_header_idx, and resolved.auth_header_value.
- Around line 717-732: The leaked detection uses windows() inconsistently: add
explicit length guards for the cred_base64 and cred_url_encoded checks to match
the cred_html_entity guard so all branches first verify scan_bytes.len() >=
pattern.len() before calling windows(); specifically, update the expressions
that compare windows against cred_base64.as_bytes() and
cred_url_encoded.as_bytes() to include scan_bytes.len() >= cred_base64.len() and
scan_bytes.len() >= cred_url_encoded.len() respectively (keeping the existing
checks for cred_bytes and cred_html_entity) so the logic is consistent and clear
while still using windows().
- Around line 86-117: Extract the repeated domain and profile validation into a
helper function (e.g., validate_credential_scope) that takes
&crate::credentials::ResolvedCredential, host: Option<&str>, profile:
Option<&str>, branch_id: &BranchId and audit_sender:
Option<&tokio::sync::mpsc::Sender<crate::ProxyAuditEvent>> and returns
Result<(), Response<BoxBody>>; implement the domain check using
crate::credentials::domain_matches and the profile check against
resolved.allowed_profiles (treating "*" as wildcard), emit the same
tracing::warn and send_audit calls (using resolved.credential_name and the same
reason strings like "domain_mismatch"/"profile_mismatch") and return the
identical 403 Response on failure, then replace the duplicated blocks around the
Authorization header path (the code using resolved, domain_ok, profile_ok) and
the custom header path with calls to this helper to ensure consistent behavior.
In `@crates/puzzlectl/src/cli.rs`:
- Around line 570-591: The Add command accepts both from_env and from_file which
must be mutually exclusive; update the argument definitions for the Add variant
so that the --from-env (from_env) and --from-file (from_file) args declare a
conflict with each other (e.g., use clap's conflicts_with/conflicts_with_all
attributes or the #[arg(conflicts_with = "from_file")] / #[arg(conflicts_with =
"from_env")] on the from_env/from_file fields) so the CLI prevents supplying
both simultaneously and surfaces a clear error to the user.
In `@crates/puzzled-types/src/branch.rs`:
- Around line 68-78: The contains("..") check is redundant because the character
loop in the BranchId validation already rejects '.'; remove the if
s.contains("..") { return Err("BranchId must not contain '..'".to_string()); }
block from the BranchId validation function (the impl/constructor that does the
character validation and returns Ok(Self(s))) so the code relies on the existing
alphanumeric/'-/'_' check, or alternatively move the ".." check before the
character loop if you prefer a specific path-traversal error message.
In `@crates/puzzled-types/src/credential.rs`:
- Around line 113-124: The helper function default_true_val has broader
visibility (pub(crate)) while the other defaults (default_ttl,
default_swap_headers, default_max_credential_size) are private; make
default_true_val private for consistency by removing pub(crate) unless
crate-wide access is required—if it is required, add a short doc comment
explaining why default_true_val must be pub(crate) and keep the other helpers
private. Locate the functions default_true_val, default_ttl,
default_swap_headers, and default_max_credential_size in credential.rs and apply
the visibility change or add the explanatory doc comment accordingly.
In `@crates/puzzled-types/src/provenance.rs`:
- Around line 66-74: The Governance variant is flattening rich policy data into
plain strings which loses type safety; replace the String/Vec<String> fields
with the existing typed policy models from puzzled_types::policy (e.g. change
decision_id: String -> decision: policy::Decision or policy::DecisionId,
change_ids: Vec<String> -> changes: Vec<policy::Change> or
Vec<policy::ChangeId>, policy_version: String -> policy_version:
policy::PolicyVersion, result: String -> result: policy::DecisionResult,
violations: Vec<String> -> violations: Vec<policy::Violation>), import those
types at top of provenance.rs, update serde attributes if needed
(flatten/rename/skip_serializing_if) and adjust any code using Governance to the
new field names/types.
In `@crates/puzzled/src/branch/commit_flow.rs`:
- Around line 734-750: The loop in apply_fail_mode that polls procs_path inside
FailSafeState blocks the current thread with repeated std::thread::sleep calls
for up to 3s; move that blocking work off the caller thread by spawning a
dedicated background thread (e.g., spawn a thread named wait_for_cgroup_removal)
which performs the same read_to_string/polling until deadline, logs the error
with branch_id_clone if the timeout elapses, and then exits so the
apply_fail_mode path doesn't block the caller or a thread-pool worker; keep
references to procs_path, deadline, and branch_id_clone so the new thread can
perform the same checks and logging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| #[default] | ||
| V4, | ||
| V5, | ||
| V6, |
There was a problem hiding this comment.
Missing test coverage for AbiVersion::V6 parsing.
The AbiVersion::V6 variant was added to the enum and deserializer, but the test test_parse_rules_all_abi_versions (lines 1203-1217) only tests V1-V5. Add V6 to the test loop to ensure deserialization coverage.
🧪 Proposed fix
for (abi_str, expected) in [
("V1", AbiVersion::V1),
("V2", AbiVersion::V2),
("V3", AbiVersion::V3),
("V4", AbiVersion::V4),
("V5", AbiVersion::V5),
+ ("V6", AbiVersion::V6),
] {Also applies to: 59-59, 1203-1217
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-init/src/main.rs` at line 44, Add AbiVersion::V6 to the test
iteration in test_parse_rules_all_abi_versions so the new variant is exercised
by the deserializer; locate the enum variant AbiVersion::V6 and update the loop
in the test_parse_rules_all_abi_versions test to include V6 alongside V1..V5
(and any helper that constructs the serialized form) so the deserialization
assertion runs for the V6 case as well.
| let injected_value = resolved.auth_header_value.clone(); | ||
| let (mut parts, body) = req.into_parts(); | ||
| // H63: fail-closed — if injection fails, block the request | ||
| // R3: generic error — do not leak internal error details | ||
| if let Err(e) = inject_resolved_credential(&mut parts, &resolved) { | ||
| tracing::error!(error = %e, "H63: credential injection failed"); | ||
| return Err(Response::builder() | ||
| .status(StatusCode::INTERNAL_SERVER_ERROR) | ||
| .body(Full::new(Bytes::from("Credential injection failed\n"))) | ||
| .unwrap()); | ||
| } | ||
|
|
||
| // M-5/G36: Dual phantom token stripping on Authorization path — | ||
| // after resolving a phantom token from the Authorization header, | ||
| // scan remaining headers for unresolved phantom tokens and strip them. | ||
| // Without this, an agent sending both Authorization: Bearer pt_puzzled_foo | ||
| // and X-Api-Key: pt_puzzled_bar would leak the X-Api-Key phantom to upstream. | ||
| let phantom_prefix = mgr.phantom_prefix(); | ||
| let headers_to_remove: Vec<hyper::header::HeaderName> = parts | ||
| .headers | ||
| .iter() | ||
| .filter(|(n, v)| { | ||
| *n != "authorization" | ||
| && v.to_str().is_ok_and(|s| s.contains(phantom_prefix)) | ||
| }) | ||
| .map(|(n, _)| n.clone()) | ||
| .collect(); | ||
| for h in &headers_to_remove { | ||
| tracing::warn!( | ||
| branch = %branch_id, | ||
| header = %h, | ||
| "M-5/G36: stripping unresolved phantom token from header (Authorization path)" | ||
| ); | ||
| parts.headers.remove(h); | ||
| } | ||
|
|
||
| return Ok((Request::from_parts(parts, body), Some(injected_value))); |
There was a problem hiding this comment.
URI credential leakage check is missing in the Authorization header path.
The URI leakage check (§3.4 G34, lines 366-379) is performed after custom header injection but not after Authorization header injection (lines 178-214). If an agent includes the credential in query parameters while also using a phantom token in Authorization, the leakage would go undetected.
🛡️ Proposed fix — add URI check to Authorization path
Add the URI leakage check after line 212, before returning:
for h in &headers_to_remove {
// ... stripping logic
parts.headers.remove(h);
}
+ // §3.4 G34: URI credential leakage check (same as custom header path)
+ if let Some(query) = parts.uri.query() {
+ if query.contains(&*injected_value) {
+ tracing::error!(
+ branch = %branch_id,
+ "§3.4 G34: BLOCKED — real credential value found in request URI query parameters"
+ );
+ return Err(Response::builder()
+ .status(StatusCode::BAD_GATEWAY)
+ .body(Full::new(Bytes::from(
+ "Credential leakage in URI detected\n",
+ )))
+ .unwrap());
+ }
+ }
+
return Ok((Request::from_parts(parts, body), Some(injected_value)));Also applies to: 364-379
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 178 - 214, The
Authorization-path branch injects the resolved credential and strips phantom
headers but omits the URI leakage check; replicate the same URI leakage check
used in the custom-header path after the phantom header removal and before
returning (i.e. after parts/body are reconstructed and before
Request::from_parts/injected_value is returned). Specifically, call the same
routine or inline logic that scans parts.uri() for the phantom prefix (use
mgr.phantom_prefix() for the prefix) and handle detection the same way as the
custom-header path (strip or block according to that logic, logging with
branch_id); ensure this runs after inject_resolved_credential and the
headers_to_remove loop and before Ok((Request::from_parts(parts, body),
Some(injected_value))).
| InjectionMethod::AwsSigV4 => { | ||
| // §3.4 C4: AWS SigV4 request signing stub. | ||
| // Full SigV4 signing requires computing a canonical request hash, string-to-sign, | ||
| // and HMAC-SHA256 signature using the credential's access key and secret key. | ||
| // Do NOT inject the raw secret key — that would leak it in cleartext. | ||
| // Phase 2: Implement proper SigV4 signing with the aws-sigv4 crate. | ||
| tracing::warn!( | ||
| credential = %resolved.credential_name, | ||
| "§3.4: AWS SigV4 signing not yet implemented — request will not be authenticated (requires aws-sigv4 crate)" | ||
| ); | ||
| } | ||
| } | ||
| Ok(()) // H63: explicit success return | ||
| } |
There was a problem hiding this comment.
AwsSigV4 stub silently succeeds without authentication.
The function returns Ok(()) for AwsSigV4 injection (line 545), which means requests proceed without actual authentication. While there's a warning log, this could lead to silent failures where requests are forwarded unauthenticated. Consider returning an error to enforce fail-closed behavior until the feature is implemented, or make this configurable.
🛡️ Proposed fix — fail-closed for unimplemented AwsSigV4
InjectionMethod::AwsSigV4 => {
// §3.4 C4: AWS SigV4 request signing stub.
- // Full SigV4 signing requires computing a canonical request hash, string-to-sign,
- // and HMAC-SHA256 signature using the credential's access key and secret key.
- // Do NOT inject the raw secret key — that would leak it in cleartext.
- // Phase 2: Implement proper SigV4 signing with the aws-sigv4 crate.
- tracing::warn!(
+ // H63: Fail-closed — AWS SigV4 not yet implemented
+ tracing::error!(
credential = %resolved.credential_name,
- "§3.4: AWS SigV4 signing not yet implemented — request will not be authenticated (requires aws-sigv4 crate)"
+ "H63/§3.4: AWS SigV4 signing not yet implemented — blocking request (fail-closed)"
);
+ return Err(
+ "§3.4: AWS SigV4 signing not yet implemented — cannot proceed without authentication".to_string()
+ );
}
}
Ok(()) // H63: explicit success return📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| InjectionMethod::AwsSigV4 => { | |
| // §3.4 C4: AWS SigV4 request signing stub. | |
| // Full SigV4 signing requires computing a canonical request hash, string-to-sign, | |
| // and HMAC-SHA256 signature using the credential's access key and secret key. | |
| // Do NOT inject the raw secret key — that would leak it in cleartext. | |
| // Phase 2: Implement proper SigV4 signing with the aws-sigv4 crate. | |
| tracing::warn!( | |
| credential = %resolved.credential_name, | |
| "§3.4: AWS SigV4 signing not yet implemented — request will not be authenticated (requires aws-sigv4 crate)" | |
| ); | |
| } | |
| } | |
| Ok(()) // H63: explicit success return | |
| } | |
| InjectionMethod::AwsSigV4 => { | |
| // H63: Fail-closed — AWS SigV4 not yet implemented | |
| tracing::error!( | |
| credential = %resolved.credential_name, | |
| "H63/§3.4: AWS SigV4 signing not yet implemented — blocking request (fail-closed)" | |
| ); | |
| return Err( | |
| "§3.4: AWS SigV4 signing not yet implemented — cannot proceed without authentication".to_string() | |
| ); | |
| } | |
| } | |
| Ok(()) // H63: explicit success return | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/credentials.rs` around lines 533 - 546, The
AwsSigV4 match arm currently only logs a warning and then the function returns
Ok(()) which lets requests proceed unauthenticated; modify the
InjectionMethod::AwsSigV4 arm in the credentials injection function (the match
that references InjectionMethod::AwsSigV4 and resolved.credential_name) to
return an Err describing "AwsSigV4 not implemented" (including
resolved.credential_name) so the handler fails closed, or make this behavior
configurable via a flag and return Err unless an explicit
allow-unsafe-permissive config is set; ensure you use the function's existing
error type (or propagate with anyhow::bail/Err) instead of falling through to
the final Ok(()).
| while body.len() < content_length { | ||
| let n = agent_tls | ||
| .read(&mut tmp_buf) | ||
| .await | ||
| .map_err(|e| format!("reading request body from agent: {}", e))?; | ||
| if n == 0 { | ||
| break; | ||
| } | ||
| body.extend_from_slice(&tmp_buf[..n]); | ||
| } |
There was a problem hiding this comment.
Reject short request bodies instead of forwarding or journaling them.
Breaking on EOF here leaves the original Content-Length in place while body is truncated. In the forward path that can hang the upstream waiting for bytes; in the side-effect path it can journal and acknowledge a partial mutation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/intercept.rs` around lines 292 - 301, The
loop that reads the request body using agent_tls should detect an unexpected EOF
(n == 0) before body.len() reaches content_length and treat it as an error
instead of breaking; update the read loop in intercept.rs (the code using
agent_tls.read, tmp_buf, body, and content_length) to return an error (or
otherwise abort processing) when a short read occurs so the request is rejected
and not forwarded or journaled, and ensure any caller/path that would have
forwarded or acknowledged uses that error to stop further processing.
| if has_expect_continue && !body.is_empty() { | ||
| use tokio::io::AsyncReadExt; | ||
| let mut continue_buf = [0u8; 256]; | ||
| match tokio::time::timeout( | ||
| Duration::from_secs(5), | ||
| upstream_tls.read(&mut continue_buf), | ||
| ) | ||
| .await | ||
| { | ||
| Ok(Ok(n)) if n > 0 => { | ||
| // Relay the 100 Continue (or other response) to agent | ||
| agent_tls | ||
| .write_all(&continue_buf[..n]) | ||
| .await | ||
| .map_err(|e| format!("relaying 100-continue to agent: {}", e))?; | ||
| agent_tls | ||
| .flush() | ||
| .await | ||
| .map_err(|e| format!("flushing 100-continue: {}", e))?; | ||
| } | ||
| Ok(Ok(_)) => { | ||
| tracing::debug!( | ||
| branch = %branch_id, | ||
| "§3.4 G26: upstream closed connection during 100-continue" | ||
| ); | ||
| } | ||
| Ok(Err(e)) => { | ||
| tracing::debug!( | ||
| branch = %branch_id, | ||
| error = %e, | ||
| "§3.4 G26: error reading 100-continue from upstream" | ||
| ); | ||
| } | ||
| Err(_) => { | ||
| tracing::debug!( | ||
| branch = %branch_id, | ||
| "§3.4 G26: 100-continue timeout — sending body anyway" | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Only continue after an actual 100 Continue.
This branch relays any bytes the upstream sends and then still transmits the request body. If the upstream replies with a final status like 401 or 417, the proxy will send the body anyway and corrupt the connection state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/intercept.rs` around lines 873 - 912, The
current logic relays any bytes from upstream during Expect: 100-continue and
then always sends the request body; change it to parse the upstream response
bytes read into continue_buf (or accumulate reads if needed) and inspect the
HTTP status code from the response status line (extract the first token after
"HTTP/... " and parse the three-digit code). Only treat the response as a 100
Continue when that parsed code == 100: in that case relay the bytes to agent_tls
and proceed to send the body; if the parsed code is a final status (e.g., 401,
417, etc.) write that response bytes to agent_tls, flush, and stop further
request-body transmission (return/short-circuit the request path rather than
sending the body). Keep the existing timeout behavior (send body on timeout) and
reuse upstream_tls.read / agent_tls.write_all / agent_tls.flush symbols in the
adjusted control flow.
| pub(super) fn check_connect_host_match( | ||
| req: &Request<hyper::body::Incoming>, | ||
| branch_id: &BranchId, | ||
| ) -> Result<(), Response<BoxBody>> { | ||
| let connect_target = req | ||
| .uri() | ||
| .authority() | ||
| .map(|a| a.to_string()) | ||
| .unwrap_or_else(|| req.uri().to_string()); | ||
|
|
||
| // Extract just the hostname from the CONNECT target (strip port) | ||
| let connect_host = connect_target.split(':').next().unwrap_or(&connect_target); | ||
|
|
||
| if let Some(host_header) = req.headers().get("host") { | ||
| if let Ok(h) = host_header.to_str() { | ||
| // Strip port from Host header for comparison | ||
| let header_host = h.split(':').next().unwrap_or(h); | ||
| if !header_host.eq_ignore_ascii_case(connect_host) { |
There was a problem hiding this comment.
Make host parsing IPv6-safe.
Both of these paths strip ports with split(':').next(), which truncates bracketed IPv6 authorities like [2001:db8::1]:443 to [2001. That feeds the wrong host into CONNECT validation and the downstream SSRF/domain checks. Use the URI authority parser or a bracket-aware helper instead.
Also applies to: 318-340
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzle-proxy/src/handler/routing.rs` around lines 278 - 295, The
CONNECT host parsing in check_connect_host_match currently uses naive split(':')
which breaks bracketed IPv6 addresses (e.g., “[2001:db8::1]:443”) — update the
code to use a bracket-aware parser or the URI authority parser instead: replace
the connect_target/connect_host extraction and the Host header port-stripping
logic (variables connect_target, connect_host, header_host) with a helper that,
if the string starts with '[', finds the matching ']' and takes the bracketed
content as the host, otherwise splits on the last ':' (or split_once from the
right) to separate host and port; use that helper in both places (and in the
analogous code referenced later) so IPv6 addresses are preserved correctly for
CONNECT validation and downstream checks.
| match format.as_str() { | ||
| "path" => println!("{path}"), | ||
| _ => { | ||
| if let Ok(content) = std::fs::read_to_string(&path) { | ||
| println!("{content}"); | ||
| } else { | ||
| println!("{path}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| crate::cli::BranchAction::LandlockRules { id, format } => { | ||
| validate_branch_id(&id)?; | ||
| let path = client.generate_landlock_rules(&id).await?; | ||
| match format.as_str() { | ||
| "path" => println!("{path}"), | ||
| _ => { | ||
| if let Ok(content) = std::fs::read_to_string(&path) { | ||
| println!("{content}"); | ||
| } else { | ||
| println!("{path}"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Respect output in the seccomp/landlock branches.
These branches print bare paths or raw rule text regardless of OutputFormat, so --output=json is not reliably machine-parseable. Wrap the emitted path/content in JSON the same way the other actions do. Based on learnings: Applies to crates/puzzlectl/src/**/*.rs : Use clap derive macros for CLI implementation; output must be machine-parseable JSON when using --output=json flag
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/branch.rs` around lines 126 - 149, The seccomp
and landlock match arms currently print raw paths/content; update
BranchAction::SeccompRules and BranchAction::LandlockRules to respect the output
format flag by emitting machine-parseable JSON like the other actions: after
validate_branch_id(&id) and calling client.generate_landlock_rules(&id) /
client.generate_seccomp_rules(&id), check the output format (the format
variable) and when JSON is requested serialize an object (e.g. {"path": path} or
{"content": content}) via serde_json::to_string/print, otherwise preserve the
existing "path" and raw-content behaviors; ensure you reuse the same pattern and
helpers used by other branches to keep output consistent.
| let info = client.inspect_branch(branch_id).await?; | ||
| let state = parse_json_field(&info, "state").unwrap_or_else(|_| "unknown".into()); | ||
| match state.as_str() { | ||
| "active" | "creating" | "ready" | "frozen" | "committing" => continue, | ||
| _ => return Ok(state), |
There was a problem hiding this comment.
Fail fast when branch state can't be parsed.
Falling back to "unknown" turns a malformed inspect_branch payload into a pseudo-terminal state, and the caller then keeps going into diff/commit instead of failing closed.
Suggested fix
- let state = parse_json_field(&info, "state").unwrap_or_else(|_| "unknown".into());
+ let state = parse_json_field(&info, "state")
+ .with_context(|| format!("parsing state for branch {branch_id}"))?;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let info = client.inspect_branch(branch_id).await?; | |
| let state = parse_json_field(&info, "state").unwrap_or_else(|_| "unknown".into()); | |
| match state.as_str() { | |
| "active" | "creating" | "ready" | "frozen" | "committing" => continue, | |
| _ => return Ok(state), | |
| let info = client.inspect_branch(branch_id).await?; | |
| let state = parse_json_field(&info, "state") | |
| .with_context(|| format!("parsing state for branch {branch_id}"))?; | |
| match state.as_str() { | |
| "active" | "creating" | "ready" | "frozen" | "committing" => continue, | |
| _ => return Ok(state), |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/run.rs` around lines 30 - 34, The code
currently masks parse errors by using parse_json_field(&info,
"state").unwrap_or_else(|_| "unknown".into()), causing malformed inspect_branch
payloads to be treated as a valid "unknown" state; change this to fail fast by
propagating the parse error instead of defaulting—replace the unwrap_or_else
usage with error propagation (e.g., using ? or map_err to add context) so that
parse_json_field(&info, "state") returns a Result that is handled and an Err is
returned from the surrounding function (the run command) when parsing fails;
refer to client.inspect_branch, parse_json_field, the state variable, and
branch_id to locate the change.
| Decision::ShowDiff => { | ||
| print_diff_text(&diff_json); | ||
| eprint!("[run] approve? [y/N]: "); |
There was a problem hiding this comment.
Keep the interactive diff path JSON-clean.
print_diff_text(&diff_json) always writes plain text to stdout here. With --output=json, that breaks the CLI contract before the final result is emitted. Based on learnings Applies to crates/puzzlectl/src/**/*.rs : Use clap derive macros for CLI implementation; output must be machine-parseable JSON when using --output=json flag.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzlectl/src/commands/run.rs` around lines 257 - 259, The interactive
diff path currently prints plain text via print_diff_text(&diff_json) inside
Decision::ShowDiff, which breaks machine JSON output when --output=json is used;
update this branch to respect the CLI output mode (the --output/json flag) by
not writing human-readable text to stdout when JSON output is requested—either
skip print_diff_text and attach the diff into the final JSON result (e.g.,
include diff_json in the emitted JSON structure) or send interactive prompts to
stderr only; ensure Decision::ShowDiff checks the output flag before calling
print_diff_text so stdout remains valid JSON.
| pub fn credential_mappings(&self) -> Vec<(String, String, String, bool)> { | ||
| let mut result = Vec::new(); | ||
| for spec in &self.secrets { | ||
| let env_var = spec | ||
| .expose | ||
| .iter() | ||
| .find_map(|e| match e { | ||
| CredentialExposure::Env { var, .. } => Some(var.clone()), | ||
| _ => None, | ||
| }) | ||
| .unwrap_or_default(); | ||
| for domain in &spec.domains { | ||
| result.push(( | ||
| domain.clone(), | ||
| spec.name.clone(), | ||
| env_var.clone(), | ||
| spec.required, | ||
| )); | ||
| } | ||
| } | ||
| result | ||
| } |
There was a problem hiding this comment.
credential_mappings() returns empty env_var for credentials with only File exposures.
When a CredentialSpec has no CredentialExposure::Env variant (only File exposures), find_map returns None and unwrap_or_default() produces an empty string. The returned tuple (domain, name, "", required) could cause downstream issues if callers expect a valid env var name for phantom token issuance.
Consider filtering out specs without Env exposures, or documenting this behavior explicitly.
Suggested approach
pub fn credential_mappings(&self) -> Vec<(String, String, String, bool)> {
let mut result = Vec::new();
for spec in &self.secrets {
- let env_var = spec
+ // Skip credentials without Env exposure — phantom tokens require an env var
+ let env_var = match spec
.expose
.iter()
.find_map(|e| match e {
CredentialExposure::Env { var, .. } => Some(var.clone()),
_ => None,
})
- .unwrap_or_default();
+ {
+ Some(var) => var,
+ None => continue, // No env exposure — not usable for phantom tokens
+ };
for domain in &spec.domains {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub fn credential_mappings(&self) -> Vec<(String, String, String, bool)> { | |
| let mut result = Vec::new(); | |
| for spec in &self.secrets { | |
| let env_var = spec | |
| .expose | |
| .iter() | |
| .find_map(|e| match e { | |
| CredentialExposure::Env { var, .. } => Some(var.clone()), | |
| _ => None, | |
| }) | |
| .unwrap_or_default(); | |
| for domain in &spec.domains { | |
| result.push(( | |
| domain.clone(), | |
| spec.name.clone(), | |
| env_var.clone(), | |
| spec.required, | |
| )); | |
| } | |
| } | |
| result | |
| } | |
| pub fn credential_mappings(&self) -> Vec<(String, String, String, bool)> { | |
| let mut result = Vec::new(); | |
| for spec in &self.secrets { | |
| // Skip credentials without Env exposure — phantom tokens require an env var | |
| let env_var = match spec | |
| .expose | |
| .iter() | |
| .find_map(|e| match e { | |
| CredentialExposure::Env { var, .. } => Some(var.clone()), | |
| _ => None, | |
| }) | |
| { | |
| Some(var) => var, | |
| None => continue, // No env exposure — not usable for phantom tokens | |
| }; | |
| for domain in &spec.domains { | |
| result.push(( | |
| domain.clone(), | |
| spec.name.clone(), | |
| env_var.clone(), | |
| spec.required, | |
| )); | |
| } | |
| } | |
| result | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/puzzled-types/src/credential.rs` around lines 26 - 47, The
credential_mappings function currently emits tuples with an empty env_var when a
CredentialSpec has only File exposures; update credential_mappings to skip specs
that lack a CredentialExposure::Env instead of using unwrap_or_default. In
practice, inside credential_mappings iterate over self.secrets, attempt to find
the Env variant on spec.expose (CredentialExposure::Env { var, .. }) and if none
is found continue to the next spec; for specs that do have an Env, push
(domain.clone(), spec.name.clone(), var.clone(), spec.required) for each domain.
This change affects the credential_mappings method and references
CredentialExposure::Env, spec.expose, spec.domains, spec.name and spec.required.
Summary
This PR combines two major efforts:
Part 1: Rigorous consistency review (6 CRITICAL, 24 MAJOR, 17 MINOR findings)
Cross-reference of PRD, technical-design.md, security-guide.md, and all implementation source code identified 47 findings. All 37 non-trivial findings are addressed.
CRITICAL fixes:
attach_btf_idresolution from kernel BTFDegradedon daemon restartdefault_for_user()Part 2: Comprehensive refactoring across 7 phases
Evaluated the entire 110K-line codebase for simplicity, correctness, idiomatic Rust, and DRY violations. Implemented fixes across all 8 crates with zero regressions (1,810 tests pass, 0 clippy errors).
Phase 1 — Foundation types:
PartialEq/Defaultderives to 18+ types inpuzzled-typesFileChange::new()constructor andDefaultimplBusType,FsType,LogLevel,DefaultAction,LogTarget,PolicyEngineType,NetworkDefaultMode)SerdeJson,SerdeYaml,WalkDirerror variants with#[from]toPuzzledErrorAuditEventRecordtype between cratesPhase 2 — Module decomposition:
puzzlectl/main.rs(4,695 lines) intocli.rs,output.rs, and 9 command modulespuzzled/dbus.rs(5,225 lines) intodbus/mod.rs,dbus/helpers.rs,dbus/tests.rsPhase 3 — DRY fixes:
backup_filename()helper (3 duplicate sites)is_denylisted/is_denylisted_strinto singleimpl AsRef<Path>functionrun_one/run_allsim wrappersVerificationCounters,verify_signature(),verify_chain_link(),check_file_size()Phase 4 — Idiomatic Rust:
std::process::exit()with proper error propagation in attestation/sim commandsvalidate_branch_idto returnBranchIdinstead of discarding itPhase 5 — Test infrastructure:
tests/common/mod.rswithmake_manager(), eliminating ~317 lines of duplicate test setup across 6 fileshandler_tests.rsre-implementing production functions with different semantics — now imports real production functions (fixed 3 semantic divergences)Phase 6 — Technical correctness:
wal.rs(exists()+remove_file()patterns)report_versionhardcoded as"1.0"instead ofCARGO_PKG_VERSIONformat_bytesprecision loss (integer → float division)puzzle-hookto prevent container runtime stallConflictDetector::new()warn!→debug!to reduce test noisePhase 7 — Cleanup:
serde_yamlto dev-dependencies inpuzzled-types_branch_idparameter fromPhantomTokenManager.flatten()with explicit error logging indiscover_profile_namesStats
Summary by CodeRabbit
New Features
Bug Fixes & Improvements
CLI Enhancements