Skip to content

feat: implement command hook system (#32)#41

Merged
xinhuagu merged 5 commits into
mainfrom
feat/issue-32-command-hook-system
Feb 20, 2026
Merged

feat: implement command hook system (#32)#41
xinhuagu merged 5 commits into
mainfrom
feat/issue-32-command-hook-system

Conversation

@xinhuagu

@xinhuagu xinhuagu commented Feb 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Implements extensibility hook system matching Claude Code's design for issue [P1] Implement command Hook system (command-type) #32
  • 3 hook events: PreToolUse (blocking), PostToolUse (non-blocking), PostToolUseFailure (non-blocking)
  • Command hooks execute shell commands via ProcessBuilder with JSON stdin/stdout contract
  • Exit code semantics: 0=proceed, 2=block, other=non-blocking error (log + continue)
  • Config loaded from ~/.aceclaw/config.json and project config (project appends to global per event type)

Changes

New files (7 source + 3 test):

  • aceclaw-core: HookEvent (sealed interface), HookResult (sealed interface), HookExecutor (functional interface)
  • aceclaw-daemon: HookConfig, HookMatcher, HookRegistry, CommandHookExecutor
  • Tests: HookRegistryTest (9 tests), CommandHookExecutorTest (11 tests), HookIntegrationTest (4 E2E tests)

Modified files (3):

  • AceClawConfig — hooks field, merge logic, inner format classes for Jackson deserialization
  • StreamingAgentHandler — PreToolUse hooks before permission check (can block/modify input), PostToolUse hooks fire async on virtual threads after execution
  • AceClawDaemon — wires HookRegistry + CommandHookExecutor into agent handler

Test plan

  • HookRegistryTest: empty registry, exact/regex/null matchers, multiple matchers order, invalid regex, unknown events
  • CommandHookExecutorTest: exit codes 0/1/2, stdin piping, timeout, JSON parsing, malformed stdout, block-on-first for PreToolUse
  • HookIntegrationTest: full E2E — PreToolUse blocks bash, unmatched tool passes, PostToolUse audit marker, invocation order
  • All existing daemon tests pass (no regressions)
  • ./gradlew clean build succeeds (pre-existing SkillRegistryTest failures unrelated)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Pluggable hook system for tool lifecycle events (PreToolUse, PostToolUse, PostToolUseFailure) with runtime wiring into the agent, command-based executors, and hook outcomes (proceed, block, error).
  • Configuration

    • Hooks configurable in project/global config with matcher types (exact/regex/all), ordered resolution, and per-hook timeouts/validation.
  • Tests

    • Extensive unit and end-to-end tests validating registry resolution, execution semantics, timeouts, sequencing, blocking, and audit post-hooks.

Add extensibility hook system matching Claude Code's design. Hooks let
users run shell commands at tool lifecycle points (PreToolUse, PostToolUse,
PostToolUseFailure) to enforce policies, audit actions, or modify inputs.

- 3 core abstractions: HookEvent (sealed), HookResult (sealed), HookExecutor
- HookConfig/HookMatcher/HookRegistry for config-driven hook resolution
- CommandHookExecutor: ProcessBuilder with JSON stdin/stdout, exit code
  semantics (0=proceed, 2=block, other=non-blocking error), timeout
- PreToolUse hooks run before permission check; can block or modify input
- PostToolUse/PostToolUseFailure hooks fire async on virtual threads
- Config loaded from ~/.aceclaw/config.json and project config (appending)
- 24 new tests across 3 test classes (unit + E2E integration)
@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a hook system: core hook/event/result types and executor contract; daemon-side config, matcher, registry, and command-based executor; integrates hooks into the daemon handler to run blockable/modifiable PreToolUse and async PostToolUse/PostToolUseFailure hooks; includes unit and integration tests.

Changes

Cohort / File(s) Summary
Core hook interfaces
aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookEvent.java, aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookExecutor.java, aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookResult.java
Add sealed HookEvent (PreToolUse, PostToolUse, PostToolUseFailure), functional HookExecutor, and sealed HookResult (Proceed, Block, Error) with associated APIs and docs.
Config & model types
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java, aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookConfig.java, aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookMatcher.java
Introduce hooks mapping in config, HookConfig record with validation/timeouts, and HookMatcher record with match-all/exact/regex factories and validation.
Registry & executor
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java, aceclaw-daemon/src/main/java/dev/aceclaw/daemon/CommandHookExecutor.java
Add immutable HookRegistry (load/resolve/inspect) and CommandHookExecutor that runs shell commands, sends JSON stdin, enforces timeouts, parses stdout JSON, and maps exit codes to HookResult semantics.
Daemon integration
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawDaemon.java, aceclaw-daemon/src/main/java/dev/aceclaw/daemon/StreamingAgentHandler.java
Load HookRegistry, instantiate CommandHookExecutor, wire HookExecutor into handler and PermissionAwareTool; run PreToolUse before permission checks (allow modification/blocking) and fire PostToolUse/PostToolUseFailure asynchronously.
Tests
aceclaw-daemon/src/test/java/dev/aceclaw/daemon/CommandHookExecutorTest.java, aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookRegistryTest.java, aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookIntegrationTest.java
Add unit tests for executor behavior (exit codes, JSON parsing, timeouts, ordering), registry tests (matching/load edge cases), and integration tests validating PreToolUse blocking and PostToolUse auditing end-to-end.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as StreamingAgentHandler
    participant Registry as HookRegistry
    participant Executor as CommandHookExecutor
    participant Shell as Shell Process
    participant Tool

    Client->>Handler: Tool request
    Handler->>Registry: resolve(PreToolUse event)
    Registry-->>Handler: matching hooks

    rect rgba(100,200,100,0.5)
    Note over Handler,Shell: PreToolUse (blockable/modifiable)
    Handler->>Executor: execute(PreToolUse)
    Executor->>Shell: spawn process, write JSON stdin
    Shell-->>Executor: exit code + stdout
    alt exit code = 0 (Proceed)
        Executor-->>Handler: Proceed (maybe updatedInput)
    else exit code = 2 (Block)
        Executor-->>Handler: Block(reason)
        Handler-->>Client: Tool execution blocked
    else other (Error)
        Executor-->>Handler: Error
    end
    end

    rect rgba(100,150,200,0.5)
    Note over Handler,Tool: Tool execution
    Handler->>Tool: execute(possibly modified input)
    Tool-->>Handler: result or error
    end

    rect rgba(200,100,100,0.5)
    Note over Handler,Shell: PostToolUse (async)
    Handler->>Registry: resolve(PostToolUse/PostToolUseFailure)
    Registry-->>Handler: matching hooks
    Handler->>Executor: execute(PostToolUse) [async]
    Executor->>Shell: spawn process, write JSON stdin
    Shell-->>Executor: exit code + stdout
    Executor-->>Handler: (async result logged)
    end

    Handler-->>Client: Tool result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: implement command hook system (#32)' clearly and concisely summarizes the primary change: implementation of a command hook system. It is specific, follows conventional commit format, and directly reflects the main objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/issue-32-command-hook-system

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Feb 20, 2026

Copy link
Copy Markdown

Greptile Summary

Implements a command hook system for tool lifecycle extensibility, matching Claude Code's design. Three hook events (PreToolUse, PostToolUse, PostToolUseFailure) allow shell commands to run at different points in tool execution. PreToolUse hooks execute before permission checks and can block or modify tool input via exit code semantics (0=proceed, 2=block, other=error). Post-execution hooks run asynchronously on virtual threads for auditing/logging.

Key changes:

  • New sealed interfaces in aceclaw-core: HookEvent, HookExecutor, HookResult with clean type hierarchies
  • CommandHookExecutor spawns processes via ProcessBuilder with JSON stdin/stdout contract
  • HookRegistry loads from config and resolves matchers (exact, regex, or match-all patterns)
  • Config merging: project hooks append to global hooks per event type
  • Integration in StreamingAgentHandler.PermissionAwareTool wrapper ensures proper execution order
  • Comprehensive test coverage: 24 tests across unit, executor, and E2E integration tests

Potential issue:

  • Stream reading after waitFor() in CommandHookExecutor:123-124 could miss output if process terminates quickly (see inline comment)

Confidence Score: 4/5

  • Safe to merge with one stream-reading race condition that should be fixed
  • Well-architected implementation with excellent test coverage (24 tests including E2E), clean sealed type hierarchies, and proper error handling. One process stream-reading race condition could cause hook output loss in edge cases. No security issues, follows project conventions, and includes thorough documentation.
  • Pay attention to CommandHookExecutor.java - fix the stream reading race condition before production use

Important Files Changed

Filename Overview
aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookEvent.java New sealed interface defining three hook event types (PreToolUse, PostToolUse, PostToolUseFailure) with session context and tool metadata
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java Immutable registry loading and resolving hook matchers by event type with proper error handling
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/CommandHookExecutor.java ProcessBuilder-based hook executor with JSON stdin/stdout contract, proper timeout handling, and exit code interpretation
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/StreamingAgentHandler.java Integrated hooks into PermissionAwareTool wrapper - PreToolUse fires before permission check, PostToolUse/PostToolUseFailure fire async after execution
aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookIntegrationTest.java Full E2E integration tests verifying PreToolUse blocking, PostToolUse side effects, and complete hook lifecycle

Sequence Diagram

sequenceDiagram
    participant Agent as StreamingAgentHandler
    participant PAT as PermissionAwareTool
    participant HE as HookExecutor
    participant HR as HookRegistry
    participant CHE as CommandHookExecutor
    participant Shell as Shell Process
    participant Tool as Actual Tool

    Agent->>PAT: execute(inputJson)
    
    Note over PAT,HE: PreToolUse Hook (Blocking)
    PAT->>HE: execute(PreToolUse event)
    HE->>HR: resolve(event)
    HR-->>HE: List<HookConfig>
    
    loop For each hook config
        HE->>CHE: executeOne(hookConfig, event)
        CHE->>Shell: spawn process with JSON stdin
        Shell-->>CHE: exit code + stdout/stderr
        CHE-->>HE: HookResult (Proceed/Block/Error)
        alt Block result
            HE-->>PAT: HookResult.Block
            PAT-->>Agent: ToolResult (error)
        end
    end
    
    alt Not blocked
        Note over PAT: Permission Check
        PAT->>PermissionManager: check(request)
        
        Note over PAT,Tool: Tool Execution
        PAT->>Tool: execute(effectiveInputJson)
        Tool-->>PAT: ToolResult
        
        Note over PAT,HE: PostToolUse Hook (Async)
        PAT-->>HE: fire async on virtual thread
        par Async hook execution
            HE->>HR: resolve(PostToolUse/Failure event)
            HR-->>HE: List<HookConfig>
            loop All hooks run
                HE->>CHE: executeOne(hookConfig, event)
                CHE->>Shell: spawn process
                Shell-->>CHE: exit code
            end
        end
        
        PAT-->>Agent: ToolResult
    end
Loading

Last reviewed commit: dd6c388

@greptile-apps greptile-apps 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.

13 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +123 to +124
String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8).trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

reading streams after waitFor() can miss output if process terminates quickly - read streams concurrently before waiting

Suggested change
String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
String stderr = new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8).trim();
// Read streams concurrently while waiting
var stdoutFuture = CompletableFuture.supplyAsync(() -> {
try { return new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); }
catch (IOException e) { return ""; }
});
var stderrFuture = CompletableFuture.supplyAsync(() -> {
try { return new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8).trim(); }
catch (IOException e) { return ""; }
});
boolean finished = process.waitFor(hookConfig.timeout(), TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
log.warn("Hook command timed out after {}s: {}", hookConfig.timeout(), hookConfig.command());
return new HookResult.Error(-1, "Hook timed out after " + hookConfig.timeout() + "s");
}
int exitCode = process.exitValue();
String stdout = stdoutFuture.getNow("");
String stderr = stderrFuture.getNow("");
Prompt To Fix With AI
This is a comment left during a code review.
Path: aceclaw-daemon/src/main/java/dev/aceclaw/daemon/CommandHookExecutor.java
Line: 123-124

