Skip to content

Consistency review fixes + comprehensive refactoring (simplicity, correctness, DRY, idiomatic Rust)#13

Merged
maxamillion merged 1 commit into
mainfrom
refactor
Mar 31, 2026
Merged

Consistency review fixes + comprehensive refactoring (simplicity, correctness, DRY, idiomatic Rust)#13
maxamillion merged 1 commit into
mainfrom
refactor

Conversation

@maxamillion

@maxamillion maxamillion commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

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:

  • C1: BPF LSM attach_btf_id resolution from kernel BTF
  • C2: Document connect/bind sockaddr TOCTOU as residual risk
  • C3: Add 2s per-notification EPERM deadline to seccomp handler
  • C4: Restore branches as Degraded on daemon restart
  • C5: Fail-closed on symlink escape in commit path
  • C6: Fix rootless config panic in default_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:

  • 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] to PuzzledError
  • Unify duplicated AuditEventRecord type between crates
  • Replace glob re-exports with explicit named exports for 50+ types

Phase 2 — Module decomposition:

  • Split puzzlectl/main.rs (4,695 lines) into cli.rs, output.rs, and 9 command modules
  • Split puzzled/dbus.rs (5,225 lines) into dbus/mod.rs, dbus/helpers.rs, dbus/tests.rs

Phase 3 — DRY fixes:

  • Extract WAL backup_filename() helper (3 duplicate sites)
  • Unify is_denylisted/is_denylisted_str into single impl AsRef<Path> function
  • Remove dead run_one/run_all sim wrappers
  • Deduplicate attestation verification with shared VerificationCounters, verify_signature(), verify_chain_link(), check_file_size()

Phase 4 — Idiomatic Rust:

  • Replace std::process::exit() with proper error propagation in attestation/sim commands
  • Change validate_branch_id to return BranchId instead of discarding it

Phase 5 — Test infrastructure:

  • Create shared tests/common/mod.rs with make_manager(), eliminating ~317 lines of duplicate test setup across 6 files
  • Fix puzzle-proxy handler_tests.rs re-implementing production functions with different semantics — now imports real production functions (fixed 3 semantic divergences)

Phase 6 — Technical correctness:

  • Fix 6 TOCTOU race conditions in wal.rs (exists() + remove_file() patterns)
  • Fix report_version hardcoded as "1.0" instead of CARGO_PKG_VERSION
  • Fix format_bytes precision loss (integer → float division)
  • Add 30s D-Bus call timeout in puzzle-hook to prevent container runtime stall
  • Change ConflictDetector::new() warn!debug! to reduce test noise

Phase 7 — Cleanup:

  • Move serde_yaml to dev-dependencies in puzzled-types
  • Remove unused _branch_id parameter from PhantomTokenManager
  • Replace .flatten() with explicit error logging in discover_profile_names

Stats

  • 55 files changed, 5,867 insertions, 5,972 deletions
  • 1,810 tests pass, 0 failures, 0 clippy errors
  • Zero regressions — every existing test continues to pass

Summary by CodeRabbit

  • New Features

    • Phantom-token credential injection with leakage blocking; DLP inspection (block/quarantine/redact) for requests/responses
    • CONNECT tunneling, TLS MITM interception, and transparent proxying; journaling of side-effect requests for replay
    • Landlock network allowlists for TCP connect/bind ports; governance review and attestation/compliance tooling
  • Bug Fixes & Improvements

    • D-Bus and proxy timeouts (30s+); seccomp handler wall-clock enforcement and improved SSRF/DNS-rebinding protections
    • Rootless OverlayFS support, stronger sandbox cleanup/recovery, and stricter fail-closed behaviors
  • CLI Enhancements

    • Restructured puzzlectl with organized subcommands, unified output helpers, and safer credential stdin handling

@coderabbitai

