@hydra-acp/approver
v0.1.13
Published
Headless permission auto-approver extension for hydra-acp.
Downloads
1,738
Readme
hydra-acp-approver
Headless permission auto-responder extension for hydra-acp.
Attaches to every live hydra session and answers session/request_permission based on a JavaScript rule function you provide. When the rule matches, the approver wins the race and dismisses the permission prompt before any human client sees it. When the rule abstains, the request stays open so your interactive clients (slack, TUI, browser) can still answer it normally.
Install
From npm (recommended once published):
npm install -g @hydra-acp/cli @hydra-acp/approverThis drops the hydra-acp (and hydra) CLI plus a hydra-acp-approver binary on your PATH.
Or from source:
git clone [email protected]:smagnuso/hydra-acp-approver.git ~/dev/hydra-acp-approver
cd ~/dev/hydra-acp-approver
npm install
npm run buildRegister the extension with hydra. If installed via npm:
hydra-acp extensions add hydra-acp-approver --command hydra-acp-approverOr pointed at a local build:
hydra-acp extensions add hydra-acp-approver \
--command node \
--args ~/dev/hydra-acp-approver/dist/index.jsThat writes the equivalent entry into ~/.hydra-acp/config.json:
{
"extensions": {
"hydra-acp-approver": {
"command": ["node"],
"args": ["/home/you/dev/hydra-acp-approver/dist/index.js"],
"enabled": true
}
}
}On hydra-acp daemon start, hydra spawns hydra-acp-approver as a managed
process with these env vars set: HYDRA_ACP_DAEMON_URL, HYDRA_ACP_TOKEN,
HYDRA_ACP_WS_URL. Stdout/stderr land in
~/.hydra-acp/extensions/hydra-acp-approver.log. Lifecycle is managed with
hydra-acp extensions start|stop|restart hydra-acp-approver and
hydra-acp extensions log hydra-acp-approver -f to tail.
Configure
Drop a JS module at ~/.hydra-acp/approver.config.js (override with HYDRA_ACP_APPROVER_CONFIG). Default-export a function that decides per request:
// ~/.hydra-acp/approver.config.js
export default function approve(req) {
// req.toolCall.kind is one of: "read", "edit", "execute", "search",
// "delete", "move", "fetch", "switch_mode", "think", "other".
const kind = req.toolCall?.kind;
if (["read", "search", "other", "execute"].includes(kind)) {
return req.options.find((o) => o.kind === "allow_once")?.optionId ?? null;
}
// Return null/undefined to abstain — the request stays open and a
// human client handles it as usual.
return null;
}Prefer
allow_once— agents typically cacheallow_alwayschoices locally and bypass the approver on subsequent identical calls.
Built-in default rule
When no config file is present, the approver applies a built-in default rule defined in src/rule.ts (DEFAULT_RULE). It auto-approves read/search/other, auto-approves execute unless the serialized tool call matches one of a list of danger patterns (rm -rf /, dd of=/dev/..., fork bombs, piping curl into sh, system-state changes like shutdown/reboot, and friends), and abstains on every other kind. When it abstains, the request stays open so an interactive client (Slack, TUI, browser) can prompt a human.
Patterns are matched against JSON.stringify(toolCall), so they catch whichever field the agent put the command in (rawInput.command, terminal blocks in content, the title, etc.). Abstaining is safe — the request stays open — so the list errs on the side of being broad.
Treat the default as a starting point, not a security boundary — pattern-based detection inevitably misses things, and an agent that can craft commands can probably evade any list. The win is "no permission prompts for the 99% case, human-in-the-loop for the obviously-irreversible 1%."
Drop a JS file at ~/.hydra-acp/approver.config.js to override it entirely. See src/rule.ts if you want to copy the default and extend it.
Request shape
interface PermissionRequest {
sessionId: string;
toolCall: {
toolCallId: string;
name?: string;
title?: string;
kind?: string;
[k: string]: unknown;
};
options: ReadonlyArray<{
optionId: string;
name: string;
kind?: "allow_once" | "allow_always" | "reject_once" | "reject_always" | string;
}>;
cwd?: string;
agentId?: string;
}Return value
| Return | Behavior |
|--------------|------------------------------------------------------------------------------------------------------|
| string | An optionId from req.options. Approver responds with { outcome: { outcome: "selected", optionId } } and wins the race against other attached clients. |
| null / undefined | Abstain. Approver doesn't respond; other attached clients (humans) see the prompt. |
| Promise<...> | Awaited. Same semantics on resolve. |
| Throw | Caught + logged + treated as abstain — safe-by-default if your rule has a bug. |
If optionId doesn't appear in req.options (typo, agent-specific renaming), the approver abstains and logs a warning.
Reload
Edits to approver.config.js are picked up automatically — the approver watches the file and re-imports it (with cache-busting) on every save. The next permission request after the reload completes uses the fresh rule.
If fs.watch is unreliable on your filesystem (NFS, some network mounts, certain container layouts), trigger a reload manually:
hydra-acp extensions restart hydra-acp-approver
# or, lighter, just re-import the rule without bouncing the WS attaches:
kill -HUP $(cat ~/.hydra-acp/extensions/hydra-acp-approver.pid)Pending (already-abstained) requests are unaffected; new requests use the fresh rule.
Broken config
If the config file exists but fails to load (syntax error, no default export, throw at import time, etc.), the approver abstains on every request rather than silently falling back to the built-in default — a broken config shouldn't quietly auto-approve anything. Fix the file and either save it (auto-reloads) or SIGHUP the process.
Dangerously allow all
Set HYDRA_ACP_APPROVER_DANGEROUSLY_ALLOW_ALL=1 to auto-approve every permission request — no kind check, no danger list, no human in the loop. The rule config file is ignored entirely (not loaded, not watched). This is the equivalent of Claude Code's --dangerously-skip-permissions: convenient for sandboxes and throwaway VMs, reckless on anything you care about.
Wire it through the extension's env in ~/.hydra-acp/config.json:
{
"extensions": {
"hydra-acp-approver": {
"command": ["hydra-acp-approver"],
"env": { "HYDRA_ACP_APPROVER_DANGEROUSLY_ALLOW_ALL": "1" },
"enabled": true
}
}
}Or via the CLI:
hydra-acp extensions add hydra-acp-approver \
--command hydra-acp-approver \
--env HYDRA_ACP_APPROVER_DANGEROUSLY_ALLOW_ALL=1Environment
| Env var | Default | Purpose |
|---|---|---|
| HYDRA_ACP_DAEMON_URL | http://127.0.0.1:8765 | Daemon HTTP endpoint (injected by hydra when run as an extension) |
| HYDRA_ACP_TOKEN | (required) | Daemon auth token (injected by hydra) |
| HYDRA_ACP_WS_URL | derived from daemon URL | Override WS endpoint |
| HYDRA_ACP_APPROVER_CONFIG | ~/.hydra-acp/approver.config.js | Path to the rule module |
| HYDRA_ACP_APPROVER_POLL_MS | 2000 | Session-discovery poll interval |
| HYDRA_ACP_APPROVER_DANGEROUSLY_ALLOW_ALL | false | Auto-approve every request, ignoring the rule config |
| DEBUG | false | Verbose logging |
How it works
The hydra-acp daemon broadcasts each session/request_permission to every attached client simultaneously and resolves the original agent request on the first response (see hydra-acp/src/core/session.ts handlePermissionRequest). Losers receive a session/update with sessionUpdate: "permission_resolved" (per RFD #533) carrying the winning outcome.
The approver attaches as one more client. When the rule fn returns an optionId, it replies immediately and wins. When it abstains, it stashes the JSON-RPC respond callback keyed by toolCallId; when permission_resolved arrives for that id, it replies with { outcome: { outcome: "cancelled" } } to close out its own pending promise (no side effect — the daemon already settled the original request).
This means: install the approver and any per-client approve lambdas can go. Centralize the policy in one place.