Comment:
reading streams after `waitFor()` can miss output if process terminates quickly - read streams concurrently before waiting

```suggestion
            // Read streams concurrently while waiting
            var stdoutFuture = CompletableFuture.supplyAsync(() -> {
                try { return new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); }
                catch (IOException e) { return ""; }
            });
            var stderrFuture = CompletableFuture.supplyAsync(() -> {
                try { return new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8).trim(); }
                catch (IOException e) { return ""; }
            });

            boolean finished = process.waitFor(hookConfig.timeout(), TimeUnit.SECONDS);
            if (!finished) {
                process.destroyForcibly();
                log.warn("Hook command timed out after {}s: {}", hookConfig.timeout(), hookConfig.command());
                return new HookResult.Error(-1, "Hook timed out after " + hookConfig.timeout() + "s");
            }

            int exitCode = process.exitValue();
            String stdout = stdoutFuture.getNow("");
            String stderr = stderrFuture.getNow("");
```

How can I resolve this? If you propose a fix, please make it concise.

Process pipe buffers are finite (~64KB on Linux). If the hook command
writes more than the buffer capacity before we start reading, it blocks
on write while we block on waitFor() — a classic deadlock. Fix by
draining stdout and stderr on virtual threads concurrently with waitFor.

@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: 4

🧹 Nitpick comments (1)
aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookIntegrationTest.java (1)

287-290: Replace fixed sleeps with a bounded poll to reduce test flakiness.
Async hooks can be slower on busy CI; polling for the marker with a timeout is more deterministic.

🛠️ Suggested refactor
-            Thread.sleep(1000);
+            waitForFile(tempDir.resolve("audit-marker.json"), 2_000);
@@
-            Thread.sleep(500);
+            waitForFile(tempDir.resolve("audit-marker.json"), 2_000);
@@
     // -- Helper methods (mirrors DaemonIntegrationTest) --
+
+    private static void waitForFile(Path path, long timeoutMs) throws Exception {
+        long deadline = System.currentTimeMillis() + timeoutMs;
+        while (System.currentTimeMillis() < deadline) {
+            if (Files.exists(path)) {
+                return;
+            }
+            Thread.sleep(50);
+        }
+        throw new AssertionError("Timed out waiting for file: " + path);
+    }

Also applies to: 346-347, 363-369

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookIntegrationTest.java`
around lines 287 - 290, In HookIntegrationTest replace the fixed
Thread.sleep(1000) after calling sendPromptAndCollectEvents with a bounded poll
that repeatedly checks for the async PostToolUse hook completion marker (the
same condition you assert later) until a configurable timeout (e.g., 5–10s)
elapses; implement a small sleep between polls (e.g., 50–200ms), stop early when
the marker is observed, and fail the test if the timeout is reached — apply the
same change for the other occurrences around lines where
sendPromptAndCollectEvents is used so tests no longer rely on fixed sleeps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java`:
- Around line 454-473: Replace the mutable POJO classes HookMatcherFormat and
HookConfigFormat with Java records to make them immutable and use
constructor-based deserialization with Jackson; change the declarations of
HookMatcherFormat and HookConfigFormat to record types (keeping
`@JsonIgnoreProperties`) and update tests (HookIntegrationTest,
CommandHookExecutorTest, HookRegistryTest) that currently assign fields (e.g.,
mf.matcher = ...) to construct instances via the record constructors (e.g., new
HookMatcherFormat(matcher, hooks)) so deserialization and immutability are
preserved.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java`:
- Around line 18-33: Replace the immutable holder class HookRegistry with a
record declaration: declare "public record HookRegistry(Map<String,
List<HookMatcher>> matchers)". Move the static Logger (log) into the record as a
static field and keep all instance/static methods as-is. Implement a compact
canonical constructor for the record that performs the same
deep-copy/validation: create a new LinkedHashMap, copy each entry with
List.copyOf(entry.getValue()), then assign the record component to an
unmodifiable copy via Map.copyOf(copy) (so the component remains immutable).
Ensure any existing usages of the private constructor are updated to the
record's canonical constructor semantics and preserve visibility and behavior of
other methods.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/StreamingAgentHandler.java`:
- Around line 139-141: The hook that sets the current working directory should
prefer the session's project path instead of falling back to
System.getProperty("user.dir") or a generic workingDir; update the code around
the createPermissionAwareRegistry call in StreamingAgentHandler (and the similar
block at the other occurrence) to read the project path from the session (use
the sessionId/context API that holds the project path) and pass that into the
hook's cwd, falling back to the existing workingDir only if the session project
path is absent; ensure the permission-aware registry/hook constructors (the code
paths using cancelContext and sessionId) use this session-derived path for all
command execution contexts.

