feat(hooks): add Factory Droid integration#2267
Conversation
Register RTK with Factory Droid so shell commands the agent runs are transparently rewritten to their token-saving `rtk` equivalents. - `rtk init --agent droid`: install a native PreToolUse hook into ~/.factory/settings.json (matcher "Execute"); supports global (-g) and project scope, the FACTORY_HOME override, idempotent install with backup + atomic write, and a clean uninstall round-trip. - `rtk hook droid`: process Droid's PreToolUse payload (BOM strip, stdin cap, JSON-parse fallback), mirroring the existing provider hooks. - Deny verdict steps aside (no output) so Droid's native deny handling fires, matching Claude/Cursor/Copilot and the PermissionVerdict::Deny contract. - Rewrites are auto-allowed: Droid only applies a hook's updatedInput when the decision is "allow", so we mirror run_cursor to avoid silently dropping the rewrite (and its savings) on the default verdict.
Droid 0.140.0 applies a PreToolUse hook's updatedInput regardless of the permission decision (verified empirically + via binary decompile), so the forced permissionDecision:"allow" was unnecessary. Worse, Droid resolves the decision as the first hook result carrying one (no deny-over-allow priority), so an unconditional allow could suppress another PreToolUse hook's deny/ask and bypass Droid's native prompt. Now omit permissionDecision by default and only assert allow on an explicit allow rule, mirroring process_claude_payload. The rewrite still lands via Droid's decision-independent "updated input result" path. Updated the inaccurate comment and the test that required allow.
Adopt the decide_from_verdict/HookDecision flow introduced on develop so the Droid handler gains the same safety behavior as Claude/Cursor/Copilot: commands with substitution or file redirects now Defer (no rewrite) instead of being rewritten, and deny/allow/ask mapping is shared instead of reimplemented. Also removes an unwrap() in the response builder. Claude-Session: https://claude.ai/code/session_01Vp4p5YroP2cfFvThhQE4Rv
Adversarial verification against Droid v0.164.0 (shipped code + docs) found two broken assumptions in the original integration: - Droid's canonical hooks file is hooks.json (root event map, no wrapper); the hooks key of settings.json is only a fallback that hooks.json shadows per event key. Droid's own /hooks UI writes hooks.json, so an RTK entry in settings.json silently dies as soon as the user adds any PreToolUse hook there. Install now targets the file whose PreToolUse array Droid actually reads (live hooks.json > live settings.json fallback > create canonical hooks.json), migrates stale copies, and uninstall sweeps root hooks.json, legacy hooks/hooks.json, and settings.json. - The config-home env var is FACTORY_HOME_OVERRIDE (replaces $HOME, with .factory appended), not FACTORY_HOME pointing at the config dir. Also corrects the 'legacy Bash matcher' comment (Droid never had a Bash tool; the acceptance is purely defensive) and records that the omit-permissionDecision rewrite path is confirmed in v0.164.0: decisions resolve as find(first result with a decision) and updatedInput applies via a separate path even when no decision is set. Claude-Session: https://claude.ai/code/session_01Vp4p5YroP2cfFvThhQE4Rv
5106c44 to
77d9ee4
Compare
|
|
||
| /// Install Factory Droid PreToolUse hook into `settings.json`. | ||
| /// | ||
| /// - Global (`-g`): writes to `~/.factory/settings.json` (or `$FACTORY_HOME/settings.json`). |
There was a problem hiding this comment.
Following the Droid CLI documentation:
Droid hooks live in hooks.json
- ~/.factory/hooks.json – User (applies to all projects)
- .factory/hooks.json – Project (commit to share)
- enterprise managed policy hooks
If hooks.json is absent, Droid falls back to hooks declared under the hooks key in the matching settings.json.
So it seems that settings.json is a fallback.
We should try to use this file and fallback to settings if not available to better match Droid CLI behavior ?
| ti | ||
| }; | ||
|
|
||
| // Wire format mirrors Claude's `hookSpecificOutput`; the allow policy mirrors |
There was a problem hiding this comment.
Too verbose comment, if need to refer to a agent documentation or behavior to specific version, you can link a documentation or changelog
Just to keep it clean :)
| "updatedInput": updated_input | ||
| }); | ||
|
|
||
| if verdict == PermissionVerdict::Allow { |
There was a problem hiding this comment.
This leak claude settings because using the check_command that check for claude code settings.
Droid has its own permissions through commandAllowlist / commandDenylist / commandBlocklist and autonomyLevel in ~/.factory/settings.json
RTK should respect the agents permissions system and always match the verdict (allow/deny or ask) that the agent would have natively emitted
| rtk init --agent antigravity # Google Antigravity | ||
| rtk init -g --agent pi # Pi | ||
| rtk init --agent hermes # Hermes | ||
| rtk init -g --agent droid # Factory Droid |
There was a problem hiding this comment.
Correct documentation, should also be propagate to https://github.com/rtk-ai/rtk/blob/develop/docs/guide/getting-started/supported-agents.md
The Droid hook consulted Claude Code's settings (Host::Claude), leaking
one agent's permission config into another: a Bash(...) allow rule in
~/.claude/settings.json could auto-allow commands in Droid, bypassing
its native prompt.
Add permissions::Host::Droid backed by ~/.factory/settings.json
(honoring $FACTORY_HOME_OVERRIDE as a home-dir override, matching the
shipped Droid CLI):
- commandAllowlist -> Allow: the rewrite carries permissionDecision
"allow", exactly the verdict Droid would emit natively.
- commandDenylist (always confirm) and commandBlocklist (never runs)
-> Deny: RTK steps aside entirely so Droid's native confirm/block
fires on the original command. Rewriting first would dodge Droid's
own pattern matching ("rtk git log" no longer matches a "git log"
denylist entry).
- Built-in defaults are mirrored from the shipped Droid CLI (verified
against v0.153.1); a user list replaces its default outright, same
as Droid's own accessor.
Also trims the wire-format comment per review feedback.
Verified live against droid 0.153.1: git status/git log rewrite and
auto-run at --auto low via the default allowlist; a denylisted git log
is left untouched and natively refused; uninstall round-trip clean.
Claude-Session: https://claude.ai/code/session_015CvKLxKMnmcvwY5CjHVTF5
Propagates the README Droid documentation to docs/guide/getting-started/supported-agents.md per review feedback: frontmatter description, agents table row, and an installation section covering hooks.json placement and Droid-native permission handling. Claude-Session: https://claude.ai/code/session_015CvKLxKMnmcvwY5CjHVTF5
8623937 to
0a032aa
Compare
| "rm -rf /", | ||
| "rm -rf /*", | ||
| "rm -rf .", | ||
| "rm -rf ~", |
There was a problem hiding this comment.
Those kind of list are often a bad idea for maintenance and transparence.
Drop DROID_DEFAULT_ALLOWLIST / DROID_DEFAULT_DENYLIST and fall back to empty lists. Hardcoding defaults will drift against Factory's releases and makes RTK auto-allow commands the user never listed.
To keep : (1) read all scopes global + settings.local.json + project .factory/ (Droid merges them; see load_gemini_rules' folderTrust handling), not global-only, or project/local deny entries get dodged; (2) honor only explicit commandDenylist/commandBlocklist → step-aside (the fix for the rtk-rename dodge).
For the allow side, prefer defer (no permissionDecision) rather than emitting allow, since RTK renaming the program to rtk means it can't transparently reproduce Droid's own allow decision.
If any question do no hesitate to tag me so we can cleanly implement this integration
There was a problem hiding this comment.
Implemented in 3d40742 — thanks for the detailed guidance @aeppling.
- Dropped
DROID_DEFAULT_ALLOWLIST/DROID_DEFAULT_DENYLISTentirely; no built-in lists are mirrored anymore. - Deny-only: RTK reads explicit
commandDenylist/commandBlocklistfrom all four scopes (~/.factory/settings.json+settings.local.json, project.factory/settings.json+settings.local.json) and steps aside on a match. One deliberate divergence: entries are unioned across scopes rather than replicating Droid's replace-on-merge precedence — over-collecting only causes a spurious step-aside (Droid still decides natively on the original command), while under-collecting would reopen the rename dodge. - Allow side: RTK never emits
permissionDecision— rewrites land viaupdatedInputalone and the verdict stays with Droid's native flow (allowlist, autonomy level, other hooks).
Verified end-to-end against Droid CLI 0.153.1: Droid evaluates its permission lists on the original command before applying updatedInput, so native allowlist decisions are unaffected (git status still auto-runs at --auto low) and denylisted commands are gated natively with no rewrite involved.
…scopes Drop the hardcoded mirrors of Droid's built-in command lists and stop emitting permissionDecision entirely. RTK now steps aside only on explicit commandDenylist/commandBlocklist entries, unioned across all four settings scopes (user + project, settings.json + settings.local.json); every other command is rewritten via updatedInput and the decision is left to Droid's native flow. Verified against Droid CLI 0.153.1: updatedInput is applied after Droid's own permission evaluation of the original command, so native allowlist decisions are unaffected and deny entries fire natively. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01SpSp3Arj7fG1XJYhXoCMGC
|
Hey @krimvp , LGTM , thanks for contributing to RTK with this new integration ! |

Closes #2099
Summary
Adds first-class Factory Droid support so shell commands the agent runs are transparently rewritten to their token-saving
rtkequivalents.rtk init --agent droid— installs a nativePreToolUsehook (matcherExecute) into Droid's canonicalhooks.json(~/.factory/hooks.json, or project.factory/hooks.json), falling back to thehookskey ofsettings.jsononly when that file already carries livePreToolUsehooks. Supports global (-g) and project scope, theFACTORY_HOME_OVERRIDEenv override, idempotent install with.bakbackup + atomic write, and a clean uninstall round-trip (--uninstall).rtk hook droid— processes Droid'sPreToolUsepayload (BOM strip, stdin cap, JSON-parse fallback), mirroring the existing provider hooks.permissions::Host::Droid, sourced from~/.factory/settings.json— never from another agent's settings.commandAllowlist→ rewrite carriespermissionDecision: "allow"(the verdict Droid would emit natively). Droid's built-in default allowlist (ls,pwd,git status,git log, …) is mirrored, and a user list replaces its default outright — matching Droid's ownsettings.commandAllowlist || DEFAULTaccessor.commandDenylist(always confirm) andcommandBlocklist(never runs) → RTK steps aside entirely so Droid's native confirm/block fires on the original command. Rewriting first would dodge Droid's own pattern matching (rtk git logno longer matches agit logdenylist entry).Protocol verified against Factory's hooks reference and the shipped Droid CLI (v0.153.1):
Executematcher,tool_input.command,hookSpecificOutputwithpermissionDecision/updatedInput, per-event-key merge ofhooks.jsonoversettings.jsonhooks,FACTORY_HOME_OVERRIDEas a home-dir override, and thecommandAllowlist/commandDenylist/commandBlocklistdefaults in settings.Demo
End-to-end: install → Droid rewrites & auto-runs allowlisted commands at
--auto low→ a denylisted command is left to Droid's native permission gate → savings inrtk gain→ uninstall.Test plan
cargo fmt --allcleancargo clippy --all-targets— zero warningscargo test --all— 2394 passed (34 Droid-specific: install/uninstall round-trip, idempotency, matcher-group reuse, hooks.json/settings.json target resolution, allowlist auto-allow, denylist/blocklist step-aside, user-list-replaces-default, deny step-aside, legacyBashmatcher, passthrough)droidCLI (v0.153.1), all with--auto low:git status/git logrewritten tortk …and auto-run via Droid's default allowlist (session transcript shows RTK's filtered output; savings tracked inrtk gain)"commandDenylist": ["git log"]: RTK emits nothing, Droid natively refuses (insufficient permission to proceed) — A/B control with the entry removed runs rewritten againBash(curl:*)allow rule in~/.claude/settings.local.jsonno longer auto-allowscurlin Droidsettings.jsonuntouched by install/uninstall round-tripDocs
README updated — Supported Agents list + integration table row.
docs/guide/getting-started/supported-agents.mdupdated per review feedback (frontmatter, agents table, installation section).🤖 Generated with Claude Code