Skip to content

feat(runtime): add trusted PreToolUse policy hooks - #2910

Open
xxhZs wants to merge 3 commits into
apache:mainfrom
xxhZs:feature/agent-lifecycle-hooks
Open

feat(runtime): add trusted PreToolUse policy hooks#2910
xxhZs wants to merge 3 commits into
apache:mainfrom
xxhZs:feature/agent-lifecycle-hooks

Conversation

@xxhZs

@xxhZs xxhZs commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduce the backend foundation for user- and project-configurable PreToolUse policy hooks.

This PR implements the first two backend phases of #2908:

  • a bounded, command-only Hook configuration and exact-definition trust model;
  • Runtime Host composition and immutable per-Turn snapshots;
  • a process runner and decision engine with deterministic denial semantics;
  • the ToolRuntime integration point before durable dispatch (T1) and tool side effects;
  • hidden, bounded runtime audit events;
  • end-to-end coverage proving that a configured and trusted Hook can block a real tool call.

The goal is to support policies such as blocking git push, rejecting dangerous shell commands, or protecting project paths without allowing Hooks to bypass Maka's existing permission, sandbox, and durable-execution boundaries.

Refs #2908

Configuration and trust contract

Runtime Host loads and merges:

  • user Hooks from <State Root>/hooks.json;
  • project Hooks from <cwd>/.maka/hooks.json.

V1 intentionally supports only PreToolUse command handlers. Matchers are bounded to *, exact-name unions such as Bash|Write, and trailing-prefix matches such as mcp__github__*. Commands must be absolute executable paths and are spawned with argv directly, never through an implicit shell.

{
  "version": 1,
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|apply_patch",
        "hooks": [
          {
            "id": "project-policy",
            "type": "command",
            "command": "/absolute/path/to/check-tool-policy",
            "args": [],
            "timeoutMs": 3000,
            "enabled": true
          }
        ]
      }
    ]
  }
}

Each enabled definition is normalized and hashed from its source, project identity, event, matcher, command, arguments, and timeout. Trust records live in <State Root>/hook-trust.json; an execution-relevant change produces a new hash and is skipped until that exact definition is trusted again.

The effective Hook set is snapshotted once per Turn, so configuration changes cannot alter policy midway through an active Turn.

Execution semantics

Each matching trusted handler receives versioned JSON on stdin containing the session, Turn, run, tool call, validated tool input, cwd, permission mode, and origin.

Decision handling is deliberately narrow:

Hook result Runtime behavior
exit 0 with empty stdout Allow
exit 0 with valid structured output Apply the explicit allow or deny decision
exit 2 Deny, using bounded stderr as the reason
Timeout, spawn failure, ordinary non-zero exit, or invalid output Audit the failure and fail open
Untrusted matching definition Skip execution and audit skipped_untrusted

Matching handlers execute concurrently under a global limit. Any explicit denial wins, and multiple denial reasons are returned in stable configuration order rather than process-completion order.

The Hook gate runs in ToolRuntime.executeTool() after the existing admission/preflight guards and before prepareDurableToolAttempt():

existing tool admission and loop guards
  -> PreToolUse snapshot
       deny  -> paired synthetic error result, no T1, no operation ID, no tool side effect
       fail  -> bounded audit, continue
       allow -> existing durable dispatch, permission, sandbox, and tool implementation path

An allow result therefore does not grant permission or weaken any existing execution boundary.

Security and failure boundaries

  • Hook processes run with shell: false, the Host-resolved cwd, and a minimal environment allowlist.
  • stdout and stderr are bounded to 64 KiB; denial and audit messages are bounded again before entering runtime state.
  • timeouts and Turn aborts terminate the complete process tree.
  • configuration and trust files use strict schemas, size/count limits, serialized updates, atomic replacement, and private file modes where supported.
  • Hook audit facts are written as model-hidden hookCompleted RuntimeEvents; raw tool input and complete process output are not copied into the transcript.
  • dispatcher failures and audit-write failures do not make tools unavailable; they fail open and emit trace diagnostics.

Verification

Local verification:

  • Runtime: 2755 passed / 9 skipped / 0 failed
  • Runtime Host: 831 passed / 0 failed

The added tests cover:

  • strict configuration normalization, private atomic persistence, trust/revoke, and invalid input rejection;
  • per-Turn snapshotting and exact-hash trust invalidation on the next Turn;
  • bounded matcher normalization and rejection of malformed wildcard forms;
  • concurrent execution with deterministic denial aggregation;
  • JSON stdin, exit 0, exit 2, and structured allow/deny output;
  • fail-open behavior for handler, timeout, abort, dispatcher, and audit failures;
  • pre-T1 denial with no operation ID and no tool implementation side effect;
  • a real end-to-end git push policy fixture spanning config, trust, Runtime Host, ToolRuntime, SQLite audit persistence, and the synthetic tool result.

CI for 7589253f5 is green:

  • CI: changes, typecheck, Runtime Host, workspace tests, desktop e2e, Storybook, and aggregate test gate;
  • Dependency audit;
  • Windows baseline;
  • Windows recovery.

Follow-up scope

This PR does not add the product-facing Hooks settings page, trust-review dialog, diagnostics UI, fixture-based “Test hook” action, or migration documentation. Those remain the third delivery phase tracked in #2908. Until that surface lands, this PR should be reviewed as the backend contract and runtime enforcement path, not as the complete end-user workflow.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — configured, trusted PreToolUse Hooks can deny a tool before durable dispatch
  • No

@xxhZs xxhZs closed this Aug 12, 2026
@xxhZs xxhZs reopened this Aug 12, 2026

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex automated review

The central ownership choice looks sound: one Runtime Host-composed dispatcher enters through ToolRuntime after preflight and before T1, so a denial cannot grant permission or create a durable attempt. The real-process and end-to-end coverage also exercises more than a fake-only seam.

I found three reproducible contract gaps, all rated P2 below. I did not find a P0/P1 issue, low-value test block worth deleting, or an independent product slice that would become easier to verify if split from this end-to-end backend contract.

Disclosure: This is an automated review performed by Codex using delegated adversarial review passes and a final evidence check. It has not been independently verified by Astro-Han or another human reviewer, does not constitute human approval, and does not represent the final judgment of a human reviewer.

Comment thread packages/runtime/src/hooks/engine.ts Outdated
Comment thread packages/runtime/src/hooks/engine.ts
Comment thread packages/runtime/src/hooks/command-runner.ts
@xxhZs
xxhZs force-pushed the feature/agent-lifecycle-hooks branch from 7589253 to c138db7 Compare August 13, 2026 07:46
@xxhZs
xxhZs force-pushed the feature/agent-lifecycle-hooks branch from c138db7 to eeba92f Compare August 13, 2026 07:52
@likun666661

Copy link
Copy Markdown
Member

I think the runtime integration is directionally sound, especially the single ToolRuntime seam and denial before T1/side effects. I do have two product-definition concerns that seem worth resolving explicitly:

  1. Is this a best-effort guardrail or a security enforcement boundary? The current contract fails open on timeout, spawn failure, ordinary non-zero exit, invalid output, and dispatcher failure. That is a reasonable default for user-configured extensibility, but it means statements such as “protect sensitive paths” or “block dangerous commands” are conditional on the Hook executing successfully. I suggest documenting the guarantee precisely: v1 provides best-effort user/project guardrails, not a fail-closed organizational policy boundary. If reliable enforcement is an intended use case, failure mode needs to be part of the policy contract rather than fixed to fail-open.

  2. What demonstrated need requires a general executable Hook rather than bounded declarative deny rules? The examples currently given—blocking git push, rejecting dangerous shell commands, and protecting paths—could be expressed by a much smaller declarative rule system. A command Hook is justified if the actual requirement includes arbitrary dynamic policy, reuse of existing project policy scripts, external-state checks, or migration compatibility with other agent Hook ecosystems. If that is the intent, the issue should state those concrete requirements and explain why a declarative rule primitive is insufficient. Otherwise, the current solution may be substantially more infrastructure than the listed use cases require.