coderabbitai Bot commented Mar 31, 2026

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Proxy core & handlers
crates/puzzle-proxy/src/lib.rs, crates/puzzle-proxy/src/handler/..., crates/puzzle-proxy/src/secure_memory.rs
New central proxy entry, per-connection context, and many handler modules (forward, connect, tls, intercept, transparent); secure-memory SAFETY docs added.
Credential injection & leakage protection
crates/puzzle-proxy/src/handler/credentials.rs, crates/puzzle-proxy/src/handler/journal.rs
Phantom-token resolution/injection (header/URI/AWS modes), defense-in-depth header stripping, response scanning for echoed secrets, journaling of side-effect requests with redaction and size/DLP guards.
DLP & decompression utilities
crates/puzzle-proxy/src/handler/dlp_inspect.rs, crates/puzzle-proxy/src/handler/util.rs
Response/body decompression for scanning, DLP decision branching (quarantine/block/redact/log), chunked-body decoder, header validation helpers, audit enqueue helper.
Routing, SSRF & safety
crates/puzzle-proxy/src/handler/routing.rs, crates/puzzle-proxy/src/handler/mod.rs
DNS rebinding, private/reserved-IP checks, SSRF guards, port allowlisting, hop-by-hop header stripping, request routing modes and policy wiring.
Forwarding & CONNECT tunneling
crates/puzzle-proxy/src/handler/forward.rs, crates/puzzle-proxy/src/handler/connect.rs
HTTP/1.1 forwarding (pinned IP option), connect_upstream helper, CONNECT handler with upgrade, bidirectional relays, byte limits and timeouts.
TLS MITM & transparent interception
crates/puzzle-proxy/src/handler/tls.rs, crates/puzzle-proxy/src/handler/intercept.rs, crates/puzzle-proxy/src/handler/transparent.rs
MITM handshake, leaf cert issuance, intercepted-stream HTTP/1.1 pipeline with request/body parsing, credential injection modes, DLP on both request/response, and transparent SNI-based intercept.
puzzled-types modularization & new types
crates/puzzled-types/src/*
Split lib into modules and re-exports; added attestation, audit, behavioral, branch, change (FileChange/FileChangeKind), credential, identity, policy, profile, provenance, trust types.
FileChange content inspection
crates/puzzled/src/diff.rs, tests/benches, consumers
Added entropy and has_base64_blocks fields; content inspection helpers and propagation across tests/benchmarks/consumers.
Branch manager, commit flow & WAL
crates/puzzled/src/branch/*, crates/puzzled/src/commit.rs, crates/puzzled/src/policy.rs
New BranchManager, commit/freeze/rollback/governance flows, WAL writer API, policy snapshot evaluation, conflict detection/reservation, recovery, and many tests.
Sandbox / enforcement (Linux)
crates/puzzled/src/sandbox/*
BPF-LSM degraded fallback, seccomp notification wall-clock deadline, expanded deny lists, fanotify degraded mode, cgroup v2 helpers, quota failure handling, rootless overlay via fuse-overlayfs, Landlock network ABI/port gating.
Daemon, profiles & config
crates/puzzled/src/main.rs, crates/puzzled/src/config.rs, crates/puzzled/src/profile.rs
Attestation gating at startup, state-load order change, enforcement summary logs, advisory JSON schema validation for profiles, removal of hot-reload, user-mode credential-proxy defaults.
CLI & commands (puzzlectl)
crates/puzzlectl/src/{cli.rs,lib.rs,output.rs,commands/*,run.rs,...}
New clap CLI, centralized output helpers, credential stdin helper, many command modules (branch/agent/profile/policy/audit/attestation/compliance/credential/status/run) and attestation/merkle tooling.
D-Bus timeout and small deps
crates/puzzle-hook/src/main.rs, crates/puzzle-hook/Cargo.toml, crates/puzzle-init/Cargo.toml
Added 30s timeout wrapper around async D-Bus calls, enabled tokio time feature and added tracing for Linux-only builds.
Misc tests & infra
crates/puzzled/src/branch/tests.rs, crates/puzzle-proxy/tests/handler_tests.rs, .cursor/.gitignore
Extensive new/updated tests for branch manager and proxy; tests updated to use production routing helpers; .cursor/plans ignored.
Other small refactors
assorted files (credentials signature tweak, secure comments, Cargo edits)
Phantom token signature change, added SAFETY comments, dependency manifest tweaks (jsonschema, serde_yaml dev-dep), and many test adjustments.

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
Loading
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
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

"I hopped through code and stitched each shard—
My whiskers twitched at proxy guards,
Landlocks, DLP, branches sworn,
New types sprouted, tests reborn.
A carrot cheer for this deploy—hop, hop, hooray! 🥕"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Mode is not restored after execute completes.

The comment on line 342 says "Switching temporarily" to sandbox mode, but saved_mode captured at line 340 is never used to restore the original mode after execute completes. If the user was in Direct mode, they will remain in Sandbox mode after running execute.

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 | 🟡 Minor

Update stale assertion messages to match new split module paths.

The tests now read handler/credentials.rs, handler/mod.rs, and handler/forward.rs, but several expect(...) / assertion texts still reference handler.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 | 🟠 Major

Verify the manifest before writing it out.

verify_manifest() now runs only after persist() 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 | 🟠 Major

Validate read_denylist and write_denylist paths 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 | 🟠 Major

Close the remaining symlink check/use race.

Lines 157-170 validate the current symlink target, but Line 232 opens from again 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 test is still dropping metadata the daemon evaluates.

The comment says old_mode is included, but the JSON omits it; it also omits the new entropy and has_base64_blocks fields. 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 | 🟠 Major

Watcher 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 | 🟡 Minor

Use approved comment prefixes instead of WS9.

Line 4-Line 14 and Line 41-Line 50 introduce WS9 tags, but this repo’s allowed source-comment prefixes are H/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 exceeded

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: 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 | 🟡 Minor

Add missing skip_serializing_if attribute to branch_id.

The branch_id field in AuditRecordEvent is the only optional field in either struct that lacks the #[serde(default, skip_serializing_if = "Option::is_none")] attribute. Without it, absent branch_id values will serialize as "branch_id": null instead 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 | 🟡 Minor

This doesn't exercise max_branches enforcement.

insert_test_branch() writes directly into manager.branches, so the admission logic in create() 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 | 🟡 Minor

This 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 before manager2, 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 CredentialSpec has no CredentialExposure::Env variant in its expose list, unwrap_or_default() on line 35 returns an empty string for env_var. This means credentials with only File exposure 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 | 🟡 Minor

Request body streaming uses wrong size constant.

Line 104 compares total_body_size against MAX_RESPONSE_BODY_BYTES but this is for the request body in the pinned-IP path. Should this use MAX_BODY_SIZE instead 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 name chrono_now is misleading.

The function doesn't use the chrono crate — it uses std::time::SystemTime. Consider renaming to unix_timestamp_now() or timestamp_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-key and proxy-authorization headers 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 first windows() call, but the subsequent checks for cred_base64, cred_url_encoded, and cred_html_entity could call windows() on a scan_bytes that is smaller than those encoded variants. While windows() returns an empty iterator if the window size exceeds the slice length, the explicit scan_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.since uses Option<String> with a comment indicating RFC 3339 format. Using chrono::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, From is often called implicitly via .into(), which could lead to unexpected panics. Consider whether a TryFrom implementation 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8156d42 and 62ef6ac.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (97)
  • crates/puzzle-hook/src/main.rs
  • crates/puzzle-init/Cargo.toml
  • crates/puzzle-init/src/main.rs
  • crates/puzzle-phase2-demo/src/main.rs
  • crates/puzzle-proxy/src/handler.rs
  • crates/puzzle-proxy/src/handler/connect.rs
  • crates/puzzle-proxy/src/handler/credentials.rs
  • crates/puzzle-proxy/src/handler/dlp_inspect.rs
  • crates/puzzle-proxy/src/handler/forward.rs
  • crates/puzzle-proxy/src/handler/intercept.rs
  • crates/puzzle-proxy/src/handler/journal.rs
  • crates/puzzle-proxy/src/handler/mod.rs
  • crates/puzzle-proxy/src/handler/routing.rs
  • crates/puzzle-proxy/src/handler/tls.rs
  • crates/puzzle-proxy/src/handler/transparent.rs
  • crates/puzzle-proxy/src/handler/util.rs
  • crates/puzzle-proxy/src/lib.rs
  • crates/puzzle-proxy/src/secure_memory.rs
  • crates/puzzle-proxy/tests/handler_tests.rs
  • crates/puzzlectl/src/bin/puzzle_sim_worker.rs
  • crates/puzzlectl/src/compliance.rs
  • crates/puzzlectl/src/main.rs
  • crates/puzzlectl/src/sim/engine.rs
  • crates/puzzlectl/src/sim/repl.rs
  • crates/puzzled-types/src/attestation.rs
  • crates/puzzled-types/src/audit.rs
  • crates/puzzled-types/src/behavioral.rs
  • crates/puzzled-types/src/branch.rs
  • crates/puzzled-types/src/change.rs
  • crates/puzzled-types/src/credential.rs
  • crates/puzzled-types/src/identity.rs
  • crates/puzzled-types/src/lib.rs
  • crates/puzzled-types/src/policy.rs
  • crates/puzzled-types/src/profile.rs
  • crates/puzzled-types/src/provenance.rs
  • crates/puzzled-types/src/trust.rs
  • crates/puzzled/Cargo.toml
  • crates/puzzled/benches/branch.rs
  • crates/puzzled/benches/policy.rs
  • crates/puzzled/benches/wal.rs
  • crates/puzzled/src/attestation.rs
  • crates/puzzled/src/audit_store.rs
  • crates/puzzled/src/branch.rs
  • crates/puzzled/src/branch/activate.rs
  • crates/puzzled/src/branch/cleanup.rs
  • crates/puzzled/src/branch/commit_flow.rs
  • crates/puzzled/src/branch/mod.rs
  • crates/puzzled/src/branch/tests.rs
  • crates/puzzled/src/commit.rs
  • crates/puzzled/src/config.rs
  • crates/puzzled/src/conflict.rs
  • crates/puzzled/src/dbus.rs
  • crates/puzzled/src/dbus_helpers.rs
  • crates/puzzled/src/diff.rs
  • crates/puzzled/src/identity.rs
  • crates/puzzled/src/ima.rs
  • crates/puzzled/src/landlock_rules.rs
  • crates/puzzled/src/lib.rs
  • crates/puzzled/src/main.rs
  • crates/puzzled/src/policy.rs
  • crates/puzzled/src/profile.rs
  • crates/puzzled/src/provenance.rs
  • crates/puzzled/src/sandbox/bpf_lsm.rs
  • crates/puzzled/src/sandbox/cgroup.rs
  • crates/puzzled/src/sandbox/fanotify.rs
  • crates/puzzled/src/sandbox/landlock.rs
  • crates/puzzled/src/sandbox/mod.rs
  • crates/puzzled/src/sandbox/namespace.rs
  • crates/puzzled/src/sandbox/overlay.rs
  • crates/puzzled/src/sandbox/quota.rs
  • crates/puzzled/src/sandbox/seccomp/filter.rs
  • crates/puzzled/src/sandbox/seccomp/mod.rs
  • crates/puzzled/src/sandbox/seccomp/notif.rs
  • crates/puzzled/src/seccomp_handler.rs
  • crates/puzzled/src/seccomp_profile.rs
  • crates/puzzled/src/sync_util.rs
  • crates/puzzled/src/test_helpers.rs
  • crates/puzzled/src/wal.rs
  • crates/puzzled/tests/branch_lifecycle.rs
  • crates/puzzled/tests/crash_recovery.rs
  • crates/puzzled/tests/cross_module_integration.rs
  • crates/puzzled/tests/e2e_adversarial.rs
  • crates/puzzled/tests/ima_integration.rs
  • crates/puzzled/tests/phase2_features.rs
  • crates/puzzled/tests/podman_native_integration.rs
  • crates/puzzled/tests/policy_evaluation.rs
  • crates/puzzled/tests/rogue_agent.rs
  • crates/puzzled/tests/seccomp_notif_handler.rs
  • crates/puzzled/tests/security_hardening.rs
  • docs/compliance/eu-ai-act.md
  • docs/compliance/nist-ai-rmf.md
  • docs/security-guide.md
  • docs/technical-design.md
  • policies/profiles/restricted.yaml
  • policies/profiles/standard.yaml
  • policies/rules/commit.rego
  • policies/schemas/profile.schema.json

Comment on lines +265 to +317
// 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."
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +337 to +546
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"
);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +649 to +675
// §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"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.rs

Repository: LobsterTrap/puzzlepod

Length of output: 592


🏁 Script executed:

sed -n '420,450p' crates/puzzle-proxy/src/handler/routing.rs

Repository: 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.

Comment on lines +46 to +135
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
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +82 to +126
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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +130 to +134
// 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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +196 to +258
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +291 to +341
/// 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(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment thread crates/puzzled/src/commit.rs Outdated
@maxamillion maxamillion changed the title Rigorous consistency review: fix 37 findings (6 CRITICAL, 24 MAJOR, 17 MINOR) Consistency review fixes + comprehensive refactoring (simplicity, correctness, DRY, idiomatic Rust) Mar 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Mode is not restored after execute despite "Switching temporarily" comment.

saved_mode is captured at line 340 but never used to restore the original mode after execution completes. If the user was in Direct mode before running execute, 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 (in engine.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 to Trusted.

The catch-all _ => TrustLevel::Trusted at line 31 accepts any score ≥80, including scores >100. While TrustState::new and apply_delta clamp to 100, external callers of from_score could pass unclamped values.

Consider adding a debug_assert!(score <= 100) for consistency with the F26 pattern in apply_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_active without override_expires is never cleared.

If override_active is true but override_expires is None, this method returns false without 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:

  1. Documenting that override_expires: None means "indefinite override"
  2. Adding a separate clear_override() method for manual clearing
  3. Treating None as "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_str fails (line 23-24), the raw string becomes the branches field, and branch_count becomes 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-notif logic 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::Verify through handle_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_test lacks OutputFormat support — violates CLI output guideline.

Per coding guidelines, CLI commands must support machine-parseable JSON output via --output=json. This function uses println! directly without accepting an OutputFormat parameter, 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=json flag"

🤖 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_validate lacks OutputFormat support.

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=json flag"

🤖 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_test should support OutputFormat for 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_all creates the directory with default permissions, then set_permissions tightens them. Similarly, std::fs::write creates 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::OpenOptionsExt with 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: truncate has inconsistent byte vs character semantics.

The early return checks s.len() <= max_len where s.len() is byte length, but char_indices().nth(max_len) treats max_len as a character count. For ASCII strings this works, but for multi-byte UTF-8, the function could return more bytes than expected if max_len is interpreted as a byte limit.

This is likely fine if max_len is 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_ip variable is defined and then immediately shadowed in the same block. Consider using distinct names like resolved_geo_ip for the initial value and geo_ip for 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_list test references ../../../puzzled/src/sandbox/seccomp/mod.rs which is outside the puzzle-proxy crate. This creates a cross-crate coupling that could break if the directory structure changes.

Consider either:

  1. Moving this test to the puzzled crate where the seccomp module lives
  2. 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: Store and Rotate always read from stdin; consider adding --from-env/--from-file for consistency.

The Add command provides --from-env and --from-file options for scripting convenience, but Store and Rotate only 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 passes None to generate_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, and cmd_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_decode fails, the code prints an error but returns an empty Vec, 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 returning Option<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

📥 Commits

Reviewing files that changed from the base of the PR and between 62ef6ac and d0ecae1.

📒 Files selected for processing (58)
  • .cursor/.gitignore
  • crates/puzzle-hook/Cargo.toml
  • crates/puzzle-hook/src/main.rs
  • crates/puzzle-proxy/src/credentials.rs
  • crates/puzzle-proxy/src/handler/mod.rs
  • crates/puzzle-proxy/src/handler/routing.rs
  • crates/puzzle-proxy/src/secure_memory.rs
  • crates/puzzle-proxy/src/tls.rs
  • crates/puzzle-proxy/tests/handler_tests.rs
  • crates/puzzlectl/src/cli.rs
  • crates/puzzlectl/src/commands/agent.rs
  • crates/puzzlectl/src/commands/attestation.rs
  • crates/puzzlectl/src/commands/audit.rs
  • crates/puzzlectl/src/commands/branch.rs
  • crates/puzzlectl/src/commands/compliance_cmd.rs
  • crates/puzzlectl/src/commands/credential.rs
  • crates/puzzlectl/src/commands/mod.rs
  • crates/puzzlectl/src/commands/policy.rs
  • crates/puzzlectl/src/commands/profile.rs
  • crates/puzzlectl/src/commands/status.rs
  • crates/puzzlectl/src/lib.rs
  • crates/puzzlectl/src/main.rs
  • crates/puzzlectl/src/output.rs
  • crates/puzzlectl/src/sim/batch.rs
  • crates/puzzlectl/src/sim/repl.rs
  • crates/puzzled-types/Cargo.toml
  • crates/puzzled-types/src/attestation.rs
  • crates/puzzled-types/src/audit.rs
  • crates/puzzled-types/src/behavioral.rs
  • crates/puzzled-types/src/branch.rs
  • crates/puzzled-types/src/change.rs
  • crates/puzzled-types/src/lib.rs
  • crates/puzzled-types/src/policy.rs
  • crates/puzzled-types/src/provenance.rs
  • crates/puzzled-types/src/trust.rs
  • crates/puzzled/src/audit_store.rs
  • crates/puzzled/src/branch/activate.rs
  • crates/puzzled/src/branch/commit_flow.rs
  • crates/puzzled/src/config.rs
  • crates/puzzled/src/conflict.rs
  • crates/puzzled/src/dbus/helpers.rs
  • crates/puzzled/src/dbus/mod.rs
  • crates/puzzled/src/dbus/tests.rs
  • crates/puzzled/src/error.rs
  • crates/puzzled/src/landlock_rules.rs
  • crates/puzzled/src/lib.rs
  • crates/puzzled/src/main.rs
  • crates/puzzled/src/provenance.rs
  • crates/puzzled/src/trust.rs
  • crates/puzzled/src/wal.rs
  • crates/puzzled/tests/branch_lifecycle.rs
  • crates/puzzled/tests/common/mod.rs
  • crates/puzzled/tests/concurrent_branches.rs
  • crates/puzzled/tests/e2e_adversarial.rs
  • crates/puzzled/tests/e2e_governance_lifecycle.rs
  • crates/puzzled/tests/e2e_scenarios.rs
  • crates/puzzled/tests/sandbox_containment.rs
  • crates/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

Comment on lines +32 to +35
/// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +315 to +333
/// 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>,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -50

Repository: LobsterTrap/puzzlepod

Length of output: 2317


🏁 Script executed:

# Check clap version in Cargo.toml
rg "clap" crates/puzzlectl/Cargo.toml -A 3

Repository: 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 -20

Repository: 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 2

Repository: 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 -30

Repository: 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 10

Repository: 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:


🏁 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.

Comment on lines +676 to +688
// 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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -20

Repository: 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 50

Repository: 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 -40

Repository: 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 3

Repository: 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 -20

Repository: 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 2

Repository: 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=rust

Repository: 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 3

Repository: 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.rs

Repository: 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 10

Repository: 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 3

Repository: 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.rs

Repository: 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".

Comment on lines +24 to +26
let result = client.query_audit_events(&filter.to_string()).await?;
println!("{result}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +50 to +85
/// 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(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
/// 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.

Comment on lines +267 to +276
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),
)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment thread crates/puzzlectl/src/output.rs
Comment thread crates/puzzled-types/src/audit.rs
Comment thread crates/puzzled-types/src/lib.rs
Comment on lines +189 to +224
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)
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.rs

Repository: 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 -n

Repository: 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d0ecae1 and 7019ea9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (64)
  • crates/puzzle-hook/src/main.rs
  • crates/puzzle-init/src/main.rs
  • crates/puzzle-phase2-demo/src/main.rs
  • crates/puzzle-proxy/src/credentials.rs
  • crates/puzzle-proxy/src/lib.rs
  • crates/puzzle-proxy/src/secure_memory.rs
  • crates/puzzle-proxy/tests/handler_tests.rs
  • crates/puzzlectl/src/cli.rs
  • crates/puzzlectl/src/commands/mod.rs
  • crates/puzzlectl/src/commands/policy.rs
  • crates/puzzlectl/src/commands/profile.rs
  • crates/puzzlectl/src/commands/run.rs
  • crates/puzzlectl/src/compliance.rs
  • crates/puzzlectl/src/lib.rs
  • crates/puzzlectl/src/main.rs
  • crates/puzzlectl/src/sim/batch.rs
  • crates/puzzlectl/src/sim/engine.rs
  • crates/puzzlectl/src/sim/repl.rs
  • crates/puzzled-types/src/lib.rs
  • crates/puzzled-types/src/profile.rs
  • crates/puzzled/benches/branch.rs
  • crates/puzzled/benches/policy.rs
  • crates/puzzled/src/audit_store.rs
  • crates/puzzled/src/commit.rs
  • crates/puzzled/src/config.rs
  • crates/puzzled/src/conflict.rs
  • crates/puzzled/src/dbus/mod.rs
  • crates/puzzled/src/diff.rs
  • crates/puzzled/src/error.rs
  • crates/puzzled/src/ima.rs
  • crates/puzzled/src/landlock_rules.rs
  • crates/puzzled/src/lib.rs
  • crates/puzzled/src/main.rs
  • crates/puzzled/src/policy.rs
  • crates/puzzled/src/profile.rs
  • crates/puzzled/src/provenance.rs
  • crates/puzzled/src/sandbox/bpf_lsm.rs
  • crates/puzzled/src/sandbox/cgroup.rs
  • crates/puzzled/src/sandbox/fanotify.rs
  • crates/puzzled/src/sandbox/landlock.rs
  • crates/puzzled/src/sandbox/overlay.rs
  • crates/puzzled/src/sandbox/quota.rs
  • crates/puzzled/src/sandbox/seccomp/filter.rs
  • crates/puzzled/src/sandbox/seccomp/mod.rs
  • crates/puzzled/src/sandbox/seccomp/notif.rs
  • crates/puzzled/src/seccomp_handler.rs
  • crates/puzzled/src/seccomp_profile.rs
  • crates/puzzled/src/test_helpers.rs
  • crates/puzzled/src/trust.rs
  • crates/puzzled/src/wal.rs
  • crates/puzzled/tests/branch_lifecycle.rs
  • crates/puzzled/tests/concurrent_branches.rs
  • crates/puzzled/tests/crash_recovery.rs
  • crates/puzzled/tests/e2e_adversarial.rs
  • crates/puzzled/tests/e2e_governance_lifecycle.rs
  • crates/puzzled/tests/e2e_scenarios.rs
  • crates/puzzled/tests/ima_integration.rs
  • crates/puzzled/tests/phase2_features.rs
  • crates/puzzled/tests/podman_native_integration.rs
  • crates/puzzled/tests/policy_evaluation.rs
  • crates/puzzled/tests/sandbox_containment.rs
  • crates/puzzled/tests/security_hardening.rs
  • docs/security-guide.md
  • docs/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

Comment on lines +106 to +116

/// 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>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +144 to +151
// 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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.

Comment thread crates/puzzlectl/src/cli.rs
Comment thread crates/puzzlectl/src/commands/policy.rs
Comment on lines +66 to +77
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<_>>()
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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).

Comment on lines +170 to +174
// Step 2: Activate branch
if matches!(output, OutputFormat::Text) {
eprintln!("[run] branch {branch_id} created, activating...");
}
client.activate_branch(&branch_id, &command_json).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +202 to +205
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +116 to 183
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment thread crates/puzzled-types/src/profile.rs
Comment on lines +160 to +174
/// 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>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Rollback active branch before reset_with_mode in set mode.

If set mode ... runs after start, 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

execute mode override is not restored.

Line 341 stores saved_mode, but after a direct-mode execute success, 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 | 🟠 Major

Reject impossible Merkle proofs up front.

compute_root_from_inclusion() returns leaf_hash for tree_size <= 1, so a proof with tree_size == 1 and leaf_index > 0 can still verify if expected_root == leaf_hash. Validate tree_size > 0 and leaf_index < tree_size before 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 | 🟠 Major

Offline policy tests still omit daemon-visible FileChange fields.

The Rego input excludes old_mode, entropy, and has_base64_blocks, so puzzlectl policy test can 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 test still ignores read_denylist and write_denylist.

Daemon-side enforcement consults those lists, but this loop only checks denylist and write_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 for max_open_fds and lifetime_minutes.

The comments at lines 164-165 document max_open_fds as range 64-65536 and line 173 documents lifetime_minutes as range 1-1440, but validate() 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 | 🟠 Major

Quarantine channel captures None cgroup_path because sandbox isn't registered yet.

At line 195, self.sandboxes.get(id) returns None because the sandbox handle isn't inserted until line 454. The spawned task will always see cgroup_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 | 🔴 Critical

Credential 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 to Active at line 421. However, issue_for_branch() at lines 477-484 runs after these operations. If credential provisioning fails, the function returns Err but 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 | 🟠 Major

Invalid proxy_listen_addr returns None instead of failing branch activation.

When proxy_listen_addr.parse() fails (line 97-105), the closure returns None, causing handler.register() to proceed without a CredentialProxyContext. 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 Err on 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 | 🟡 Minor

Timestamp 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 like 2024-01-01T00:00:00Z vs 2024-01-01T00:00:00+00:00 (since 'Z' > '+' lexicographically). Use parse_rfc3339_approx() from compliance.rs or chrono::DateTime::parse_from_rfc3339 for 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 to audit_dir's default_value.

In clap v4, arguments with default_value are considered "used" during validation. Since audit_dir (line 407-408) has default_value, the conflicts_with = "audit_dir" on bundle (line 422) will error even when audit_dir isn't explicitly provided.

Change audit_dir to Option<PathBuf> without default_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 between reset and reset_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 using BranchId type for consistency.

AuditFilter.branch_id is Option<String> while the crate defines a BranchId newtype. Using Option<BranchId> would provide type safety and consistency with other filter types in the codebase.

Similarly, since could use a structured timestamp type rather than raw String to 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::spawn at 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:

  1. Active tunnels continue until REQUEST_TIMEOUT or byte limits, regardless of server shutdown
  2. The branch_id context is moved into the task, so logging continues to work

If 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().await holds the lock while journal.append(entry).await executes. 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_map on line 29-36 takes only the first Env variant, 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 use pub(crate) inconsistently.

default_true_val is pub(crate) but other default functions are private. If default_true_val is 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_ROOTS provides 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 intermediate value is zeroized when trim_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. Using with_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 name chrono_now is misleading — no chrono crate is used.

The function uses std::time::SystemTime, not the chrono crate. Consider renaming to timestamp_now or unix_timestamp_now for 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 before windows() call.

The code checks scan_bytes.len() >= cred_bytes.len() and scan_bytes.len() >= cred_html_entity.len(), but doesn't verify lengths before calling windows() on cred_base64 and cred_url_encoded. While windows() on an empty slice or when size > len returns an empty iterator (not a panic), the check for cred_html_entity suggests 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: Redundant Request::from_parts reconstruction when no headers stripped.

When stripped is 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-env and --from-file should be mutually exclusive.

The Add command allows both --from-env and --from-file to be specified simultaneously, but only one source can be used. Add conflicts_with to 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_dir is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7019ea9 and dd4612c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (115)
  • .cursor/.gitignore
  • crates/puzzle-hook/Cargo.toml
  • crates/puzzle-hook/src/main.rs
  • crates/puzzle-init/Cargo.toml
  • crates/puzzle-init/src/main.rs
  • crates/puzzle-phase2-demo/src/main.rs
  • crates/puzzle-proxy/src/credentials.rs
  • crates/puzzle-proxy/src/handler.rs
  • crates/puzzle-proxy/src/handler/connect.rs
  • crates/puzzle-proxy/src/handler/credentials.rs
  • crates/puzzle-proxy/src/handler/dlp_inspect.rs
  • crates/puzzle-proxy/src/handler/forward.rs
  • crates/puzzle-proxy/src/handler/intercept.rs
  • crates/puzzle-proxy/src/handler/journal.rs
  • crates/puzzle-proxy/src/handler/mod.rs
  • crates/puzzle-proxy/src/handler/routing.rs
  • crates/puzzle-proxy/src/handler/tls.rs
  • crates/puzzle-proxy/src/handler/transparent.rs
  • crates/puzzle-proxy/src/handler/util.rs
  • crates/puzzle-proxy/src/lib.rs
  • crates/puzzle-proxy/src/secure_memory.rs
  • crates/puzzle-proxy/tests/handler_tests.rs
  • crates/puzzlectl/src/cli.rs
  • crates/puzzlectl/src/commands/agent.rs
  • crates/puzzlectl/src/commands/attestation.rs
  • crates/puzzlectl/src/commands/audit.rs
  • crates/puzzlectl/src/commands/branch.rs
  • crates/puzzlectl/src/commands/compliance_cmd.rs
  • crates/puzzlectl/src/commands/credential.rs
  • crates/puzzlectl/src/commands/mod.rs
  • crates/puzzlectl/src/commands/policy.rs
  • crates/puzzlectl/src/commands/profile.rs
  • crates/puzzlectl/src/commands/run.rs
  • crates/puzzlectl/src/commands/status.rs
  • crates/puzzlectl/src/compliance.rs
  • crates/puzzlectl/src/lib.rs
  • crates/puzzlectl/src/main.rs
  • crates/puzzlectl/src/output.rs
  • crates/puzzlectl/src/sim/batch.rs
  • crates/puzzlectl/src/sim/engine.rs
  • crates/puzzlectl/src/sim/repl.rs
  • crates/puzzled-types/Cargo.toml
  • crates/puzzled-types/src/attestation.rs
  • crates/puzzled-types/src/audit.rs
  • crates/puzzled-types/src/behavioral.rs
  • crates/puzzled-types/src/branch.rs
  • crates/puzzled-types/src/change.rs
  • crates/puzzled-types/src/credential.rs
  • crates/puzzled-types/src/identity.rs
  • crates/puzzled-types/src/lib.rs
  • crates/puzzled-types/src/policy.rs
  • crates/puzzled-types/src/profile.rs
  • crates/puzzled-types/src/provenance.rs
  • crates/puzzled-types/src/trust.rs
  • crates/puzzled/Cargo.toml
  • crates/puzzled/benches/branch.rs
  • crates/puzzled/benches/policy.rs
  • crates/puzzled/src/audit_store.rs
  • crates/puzzled/src/branch.rs
  • crates/puzzled/src/branch/activate.rs
  • crates/puzzled/src/branch/cleanup.rs
  • crates/puzzled/src/branch/commit_flow.rs
  • crates/puzzled/src/branch/mod.rs
  • crates/puzzled/src/branch/tests.rs
  • crates/puzzled/src/commit.rs
  • crates/puzzled/src/config.rs
  • crates/puzzled/src/conflict.rs
  • crates/puzzled/src/dbus/helpers.rs
  • crates/puzzled/src/dbus/mod.rs
  • crates/puzzled/src/dbus/tests.rs
  • crates/puzzled/src/diff.rs
  • crates/puzzled/src/error.rs
  • crates/puzzled/src/ima.rs
  • crates/puzzled/src/landlock_rules.rs
  • crates/puzzled/src/lib.rs
  • crates/puzzled/src/main.rs
  • crates/puzzled/src/policy.rs
  • crates/puzzled/src/profile.rs
  • crates/puzzled/src/provenance.rs
  • crates/puzzled/src/sandbox/bpf_lsm.rs
  • crates/puzzled/src/sandbox/cgroup.rs
  • crates/puzzled/src/sandbox/fanotify.rs
  • crates/puzzled/src/sandbox/landlock.rs
  • crates/puzzled/src/sandbox/overlay.rs
  • crates/puzzled/src/sandbox/quota.rs
  • crates/puzzled/src/sandbox/seccomp/filter.rs
  • crates/puzzled/src/sandbox/seccomp/mod.rs
  • crates/puzzled/src/sandbox/seccomp/notif.rs
  • crates/puzzled/src/seccomp_handler.rs
  • crates/puzzled/src/seccomp_profile.rs
  • crates/puzzled/src/sync_util.rs
  • crates/puzzled/src/test_helpers.rs
  • crates/puzzled/src/trust.rs
  • crates/puzzled/src/wal.rs
  • crates/puzzled/tests/branch_lifecycle.rs
  • crates/puzzled/tests/common/mod.rs
  • crates/puzzled/tests/concurrent_branches.rs
  • crates/puzzled/tests/crash_recovery.rs
  • crates/puzzled/tests/e2e_adversarial.rs
  • crates/puzzled/tests/e2e_governance_lifecycle.rs
  • crates/puzzled/tests/e2e_scenarios.rs
  • crates/puzzled/tests/ima_integration.rs
  • crates/puzzled/tests/phase2_features.rs
  • crates/puzzled/tests/podman_native_integration.rs
  • crates/puzzled/tests/policy_evaluation.rs
  • crates/puzzled/tests/sandbox_containment.rs
  • crates/puzzled/tests/security_hardening.rs
  • docs/compliance/eu-ai-act.md
  • docs/compliance/nist-ai-rmf.md
  • docs/security-guide.md
  • docs/technical-design.md
  • policies/profiles/restricted.yaml
  • policies/profiles/standard.yaml
  • policies/rules/commit.rego
  • policies/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

Comment on lines +29 to +58
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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 {

Comment on lines +533 to +544
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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +206 to +207
let server_name = rustls::pki_types::ServerName::try_from(domain.to_string())
.map_err(|e| format!("invalid server name '{}': {}", domain, e))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 -60

Repository: 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.rs

Repository: 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 -A10

Repository: 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.rs

Repository: 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 -20

Repository: 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 -A5

Repository: 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 -40

Repository: 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 -A5

Repository: 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.rs

Repository: 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.rs

Repository: 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 -100

Repository: 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/ -A15

Repository: 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/ -A20

Repository: 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.rs

Repository: 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.rs

Repository: 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.

Comment on lines +117 to +120
/// Escape a string for embedding in a Rego double-quoted string literal.
fn escape_rego_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +18 to +21
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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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).

Comment on lines +75 to +80
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +124 to +127
let diff_start = std::time::Instant::now();
let changes =
self.diff_engine
.generate(&upper_dir, &base_path, cgroup_path_for_diff.as_deref())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +143 to +156
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,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +304 to +319
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd crates/puzzled/src/branch && head -330 commit_flow.rs | tail -40

Repository: LobsterTrap/puzzlepod

Length of output: 1801


🏁 Script executed:

find . -name "*.rs" -type f | xargs rg "enum PolicyDecision|struct PolicyDecision" -A 5

Repository: LobsterTrap/puzzlepod

Length of output: 378


🏁 Script executed:

rg "fn.*match.*decision|let.*decision.*=" crates/puzzled/src/branch/commit_flow.rs -B 5 -A 15

Repository: LobsterTrap/puzzlepod

Length of output: 1993


🏁 Script executed:

rg "policy_result:" crates/puzzled/src/branch/commit_flow.rs -B 2 -A 2

Repository: LobsterTrap/puzzlepod

Length of output: 1537


🏁 Script executed:

cd crates/puzzled && grep -r "PolicyDecision" --include="*.rs" | grep -i "derive\|copy" | head -20

Repository: 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.rs

Repository: 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.rs

Repository: LobsterTrap/puzzlepod

Length of output: 1319


🏁 Script executed:

# Verify compilation status
cargo check --manifest-path crates/puzzled/Cargo.toml 2>&1 | head -50

Repository: 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.rs

Repository: 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 -80

Repository: 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 -10

Repository: 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.

Comment on lines +370 to +379
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(),
));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (16)
crates/puzzlectl/src/commands/credential.rs (1)

248-249: ⚠️ Potential issue | 🔴 Critical

Validate name before joining it into secrets_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 | 🟡 Minor

Accept .yml profile names too.

Bare-name resolution still only scans/builds *.yaml, so a valid foo.yml profile 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 validate still 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 | 🟠 Major

Match profile test to the daemon's denylist rules.

This helper still ignores read_denylist and write_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 | 🟠 Major

Don'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 | 🟠 Major

Rollback the created branch when activation fails.

Once create_branch succeeds, an activate_branch error 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 | 🟠 Major

Treat policy_result.Error as a failed commit.

The JSON and text branches only mark Rejected; {"Error": ...} still falls through to the committed path here, so cmd_run can 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 | 🟡 Minor

Don't reject Content-Encoding: identity.

This branch still blocks any Content-Encoding header, even though identity is 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 | 🟠 Major

Keep the Rego test input schema aligned with FileChange.

This mapping still drops old_mode, entropy, and has_base64_blocks, so puzzlectl policy test can 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 | 🟠 Major

Escape full Rego string syntax, not just quotes and backslashes.

A user-supplied newline, tab, or carriage return in message or 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 | 🔴 Critical

Don'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 ?/Err leaves a live sandbox behind the failure path, and by the time credential provisioning fails the branch is already marked Active and stored in self.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 | 🟠 Major

Capture the new sandbox's cgroup path directly.

self.sandboxes.get(id) is still None at this point, so the quarantine task closes over None and cgroup.freeze never 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 | 🔴 Critical

Fail activation when proxy_listen_addr is invalid.

Returning None here 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 | 🟠 Major

Reject impossible inclusion proofs up front.

compute_root_from_inclusion() returns leaf_hash for tree_size <= 1, so a proof with tree_size == 0 or leaf_index >= tree_size can 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 | 🟠 Major

Make --no-notif fail 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 | 🔴 Critical

Fail closed when Phantom mode has no token manager.

This TLS path still does nothing when credential_mode == Phantom and phantom_token_manager is None, so raw Authorization, Proxy-Authorization, and X-Api-Key headers 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 for PR_CAP_AMBIENT magic numbers.

The magic numbers 47 and 4 represent PR_CAP_AMBIENT and PR_CAP_AMBIENT_CLEAR_ALL respectively. 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 53 but 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, the contains("..") 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_val is pub(crate) while the other default functions (default_ttl, default_swap_headers, default_max_credential_size) are private. If default_true_val needs 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: Blocking thread::sleep in synchronous apply_fail_mode.

The FailSafeState verification loop on lines 734-750 uses std::thread::sleep in 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.

Governance flattens decision and violation data down to String/Vec<String>, which loses structure the rest of puzzled-types already 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-env and --from-file.

The Add command allows --from-env and --from-file without conflicts_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: BearerHeader and BasicHeader branches 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 before windows() calls.

The cred_html_entity check includes scan_bytes.len() >= cred_html_entity.len() (line 729) but the cred_base64 and cred_url_encoded checks don't have equivalent guards (lines 722-727). While windows() 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +178 to +214
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)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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))).

Comment on lines +533 to +546
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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(()).

Comment on lines +292 to +301
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]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +873 to +912
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"
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +278 to +295
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +126 to +149
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}");
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +30 to +34
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +257 to +259
Decision::ShowDiff => {
print_diff_text(&diff_json);
eprint!("[run] approve? [y/N]: ");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +26 to +47
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

@maxamillion maxamillion merged commit 6fd0ffa into main Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant