diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 988ed1cee..be3794f74 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -125,7 +125,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | | `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | | `beforeToolSelection` | — | — | — | — | — | — | ✅ | — | — | -| `permissionRequest` | — | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | +| `permissionRequest` | — | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | | `notification` | — | ✅ | — | — | — | ✅ | ✅ | — | — | | `setup` | — | ✅ | — | — | — | ✅ | — | — | — | | `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 988ed1cee..be3794f74 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -125,7 +125,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `beforeTabFileRead` | ✅ | — | — | — | — | — | — | — | — | | `afterTabFileEdit` | ✅ | — | — | — | — | — | — | — | — | | `beforeToolSelection` | — | — | — | — | — | — | ✅ | — | — | -| `permissionRequest` | — | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | +| `permissionRequest` | — | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | | `notification` | — | ✅ | — | — | — | ✅ | ✅ | — | — | | `setup` | — | ✅ | — | — | — | ✅ | — | — | — | | `worktreeCreate` | — | ✅ | — | — | — | — | — | — | — | diff --git a/src/features/hooks/codexcli-hooks.test.ts b/src/features/hooks/codexcli-hooks.test.ts index 96d7dd06d..819223801 100644 --- a/src/features/hooks/codexcli-hooks.test.ts +++ b/src/features/hooks/codexcli-hooks.test.ts @@ -135,6 +135,62 @@ describe("CodexcliHooks", () => { expect(parsed.hooks.Stop[0].hooks[0].command).toBe("echo stop"); }); + it("should support permissionRequest event with matcher", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + permissionRequest: [ + { command: ".rulesync/hooks/perm.sh", matcher: "Bash", timeout: 30 }, + ], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.PermissionRequest).toBeDefined(); + expect(parsed.hooks.PermissionRequest[0].matcher).toBe("Bash"); + expect(parsed.hooks.PermissionRequest[0].hooks[0].command).toBe(".rulesync/hooks/perm.sh"); + expect(parsed.hooks.PermissionRequest[0].hooks[0].type).toBe("command"); + expect(parsed.hooks.PermissionRequest[0].hooks[0].timeout).toBe(30); + }); + + it("should preserve apply_patch and MCP tool matchers on permissionRequest", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + permissionRequest: [ + { command: "./scripts/audit-patch.sh", matcher: "apply_patch" }, + { command: "./scripts/audit-mcp.sh", matcher: "mcp__fs__read" }, + ], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.PermissionRequest).toHaveLength(2); + const byMatcher = Object.fromEntries( + parsed.hooks.PermissionRequest.map((entry: { matcher: string }) => [entry.matcher, entry]), + ); + expect(byMatcher.apply_patch.hooks[0].command).toBe("./scripts/audit-patch.sh"); + expect(byMatcher.mcp__fs__read.hooks[0].command).toBe("./scripts/audit-mcp.sh"); + }); + it("should not write config.toml as a side effect", async () => { const rulesyncHooks = new RulesyncHooks( createMockAiFileParams({ @@ -252,6 +308,40 @@ describe("CodexcliHooks", () => { }); }); + it("should convert PermissionRequest to canonical permissionRequest", () => { + const codexHooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + PermissionRequest: [ + { + matcher: "Bash", + hooks: [ + { + type: "command", + command: ".rulesync/hooks/perm.sh", + }, + ], + }, + ], + }, + }), + }), + ); + + const rulesyncHooks = codexHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.permissionRequest).toBeDefined(); + expect(parsed.hooks.permissionRequest?.[0]).toEqual({ + type: "command", + command: ".rulesync/hooks/perm.sh", + matcher: "Bash", + }); + }); + it("should ignore invalid entries", () => { const codexHooks = new CodexcliHooks( createMockAiFileParams({ diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 99e56a741..dc79eccc2 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -186,6 +186,7 @@ export const CODEXCLI_HOOK_EVENTS: readonly HookEvent[] = [ "postToolUse", "beforeSubmitPrompt", "stop", + "permissionRequest", ]; const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema)); @@ -365,6 +366,7 @@ export const CANONICAL_TO_CODEXCLI_EVENT_NAMES: Record = { postToolUse: "PostToolUse", beforeSubmitPrompt: "UserPromptSubmit", stop: "Stop", + permissionRequest: "PermissionRequest", }; /**