I am not concerned that trust hashes the command definition rather than executable contents; treating integrity of a user-trusted local command as the user responsibility is a reasonable boundary.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This head fixes the earlier snapshot lifetime, Host-wide concurrency, and cross-process recursion issues, and the pre-T1 denial boundary is well placed. One process-ownership gap remains, and the branch currently conflicts with main in the Runtime event/TurnOrigin authority.

The first-principles model should be that the hook runner owns one killable OS job/process group for the entire command lifetime, independent of whether the original child PID has already exited. Rebase first, preserve both current TurnOrigin validation and the new hook event schema, then make termination target that durable ownership unit and test a root that spawns a grandchild and exits before timeout.

Reviewed with Codex using two independent review passes; I verified the process-tree path and current merge conflict against this head and current main.

中文

当前 head 已修复之前的 snapshot 生命周期、Host 全局并发和跨进程递归问题,T1 之前的拒绝边界也正确。仍有一个进程所有权缺口,并且当前分支与 main 的 Runtime event/TurnOrigin 权威冲突。

第一性原理下,hook runner 应拥有一个在整个命令生命周期内都可终止的 OS job/process group,不依赖原始 child PID 是否已经退出。先 rebase 并保留两边的权威逻辑,再按该 ownership unit 终止,并测试 root 先退出、grandchild 仍存活时的 timeout。

本次由 Codex 进行两轮独立审查,并核对了当前 head 与最新 main