In
`@aceclaw-daemon/src/test/java/dev/aceclaw/daemon/CommandHookExecutorTest.java`:
- Around line 106-114: The test exit0WithDenyDecisionBlocks is asserting a block
but the deny.sh fixture exits 0 (proceed) causing CI failure; update the fixture
script used by buildExecutor("PreToolUse", "bash", scriptPath("deny.sh")) to
exit with the block code (e.g., exit 2) and emit the block reason, or
alternatively change the test assertion to expect a HookResult.Proceed; target
the deny.sh script (or the assertion in exit0WithDenyDecisionBlocks) and ensure
the HookResult.Block.reason() value matches the emitted message when using
HookResult.Block in the test.

---

Nitpick comments:
In `@aceclaw-daemon/src/test/java/dev/aceclaw/daemon/HookIntegrationTest.java`:
- Around line 287-290: In HookIntegrationTest replace the fixed
Thread.sleep(1000) after calling sendPromptAndCollectEvents with a bounded poll
that repeatedly checks for the async PostToolUse hook completion marker (the
same condition you assert later) until a configurable timeout (e.g., 5–10s)
elapses; implement a small sleep between polls (e.g., 50–200ms), stop early when
the marker is observed, and fail the test if the timeout is reached — apply the
same change for the other occurrences around lines where
sendPromptAndCollectEvents is used so tests no longer rely on fixed sleeps.

