feat(runtime): add user-configurable PreToolUse policy hooks
- Dominant language
- TypeScript
- Stars
- 5.4k
- Forks
- 502
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 716
Description
# feat(runtime): add user-configurable `PreToolUse` policy hooks
## Summary
Add a user-configurable Agent lifecycle hook system to Maka, starting with a deliberately narrow but complete `PreToolUse` v1.
The first release should let users and projects run trusted local command hooks before a tool is dispatched. A matching hook can allow the tool or deny it with a reason that is returned to the model. It must not rewrite tool input, grant permissions, bypass the sandbox, or weaken the durable tool execution contract.
This covers policy use cases such as:
- blocking `git push` before a configured time;
- rejecting dangerous shell commands;
- protecting sensitive paths;
- enforcing project-specific checks before writes or external actions.
## Intended final result
When this feature is complete:
1. Users can define command-based `PreToolUse` hooks in user and project configuration.
2. Runtime Host discovers, validates, snapshots, and executes the effective hook set consistently for Desktop, TUI, CLI, bot, and eval surfaces.
3. Each matching hook receives a versioned JSON payload on stdin containing the tool name, validated tool input, execution origin, cwd, and run identifiers.
4. `exit 0` allows execution. `exit 2` denies execution and returns bounded stderr, or a structured denial reason, to the model.
5. Explicit denial happens before T1 durable dispatch, permission prompts, sandbox execution, and any tool side effect.
6. Hook failures such as timeout, spawn failure, ordinary non-zero exit, or invalid output fail open for user/project hooks, while producing bounded audit events.
7. Command hooks run only after the user trusts the exact normalized hook definition hash. Any execution-relevant change invalidates trust.
8. An allow decision never grants permission: the existing permission, sandbox, execution-boundary, and tool-specific validation paths still run unchanged.
9. The product exposes hook discovery, trust review, status, diagnostics, and safe fixture-based testing.
## Proposed v1 contract
### Configuration sources
Merge, without overriding, two sources:
- user: `/hooks.json`;
- project: `/.maka/hooks.json`.
Runtime Host owns State Root resolution, project identity, workspace trust, configuration discovery, and the immutable per-turn snapshot. A configuration change during a turn applies only to the next turn.
Example:
```json
{
"version": 1,
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit|apply_patch",
"hooks": [
{
"id": "work-hours-policy",
"type": "command",
"command": "/absolute/path/to/check-tool-policy",
"args": [],
"timeoutMs": 3000,
"enabled": true
}
]
}
]
}
}
```
V1 matcher rules should be bounded and predictable:
- omitted or `*`: all tools;
- `A|B`: exact-name union;
- trailing `*`: prefix match, for example `mcp__github__*`;
- no arbitrary regular expressions;
- argument-level policy remains inside the hook command, which receives the full validated `tool_input`.
Commands must use an absolute executable path and argv arguments, without an implicit shell. Apply strict schema validation, size limits, handler-count limits, timeout bounds, and bounded stdout/stderr.
### Process protocol
Each invocation receives versioned JSON on stdin, for example:
```json
{
"schema_version": 1,
"hook_event_name": "PreToolUse",
"session_id": "session-id",
"turn_id": "turn-id",
"run_id": "run-id",
"tool_use_id": "provider-tool-call-id",
"tool_name": "Bash",
"tool_input": {
"command": "git push origin main"
},
"cwd": "/host/project",
"permission_mode": "default",
"origin": "provider"
}
```
Result handling:
| Result | Runtime behavior |
| --- | --- |
| `exit 0`, empty stdout | Allow |
| `exit 0`, valid structured output | Accept v1 `allow` or `deny` |
| `exit 2` | Deny; use bounded stderr or a stable default reason |
| Other exit, spawn error, timeout, invalid output | Record failure and fail open |
| Turn abort | Terminate the complete hook process tree and settle the tool call |
All matching handlers run concurrently under a global concurrency limit. Any explicit deny wins. Multiple denials are reported in stable configuration order, not process-completion order.
### Trust and security model
Command hooks execute outside the Agent tool sandbox with Runtime Host user privileges, so configuration presence alone must never authorize execution.
Hash the normalized execution definition, including source identity, event, matcher, handler type, command, args, and timeout. Store trust by project identity plus definition hash. First discovery requires review; any execution-relevant edit creates a new hash and requires review again.
The command runner must:
- spawn argv directly without a shell;
- use the Host-resolved session cwd;
- provide a minimal environment and strip provider/API/OAuth/secret variables;
- write JSON once to stdin and close it;
- enforce timeout and abort by terminating the whole process tree;
- retain only bounded stdout/stderr;
- prevent recursive Hook execution and disallow permission or Client Capability requests from hooks.
## Runtime integration
The single integration point should be `ToolRuntime.executeTool()`, because built-in tools, Computer Use, MCP tools, and Code Mode nested calls converge there.
Recommended ordering:
```text
persist model tool_call intent
-> existing argument/admission/loop/deferred/client-capability guards
-> run PreToolUse hook snapshot
deny -> generic tool_start + synthetic error tool_result, no T1
fail -> hook audit event, continue
allow -> continue
-> emit dispatch-lane tool_start
-> prepareDurableToolAttempt() / commit T1
-> permission + sandbox + tool.impl()
-> commit T2 / tool_result
```
This preserves three invariants:
1. invalid or unavailable tools do not launch external hooks;
2. denial occurs before T1 and before any tool side effect;
3. allowance still proceeds through the existing durable execution, permission, sandbox, and tool validation paths.
A denial should reuse the current pre-dispatch refusal semantics, produce a paired synthetic error result, and never allocate an operation ID.
Runtime Host should own configuration/trust stores, project composition, per-turn snapshots, dispatcher injection, active-process cleanup, and cross-surface consistency. Add a bounded `hook_completed` runtime audit event without copying raw command input or complete process output into the transcript.
## Delivery plan
### PR 1: configuration, trust, and execution kernel
- Add core schemas, normalized types, and bounded matcher behavior.
- Add user configuration and trust stores with atomic persistence and strict file bounds.
- Implement command spawning with JSON stdin, minimal environment, bounded output, timeout, abort, and process-tree cleanup.
- Implement concurrent dispatch, deterministic deny aggregation, and bounded audit results.
- Compose effective hook snapshots in Runtime Host.
- Add unit tests for schema limits, matching, hash invalidation, exit `0`/`2`, structured output, timeout, abort, concurrent denial, and output caps.
- Preserve zero-config behavior.
### PR 2: `ToolRuntime` integration and durable-execution coverage
- Insert `PreToolUse` after existing preflight guards and before dispatch-lane `tool_start`/T1.
- Cover Bash, file mutation tools, Computer Use, MCP, and Code Mode nested tools through the single runtime seam.
- Return a paired synthetic error tool result on denial without creating T1 or an operation ID.
- Verify that allow cannot skip permission or sandbox checks.
- Add fault-injection and recovery tests for deny-before-T1, allow-before-T1 crashes, and unchanged T1/T2 ordering.
- Add value-free telemetry for match count, result status, and duration.
### PR 3: product surfaces and migration guidance
- Add a Hooks settings view showing source, matcher, trust state, enabled state, and last result.
- Add an exact-definition review dialog for command, args, cwd/source, and definition hash.
- Surface configuration errors and untrusted-hook diagnostics consistently in Desktop, TUI, and CLI.
- Add fixture-based "Test hook" support that cannot execute a real tool.
- Document Claude Code/Codex migration examples and the boundary between Maka policy hooks, Git hooks, and hosting-provider rulesets.
## Acceptance criteria
- [ ] A denied tool implementation is invoked zero times.
- [ ] Denial creates no T1 operation ID and returns exactly one paired error tool result to the model.
- [ ] Allow does not change permission, sandbox, execution-boundary, or tool validation outcomes.
- [ ] Any execution-relevant edit to a trusted hook invalidates trust by the next turn.
- [ ] Desktop, TUI, CLI, bot, and eval resolve the same effective snapshot for the same State Root and project.
- [ ] Timeout and abort leave no child or grandchild processes behind.
- [ ] No configuration or no matcher hit adds less than 0.1 ms p95 CPU time to `executeTool()`.
- [ ] Matching handlers execute concurrently; latency is dominated by the slowest handler rather than their sum.
- [ ] Raw hook input/output is not copied into provider context; only a bounded denial reason may be returned.
- [ ] Zero-config behavior remains unchanged.
## Explicitly out of scope for v1
- `PostToolUse`, `Stop`, session, subagent, and compaction lifecycle events;
- HTTP, MCP, prompt, and agent handlers;
- asynchronous/background hooks;
- input rewriting or additional-context injection;
- using allow to auto-approve permissions;
- managed organization policy or project-configurable fail-closed behavior;
- hooks calling Maka tools;
- arbitrary-regex or shell-string matchers.
## References
- [Codex Hooks](https://learn.chatgpt.com/docs/hooks)
- [Codex hook schemas and implementation](https://github.com/openai/codex/tree/main/codex-rs/hooks)
- [Claude Code hooks reference](https://code.claude.com/docs/en/hooks)
- [Claude Code hooks guide](https://code.claude.com/docs/en/hooks-guide)
- [Claude Code permissions](https://code.claude.com/docs/en/permissions)
- [Claude Code settings](https://code.claude.com/docs/en/settings)
Contributor guide
Assessment
This issue has not been assessed yet.