let inputError: string | undefined;
const timer = setTimeout(() => {
timedOut = true;
lifecycle.terminate();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 — Timeout can leave a grandchild running after the root exits. lifecycle.terminate() ultimately skips process-group termination once the original child reports exited. If that child spawned a grandchild inheriting stdio and exited first, timeout/abort can complete without killing the surviving process. Track a killable process group/job independently of the root PID's exit state, and add a real grandchild regression where the root exits before timeout.

@Astro-Han

Copy link
Copy Markdown
Contributor

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Malformed project config bypasses policy 🐞 Bug ⛨ Security
Description
Fix-now: loadSnapshot loads user and project configuration together, so a malformed, oversized,
unreadable, or invalid project .maka/hooks.json rejects the entire snapshot; ToolRuntime then
fails open and dispatches the tool without enforcing otherwise-valid trusted user hooks. An
untrusted project can trigger this bypass with content such as {} or more than 128 handlers,
without gaining hook trust.
Code

packages/runtime-host/src/server/host-hook-composition.ts[R77-80]

+  const [userConfig, projectConfig, trust] = await Promise.all([
+    input.userConfig.get(),
+    readHookConfigFile(join(input.header.cwd, '.maka', 'hooks.json')),
+    input.trust.get(),
Relevance

●●● Strong

Accepted fail-open configuration isolation pattern supports preserving valid trusted policy despite
malformed unrelated input.

PR-#2102

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
readHookConfigFile throws for non-ENOENT read errors, oversized content, malformed JSON, and
invalid configuration, while the host combines the user configuration, project configuration, and
trust reads in one rejecting Promise.all. That error propagates through the dispatcher to the new
ToolRuntime gate, which explicitly logs and continues tool dispatch when snapshot loading throws;
because project definitions are separately trust-gated, an untrusted project file can therefore
suppress trusted user definitions before durable dispatch.

packages/runtime-host/src/server/host-hook-composition.ts[69-96]
packages/storage/src/hook-config-store.ts[29-50]
packages/runtime/src/tool-runtime.ts[1267-1298]
packages/runtime-host/src/server/host-hook-composition.ts[74-95]
packages/storage/src/hook-config-store.ts[29-40]
packages/runtime/src/hooks/engine.ts[94-112]
packages/runtime/src/tool-runtime.ts[1339-1349]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An invalid project hook file currently rejects the shared snapshot load, causing the caller's fail-open handling to skip trusted user hooks and dispatch the tool.

## Issue Context
Project configuration is untrusted until exact-definition trust is established and must not control whether separately configured user policies are loaded. Load the project configuration independently and, on read, parse, or normalization failure, use the empty hook configuration for that source while preserving the successfully loaded user configuration and trust snapshot; reuse the existing per-source loaders, keep fail-open handling for genuine hook execution/runtime failures, and add no new public configuration or authority.

## Fix Focus Areas
- packages/runtime-host/src/server/host-hook-composition.ts[69-96]
- packages/runtime/src/tool-runtime.ts[1267-1298]
- packages/runtime-host/src/__tests__/host-hook-composition.test.ts[19-108]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Config limit reads unbounded file 🐞 Bug ⛨ Security
Description
Fix-now: readHookConfigFile calls readFile before enforcing the 1 MiB limit, so a
project-controlled .maka/hooks.json can force the host to allocate and decode an arbitrarily large
file on Turn snapshot creation. This defeats the PR's bounded configuration contract and can cause
severe memory pressure before the file is rejected.
Code

packages/storage/src/hook-config-store.ts[R31-34]

+  try {
+    text = await readFile(path, 'utf8');
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createDefaultHookConfig();
Relevance

●●● Strong

PR #3176 accepted the same post-allocation bounded-file flaw and replaced unrestricted reads with
bounded regular-file reading.

PR-#3176

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The configured 1 MiB guard executes only after an unrestricted readFile; this file reader is used
directly on the project-controlled path during every new Turn snapshot. Past PR #3176 fixed the same
post-allocation bound pattern by replacing readFile with a bounded regular-file reader.

packages/storage/src/hook-config-store.ts[29-40]
packages/runtime-host/src/server/host-hook-composition.ts[74-80]
PR-#3176

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The project configuration size limit is checked only after `readFile` has allocated and decoded the entire file.

## Issue Context
Reuse a bounded file-read seam that opens the file, rejects oversized/non-regular inputs before allocation, and preferably avoids following symlinks. A new bounded-read helper is necessary because `readFile` cannot enforce a pre-allocation byte limit; this adds one internal helper and associated filesystem edge-case tests, but no public surface or configuration.

## Fix Focus Areas
- packages/storage/src/hook-config-store.ts[29-40]
- packages/storage/src/__tests__/hook-config-store.test.ts[38-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Dropped stdout becomes allow 🐞 Bug ⛨ Security
Description
Fix-now: an oversized unterminated stdout line is deliberately erased by BashTailBuffer, but the
runner discards the buffer's hasDroppedUnsafe() state and the engine interprets the resulting
empty stdout as an allow. A hook that emits a deny decision plus sufficient same-line output can
therefore be converted from deny to allow.
Code

packages/runtime/src/hooks/engine.ts[R193-194]

+  const stdout = result.stdout.trim();
+  if (!stdout) return auditFor(definition, input, 'allowed', durationMs);
Relevance

●●● Strong

Security finding is a deterministic fail-open conversion from explicitly denied hook output to
allow.

PR-#2535

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The buffer explicitly records when content existed but had to be erased so callers do not mistake it
for no output, yet the runner returns only value() and the engine treats an empty value as
allowed.

packages/runtime/src/bash-tail-buffer.ts[19-35]
packages/runtime/src/bash-tail-buffer.ts[53-79]
packages/runtime/src/hooks/command-runner.ts[56-61]
packages/runtime/src/hooks/engine.ts[193-205]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Unsafe stdout truncation becomes indistinguishable from genuinely empty stdout, which changes a policy decision into allow.

## Issue Context
Reuse `BashTailBuffer.hasDroppedUnsafe()` and the existing command failure path. The smallest correction is to return an existing failure indication after materializing stdout when unsafe content was dropped; no new decision type or public surface is needed.

## Fix Focus Areas
- packages/runtime/src/hooks/command-runner.ts[56-61]
- packages/runtime/src/hooks/command-runner.ts[93-109]
- packages/runtime/src/hooks/engine.ts[193-206]
- packages/runtime/src/__tests__/hooks-engine.test.ts[184-212]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (2)
4. Abort can miss spawned hook 🐞 Bug ☼ Reliability
Description
Fix-now: cancellation is checked before spawning and then subscribed only after the child and
lifecycle are created, with no post-subscription recheck. If the Turn aborts in that interval, the
event is missed and the hook process can continue until normal exit or timeout instead of being
terminated with the Turn.
Code

packages/runtime/src/hooks/command-runner.ts[R82-86]

+  const abort = () => {
+    aborted = true;
+    lifecycle.terminate();
+  };
+  abortSignal.addEventListener('abort', abort, { once: true });
Relevance

●●● Strong

Recent cancellation reliability precedents favor closing races where abort can miss spawned external
work.

PR-#2674

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The only early abort check precedes process creation, while listener registration occurs later and
AbortSignal does not replay an abort event to listeners added after it fired. The completion path
then waits on the child lifecycle with aborted still false.

packages/runtime/src/hooks/command-runner.ts[34-50]
packages/runtime/src/hooks/command-runner.ts[75-101]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An abort between the initial check and listener registration is never observed by the hook runner.

## Issue Context
Make the smallest local correction: after registering the existing listener, immediately recheck `abortSignal.aborted` and invoke the same termination callback. No new state, branch authority, or public API is required.

## Fix Focus Areas
- packages/runtime/src/hooks/command-runner.ts[34-50]
- packages/runtime/src/hooks/command-runner.ts[75-93]
- packages/runtime/src/__tests__/hooks-engine.test.ts[265-284]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Concurrent revocation can be lost 🐞 Bug ⛨ Security
Description
Fix-now: FileHookTrustStore protects read-modify-write only with an instance-local queue, so
separate stores for the same state root can read the same trust file and overwrite each other's
atomic renames. A concurrent revoke(A) and trust(B) can end with [A,B], silently restoring the
revoked executable definition.
Code

packages/storage/src/hook-trust-store.ts[R84-87]

+    return this.serial(async () => {
+      const current = await this.read();
+      const next = normalizeHookTrust({
+        version: HOOK_TRUST_VERSION,
Relevance

●● Moderate

Concurrent read-modify-write loss is plausible, but no close same-store concurrency precedent was
found.

PR-#1742

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Every factory call creates an object with its own queue, while both trust and revoke calculate
replacements from a prior read and atomically replace the whole file. Atomic rename prevents partial
files but cannot prevent one independently computed replacement from resurrecting a record removed
by another.

packages/storage/src/hook-trust-store.ts[22-24]
packages/storage/src/hook-trust-store.ts[70-112]
packages/storage/src/hook-trust-store.ts[128-159]
packages/storage/src/write-queue.ts[1-25]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Trust mutations from separate store instances are not serialized, allowing lost additions and, critically, lost revocations.

## Issue Context
Consolidate mutation authority for each canonical trust-file path and reuse the storage package's keyed write-queue seam rather than adding another per-instance queue. If multiple host processes may mutate this file, the same invariant also requires a filesystem lock or a single host-owned mutation authority; this introduces lock lifecycle/error handling only if process-level consolidation is insufficient.

## Fix Focus Areas
- packages/storage/src/hook-trust-store.ts[22-24]
- packages/storage/src/hook-trust-store.ts[70-112]
- packages/storage/src/hook-trust-store.ts[147-159]
- packages/storage/src/write-queue.ts[1-25]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Queued hooks ignore cancellation 🐞 Bug ☼ Reliability
Description
When the shared execution limiter is full, an aborted tool call remains queued until another hook
releases a slot, delaying turn settlement for up to the other hooks' timeout rather than stopping
with its Turn. Disposition: fix-now.
Code

packages/runtime/src/hooks/engine.ts[R119-122]

+        const startedAt = now();
+        const result = await executionLimiter.run(() =>
+          commandRunner.run(definition, hookInput, abortSignal),
+        );
Relevance

●●● Strong

PR #3169 directly established pending external work must observe cancellation promptly rather than
wait for fixed timeout.

PR-#3169

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The dispatcher awaits the global limiter directly, but the limiter's queue stores only no-argument
callbacks and exposes no cancellation path. ToolRuntime passes the turn AbortSignal to the
dispatcher and waits for all active settlements in endTurn; command-runner's early abort check
cannot run until the queued operation is eventually admitted. This repeats the accepted
cancellation-propagation bug pattern from PR #3169.

packages/runtime/src/hooks/engine.ts[55-82]
packages/runtime/src/hooks/engine.ts[110-129]
packages/runtime/src/hooks/command-runner.ts[34-42]
packages/runtime/src/tool-runtime.ts[587-596]
PR-#3169

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The global Hook limiter has no AbortSignal support. A tool call that is waiting for a concurrency slot cannot settle when its Turn is aborted; ToolRuntime waits for active settlements during endTurn, so unrelated running Hooks can hold cancellation until their timeout.

## Issue Context
Checking `abortSignal` only in the command runner is too late because the queued operation has not entered the runner. Reuse the existing signal already passed to `runPreToolUse`; do not add a new public cancellation authority.

## Fix Focus Areas
- packages/runtime/src/hooks/engine.ts[51-82]
- packages/runtime/src/hooks/engine.ts[110-129]
- packages/runtime/src/hooks/command-runner.ts[34-42]

Make queued limiter work removable/rejectable on abort (and make snapshot waiting return promptly on abort), then have the dispatcher preserve ToolRuntime's existing abort propagation. Ensure an aborted queued item never starts a command after a slot becomes available.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: 🧠 Deep: This introduces security-sensitive command execution and trust semantics across configuration, process isolation, snapshotting, concurrency, auditing, and durable tool-dispatch paths, with many independent logic sites where redundant review can catch subtle defects.
ⓘ  5 issues published inline · 6 in summary

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +77 to +80
const [userConfig, projectConfig, trust] = await Promise.all([
input.userConfig.get(),
readHookConfigFile(join(input.header.cwd, '.maka', 'hooks.json')),
input.trust.get(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Malformed project config bypasses policy 🐞 Bug ⛨ Security

Fix-now: loadSnapshot loads user and project configuration together, so a malformed, oversized,
unreadable, or invalid project .maka/hooks.json rejects the entire snapshot; ToolRuntime then
fails open and dispatches the tool without enforcing otherwise-valid trusted user hooks. An
untrusted project can trigger this bypass with content such as {} or more than 128 handlers,
without gaining hook trust.
Agent Prompt
## Issue description
An invalid project hook file currently rejects the shared snapshot load, causing the caller's fail-open handling to skip trusted user hooks and dispatch the tool.

## Issue Context
Project configuration is untrusted until exact-definition trust is established and must not control whether separately configured user policies are loaded. Load the project configuration independently and, on read, parse, or normalization failure, use the empty hook configuration for that source while preserving the successfully loaded user configuration and trust snapshot; reuse the existing per-source loaders, keep fail-open handling for genuine hook execution/runtime failures, and add no new public configuration or authority.

## Fix Focus Areas
- packages/runtime-host/src/server/host-hook-composition.ts[69-96]
- packages/runtime/src/tool-runtime.ts[1267-1298]
- packages/runtime-host/src/__tests__/host-hook-composition.test.ts[19-108]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +31 to +34
try {
text = await readFile(path, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createDefaultHookConfig();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Config limit reads unbounded file 🐞 Bug ⛨ Security

Fix-now: readHookConfigFile calls readFile before enforcing the 1 MiB limit, so a
project-controlled .maka/hooks.json can force the host to allocate and decode an arbitrarily large
file on Turn snapshot creation. This defeats the PR's bounded configuration contract and can cause
severe memory pressure before the file is rejected.
Agent Prompt
## Issue description
The project configuration size limit is checked only after `readFile` has allocated and decoded the entire file.

## Issue Context
Reuse a bounded file-read seam that opens the file, rejects oversized/non-regular inputs before allocation, and preferably avoids following symlinks. A new bounded-read helper is necessary because `readFile` cannot enforce a pre-allocation byte limit; this adds one internal helper and associated filesystem edge-case tests, but no public surface or configuration.

## Fix Focus Areas
- packages/storage/src/hook-config-store.ts[29-40]
- packages/storage/src/__tests__/hook-config-store.test.ts[38-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +193 to +194
const stdout = result.stdout.trim();
if (!stdout) return auditFor(definition, input, 'allowed', durationMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Dropped stdout becomes allow 🐞 Bug ⛨ Security

Fix-now: an oversized unterminated stdout line is deliberately erased by BashTailBuffer, but the
runner discards the buffer's hasDroppedUnsafe() state and the engine interprets the resulting
empty stdout as an allow. A hook that emits a deny decision plus sufficient same-line output can
therefore be converted from deny to allow.
Agent Prompt
## Issue description
Unsafe stdout truncation becomes indistinguishable from genuinely empty stdout, which changes a policy decision into allow.

## Issue Context
Reuse `BashTailBuffer.hasDroppedUnsafe()` and the existing command failure path. The smallest correction is to return an existing failure indication after materializing stdout when unsafe content was dropped; no new decision type or public surface is needed.

## Fix Focus Areas
- packages/runtime/src/hooks/command-runner.ts[56-61]
- packages/runtime/src/hooks/command-runner.ts[93-109]
- packages/runtime/src/hooks/engine.ts[193-206]
- packages/runtime/src/__tests__/hooks-engine.test.ts[184-212]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +82 to +86
const abort = () => {
aborted = true;
lifecycle.terminate();
};
abortSignal.addEventListener('abort', abort, { once: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

4. Abort can miss spawned hook 🐞 Bug ☼ Reliability

Fix-now: cancellation is checked before spawning and then subscribed only after the child and
lifecycle are created, with no post-subscription recheck. If the Turn aborts in that interval, the
event is missed and the hook process can continue until normal exit or timeout instead of being
terminated with the Turn.
Agent Prompt
## Issue description
An abort between the initial check and listener registration is never observed by the hook runner.

## Issue Context
Make the smallest local correction: after registering the existing listener, immediately recheck `abortSignal.aborted` and invoke the same termination callback. No new state, branch authority, or public API is required.

## Fix Focus Areas
- packages/runtime/src/hooks/command-runner.ts[34-50]
- packages/runtime/src/hooks/command-runner.ts[75-93]
- packages/runtime/src/__tests__/hooks-engine.test.ts[265-284]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +84 to +87
return this.serial(async () => {
const current = await this.read();
const next = normalizeHookTrust({
version: HOOK_TRUST_VERSION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Concurrent revocation can be lost 🐞 Bug ⛨ Security

Fix-now: FileHookTrustStore protects read-modify-write only with an instance-local queue, so
separate stores for the same state root can read the same trust file and overwrite each other's
atomic renames. A concurrent revoke(A) and trust(B) can end with [A,B], silently restoring the
revoked executable definition.
Agent Prompt
## Issue description
Trust mutations from separate store instances are not serialized, allowing lost additions and, critically, lost revocations.

## Issue Context
Consolidate mutation authority for each canonical trust-file path and reuse the storage package's keyed write-queue seam rather than adding another per-instance queue. If multiple host processes may mutate this file, the same invariant also requires a filesystem lock or a single host-owned mutation authority; this introduces lock lifecycle/error handling only if process-level consolidation is insufficient.

## Fix Focus Areas
- packages/storage/src/hook-trust-store.ts[22-24]
- packages/storage/src/hook-trust-store.ts[70-112]
- packages/storage/src/hook-trust-store.ts[147-159]
- packages/storage/src/write-queue.ts[1-25]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

3 participants