Comment thread aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java Outdated
Comment on lines +18 to +33
public final class HookRegistry {

private static final Logger log = LoggerFactory.getLogger(HookRegistry.class);

/** Event type name → ordered list of matchers. */
private final Map<String, List<HookMatcher>> matchers;

private HookRegistry(Map<String, List<HookMatcher>> matchers) {
// Deep-copy to ensure immutability
var copy = new LinkedHashMap<String, List<HookMatcher>>();
for (var entry : matchers.entrySet()) {
copy.put(entry.getKey(), List.copyOf(entry.getValue()));
}
this.matchers = Map.copyOf(copy);
}

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:

cat -n aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java

Repository: xinhuagu/AceClaw

Length of output: 6642


Convert HookRegistry to a record for immutable data.

This class is an immutable value holder with a compact constructor performing deep-copy validation. Records are the appropriate choice per the coding guideline and will simplify the implementation while preserving all instance and static methods.

♻️ Suggested refactor
-public final class HookRegistry {
+public record HookRegistry(Map<String, List<HookMatcher>> matchers) {
     private static final Logger log = LoggerFactory.getLogger(HookRegistry.class);

-    /** Event type name → ordered list of matchers. */
-    private final Map<String, List<HookMatcher>> matchers;
-
-    private HookRegistry(Map<String, List<HookMatcher>> matchers) {
+    public HookRegistry {
         // Deep-copy to ensure immutability
         var copy = new LinkedHashMap<String, List<HookMatcher>>();
         for (var entry : matchers.entrySet()) {
             copy.put(entry.getKey(), List.copyOf(entry.getValue()));
         }
-        this.matchers = Map.copyOf(copy);
+        matchers = Map.copyOf(copy);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java` around
lines 18 - 33, Replace the immutable holder class HookRegistry with a record
declaration: declare "public record HookRegistry(Map<String, List<HookMatcher>>
matchers)". Move the static Logger (log) into the record as a static field and
keep all instance/static methods as-is. Implement a compact canonical
constructor for the record that performs the same deep-copy/validation: create a
new LinkedHashMap, copy each entry with List.copyOf(entry.getValue()), then
assign the record component to an unmodifiable copy via Map.copyOf(copy) (so the
component remains immutable). Ensure any existing usages of the private
constructor are updated to the record's canonical constructor semantics and
preserve visibility and behavior of other methods.

Comment on lines +106 to +114
@Test
void exit0WithDenyDecisionBlocks() {
var executor = buildExecutor("PreToolUse", "bash", scriptPath("deny.sh"));
var event = new HookEvent.PreToolUse("s1", "/tmp", "bash", emptyInput());

var result = executor.execute(event);
assertThat(result).isInstanceOf(HookResult.Block.class);
assertThat(((HookResult.Block) result).reason()).isEqualTo("Blocked by policy");
}

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

Test contradicts exit-code contract (CI failure).
Exit code 0 is documented as “Proceed,” but this test expects a block from deny.sh which currently exits 0. CI also reports a failure at Line 112. Align the fixture with the exit-code semantics (e.g., exit 2 for block) or change the expectation to Proceed.

🔧 Suggested fix: make deny.sh exit 2 and emit the block reason
-        // Script that exits 0 with deny decision
+        // Script that exits 2 (block) with deny decision
         writeScript("deny.sh", """
                 #!/bin/bash
-                echo '{"decision":"deny","additionalContext":"Blocked by policy"}'
+                echo '{"decision":"deny","additionalContext":"Blocked by policy"}'
+                echo "Blocked by policy" >&2
+                exit 2
                 """);
🧰 Tools
🪛 GitHub Actions: CI

[error] 112-112: Test failed: CommandHookExecutorTest.exit0WithDenyDecisionBlocks() threw java.lang.AssertionError at CommandHookExecutorTest.java:112. Gradle task ':aceclaw-daemon:test' failed during 'gradlew build test --no-daemon'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-daemon/src/test/java/dev/aceclaw/daemon/CommandHookExecutorTest.java`
around lines 106 - 114, The test exit0WithDenyDecisionBlocks is asserting a
block but the deny.sh fixture exits 0 (proceed) causing CI failure; update the
fixture script used by buildExecutor("PreToolUse", "bash",
scriptPath("deny.sh")) to exit with the block code (e.g., exit 2) and emit the
block reason, or alternatively change the test assertion to expect a
HookResult.Proceed; target the deny.sh script (or the assertion in
exit0WithDenyDecisionBlocks) and ensure the HookResult.Block.reason() value
matches the emitted message when using HookResult.Block in the test.

Xinhua Gu added 2 commits February 20, 2026 20:32
On fast CI runners, the hook process may exit before stdin write completes.
If the process doesn't read stdin, the write throws broken pipe IOException
which previously aborted the entire executeOne() method — stdout/stderr
reader threads were never started, so the hook's output was lost.

Fix: start reader threads FIRST, then write stdin with a separate
try-catch so broken pipe is non-fatal. This ensures we always capture
the process output regardless of stdin write success.
- Convert HookMatcherFormat/HookConfigFormat from mutable classes to records
- Use session's projectPath for hook cwd instead of daemon's workingDir
- Replace Thread.sleep with bounded polling in HookIntegrationTest
- Add clarifying comment on exit-0 deny test contract

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java`:
- Around line 379-387: The merge loop for fileConfig.hooks can NPE when an event
key maps to null or contains null elements; update the loop in AceClawConfig to
skip entries where hookEntry.getValue() is null or empty and only append
non-null elements to this.hooks. Specifically, in the block using
computeIfAbsent and addAll, check hookEntry.getValue() for null/empty and either
continue or filter out null items (e.g., collect non-null elements or iterate
and add only non-null) before calling addAll on this.hooks; keep references to
fileConfig.hooks, hookEntry, computeIfAbsent and addAll so the change is local
and obvious.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/StreamingAgentHandler.java`:
- Around line 1046-1089: The code picks event type by checking error != null, so
when a tool throws with a null message or an error ToolResult has null output
the wrong PostToolUse event is fired; update executeWithPostHooks and the
exception path to always pass a non-null error string for failure cases (e.g.,
use e.getMessage() != null ? e.getMessage() : "" in the catch and use
result.output() != null ? result.output() : "" when result.isError()) so
firePostHookAsync(...) receives a non-null error for failures and null for
successes (no other logic changes required to fire HookEvent.PostToolUseFailure
vs HookEvent.PostToolUse).

---

Duplicate comments:
In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/HookRegistry.java`:
- Around line 18-32: Replace the immutable holder class HookRegistry with a Java
record: declare "public record HookRegistry(Map<String, List<HookMatcher>>
matchers) { ... }", keep the static logger field (private static final Logger
log = LoggerFactory.getLogger(HookRegistry.class)); implement a compact
canonical constructor that deep-copies the incoming map and lists (e.g., build a
new LinkedHashMap, put each entry.getKey() -> List.copyOf(entry.getValue()),
then assign this.matchers = Map.copyOf(copy)) so callers get the same
defensive-copy semantics; remove the old explicit class constructor and any
mutating methods, and keep the field name matchers unchanged to preserve API.

Comment thread aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java
- AceClawConfig: skip null/empty hook lists during config merge to prevent NPE
- StreamingAgentHandler: ensure non-null error string for PostToolUseFailure
  so firePostHookAsync dispatches the correct event type
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