Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,43 @@ Security boundary for the executed `cmd`:
- **On by default** — like install/uninstall commands, it runs automatically. Set `TEAMAI_DISABLE_REMOTE_CMD=1` on the client to reject it (acked `failed` with `remote cmd disabled by client`).
- **Timeout** — a hung command is killed after 120s and acked `failed`.

The backend may also push **`install_hook_rule`** / **`uninstall_hook_rule`** commands to remotely
manage a session hook in the **current reporting tool**'s settings, keyed by `slug`. The result is
reported over the same ack channel:

```jsonc
// install (or replace) a hook keyed by slug
{ "id": 50, "type": "install_hook_rule", "handle_type": "hook", "slug": "my-hook",
"event": "SessionStart", "cmd": "echo hi", "timeout": 10 }

// uninstall the hook previously installed under slug
{ "id": 51, "type": "uninstall_hook_rule", "handle_type": "hook", "slug": "my-hook" }
```

Rules for agent hooks:

- **Current tool only** — the hook is written to the tool that is reporting (e.g. under Claude ⇒
only `.claude/settings.json`). Other tools are never touched.
- **Supported tools** — `claude` / `codex` / `workbuddy` / `codebuddy` (plus their internal
variants). **Cursor and OpenClaw-family tools are rejected** → acked `failed` (`unsupported tool`).
- **Event whitelist** — `SessionStart` / `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`.
Any other event → acked `failed` (`unsupported event`).
- **Optional `matcher`** — a tool-name filter for `PreToolUse` / `PostToolUse`; defaults to `*`
(all tools) when omitted.
- **Default timeout 10s** when `timeout` is omitted; the backend value is honored when present.
- **Idempotent** — re-installing the same `slug` replaces the existing hook rather than duplicating
it; `uninstall_hook_rule` for a missing `slug` is acked `success`.
- **Isolation** — agent hooks use a dedicated `[teamai:agent-hook:<slug>]` marker, so a team pull
never deletes them and installing one never disturbs built-in or team hooks.
- **Teardown** — agent hooks are removed by `uninstall_hook_rule`, `teamai source remove`, and
`teamai uninstall` (no residue in any tool's settings).
- **Kill-switch** — an agent hook is a backend-supplied command the tool auto-runs on its events,
so it shares the `uninstall_teamai` trust model: setting `TEAMAI_DISABLE_REMOTE_CMD=1` on the
client rejects `install_hook_rule` / `uninstall_hook_rule` too (acked `failed`).
- **Codex matching** — codex settings carry no description field, so codex agent hooks are matched
by their exact command and the local-agent manifest is the authoritative record for their
teardown. Backends should use a **unique `cmd` per codex `slug`** so replace/remove stay precise.

Configurable environment variables:

| Variable | Purpose |
Expand All @@ -657,7 +694,7 @@ Configurable environment variables:
| `TEAMAI_REPORT_AGENTS` | Comma-separated list of agents that report (default `workbuddy,codebuddy`) |
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | Allowlist of hosts for skill `download_url` (empty = allow all) |
| `TEAMAI_ALLOW_SANDBOX_REPORT` | Set to `1` to force report/sync inside a CloudStudio sandbox (see note below) |
| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed `uninstall_teamai` commands (they are acked `failed`) |
| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed `uninstall_teamai`, `install_hook_rule`, and `uninstall_hook_rule` commands (they are acked `failed`) |

> **Privacy:** The install path and machine id are only hashed locally to derive `local_agent_id` — they are never reported.

Expand Down
37 changes: 36 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,41 @@ cat ~/.claude/CLAUDE.md
- **默认开启** —— 与 install/uninstall 命令一致,会自动执行。客户端设 `TEAMAI_DISABLE_REMOTE_CMD=1` 可拒绝(ack `failed`,错误为 `remote cmd disabled by client`)。
- **超时** —— 命令卡住 120s 后被杀掉并 ack `failed`。

后端还可下发 **`install_hook_rule`** / **`uninstall_hook_rule`** 命令,按 `slug` 远程管理**当前上报工具**
settings 里的一个 session hook。结果经同一 ack 通道回报:

```jsonc
// 按 slug 安装(或替换)一个 hook
{ "id": 50, "type": "install_hook_rule", "handle_type": "hook", "slug": "my-hook",
"event": "SessionStart", "cmd": "echo hi", "timeout": 10 }

// 卸载此前以该 slug 安装的 hook
{ "id": 51, "type": "uninstall_hook_rule", "handle_type": "hook", "slug": "my-hook" }
```

agent hook 规则:

- **仅当前工具** —— hook 只写入正在上报的工具(如在 Claude 下运行 ⇒ 只写 `.claude/settings.json`),
绝不触碰其它工具。
- **支持的工具** —— `claude` / `codex` / `workbuddy` / `codebuddy`(含其内部变体)。**Cursor 与 OpenClaw
家族被拒绝** → ack `failed`(`unsupported tool`)。
- **事件白名单** —— `SessionStart` / `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`。
其它事件 → ack `failed`(`unsupported event`)。
- **可选 `matcher`** —— `PreToolUse` / `PostToolUse` 的工具名过滤;省略时默认为 `*`(全部工具)。
- **默认超时 10s** —— 省略 `timeout` 时用 10s;后端给了值则以后端为准。
- **幂等** —— 用相同 `slug` 重装会替换已有 hook 而非重复追加;对不存在的 `slug` 执行
`uninstall_hook_rule` 也 ack `success`。
- **隔离** —— agent hook 使用专属 marker `[teamai:agent-hook:<slug>]`,团队 pull 不会删除它,
安装它也不会扰动 built-in 或团队 hook。
- **清理** —— agent hook 会被 `uninstall_hook_rule`、`teamai source remove` 和 `teamai uninstall`
彻底移除(不在任何工具 settings 里留残留)。
- **关闭开关** —— agent hook 是后端下发、由工具在其事件上自动执行的命令,因此与 `uninstall_teamai`
共用信任模型:客户端设 `TEAMAI_DISABLE_REMOTE_CMD=1` 也会拒绝 `install_hook_rule` /
`uninstall_hook_rule`(ack `failed`)。
- **Codex 匹配** —— codex settings 无 description 字段,因此 codex agent hook 按其确切命令匹配,
且以 local-agent manifest 作为卸载的权威记录。后端应为**每个 codex `slug` 使用唯一的 `cmd`**,
以保证替换/删除的精确性。

可配置环境变量:

| 变量 | 作用 |
Expand All @@ -655,7 +690,7 @@ cat ~/.claude/CLAUDE.md
| `TEAMAI_REPORT_AGENTS` | 参与上报的 agent,逗号分隔(默认 `workbuddy,codebuddy`) |
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | skill `download_url` host 白名单(空 = 全部放行) |
| `TEAMAI_ALLOW_SANDBOX_REPORT` | 设为 `1` 可强制在 CloudStudio 沙箱内 report/sync(见下方说明) |
| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的 `uninstall_teamai` 命令(会 ack `failed`) |
| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的 `uninstall_teamai`、`install_hook_rule`、`uninstall_hook_rule` 命令(会 ack `failed`) |

> **隐私**:install path 和 machine id 仅在本地哈希以派生 `local_agent_id`,不会上报。

Expand Down
95 changes: 94 additions & 1 deletion src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('../utils/logger.js', () => ({
},
}));

import { getHookStatus, injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js';
import { getHookStatus, injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS, reconcileHooks, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, agentHookDescription } from '../hooks.js';

// ── Helpers ──────────────────────────────────────────────

Expand Down Expand Up @@ -619,6 +619,99 @@ describe('hooks', () => {
});
});

describe('agent hooks (issue #238)', () => {
it('isAgentHookSupportedTool: claude/codex/workbuddy/codebuddy yes, cursor/openclaw no', () => {
for (const t of ['claude', 'codex', 'workbuddy', 'codebuddy', 'codex-internal']) {
expect(isAgentHookSupportedTool(t)).toBe(true);
}
for (const t of ['cursor', 'openclaw', 'qclaw', 'easyclaw', 'autoclaw']) {
expect(isAgentHookSupportedTool(t)).toBe(false);
}
});

it('isAgentHookEvent: only the 5 whitelisted events', () => {
for (const e of ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']) {
expect(isAgentHookEvent(e)).toBe(true);
}
for (const e of ['Notification', 'PreCompact', 'foo', 'sessionStart']) {
expect(isAgentHookEvent(e)).toBe(false);
}
});

it('applyAgentHook (claude): writes a slug-tagged entry with timeout', async () => {
await applyAgentHook('/t/settings.json', 'claude', {
slug: 's1', event: 'SessionStart', command: 'echo hi', timeout: 10,
});
const s = mockFiles['/t/settings.json'] as any;
const e = s.hooks.SessionStart.find((x: any) => x.description === agentHookDescription('s1'));
expect(e).toBeDefined();
expect(e.matcher).toBe('*');
expect(e.hooks[0].command).toBe('echo hi');
expect(e.hooks[0].timeout).toBe(10);
});

it('applyAgentHook (claude): re-install same slug replaces, no duplicate', async () => {
await applyAgentHook('/t/s.json', 'claude', { slug: 's2', event: 'Stop', command: 'echo a' });
await applyAgentHook('/t/s.json', 'claude', { slug: 's2', event: 'Stop', command: 'echo b' });
const s = mockFiles['/t/s.json'] as any;
const mine = s.hooks.Stop.filter((x: any) => x.description === agentHookDescription('s2'));
expect(mine).toHaveLength(1);
expect(mine[0].hooks[0].command).toBe('echo b');
});

it('applyAgentHook preserves a user hook in the same event', async () => {
mockFiles['/t/s.json'] = {
hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'user-cmd' }] }] },
};
await applyAgentHook('/t/s.json', 'claude', { slug: 's3', event: 'SessionStart', command: 'echo hi' });
const s = mockFiles['/t/s.json'] as any;
expect(s.hooks.SessionStart.some((x: any) => x.hooks[0].command === 'user-cmd')).toBe(true);
expect(s.hooks.SessionStart.some((x: any) => x.description === agentHookDescription('s3'))).toBe(true);
});

it('applyAgentHook (codex): writes entry without description, matched by command', async () => {
await applyAgentHook('/t/codex.json', 'codex', { slug: 's4', event: 'PreToolUse', command: 'echo cx' });
const s = mockFiles['/t/codex.json'] as any;
const e = s.hooks.PreToolUse.find((x: any) => x.hooks[0].command === 'echo cx');
expect(e).toBeDefined();
expect(e.description).toBeUndefined();
});

it('removeAgentHook (claude): removes by slug, drops empty event key', async () => {
await applyAgentHook('/t/s.json', 'claude', { slug: 's5', event: 'SessionStart', command: 'echo hi' });
await removeAgentHook('/t/s.json', 'claude', { slug: 's5' });
const s = mockFiles['/t/s.json'] as any;
expect(s.hooks.SessionStart).toBeUndefined();
});

it('removeAgentHook (codex): removes by command', async () => {
await applyAgentHook('/t/codex.json', 'codex', { slug: 's6', event: 'Stop', command: 'echo cx6' });
await removeAgentHook('/t/codex.json', 'codex', { slug: 's6', command: 'echo cx6' });
const s = mockFiles['/t/codex.json'] as any;
expect(s.hooks.Stop).toBeUndefined();
});

it('normal reconcile leaves an agent hook untouched; removeAll sweeps it', async () => {
// Seed a claude settings file with a built-in inject + an agent hook.
await injectHooks('/t/rec.json', 'claude');
await applyAgentHook('/t/rec.json', 'claude', { slug: 's7', event: 'SessionStart', command: 'echo hi' });
const marker = agentHookDescription('s7');
const has = () => {
const s = mockFiles['/t/rec.json'] as any;
return Object.values(s.hooks).some((arr: any) => arr.some((e: any) => e.description === marker));
};
expect(has()).toBe(true);

// Normal reconcile (no manifest, removeAll=false) must NOT delete the agent hook.
await reconcileHooks('/t/rec.json', 'claude', []);
expect(has()).toBe(true);

// Teardown removeAll must sweep it.
await reconcileHooks('/t/rec.json', 'claude', [], { removeAll: true });
expect(has()).toBe(false);
});
});

describe('getHookStatus', () => {
it('reports installed for current Claude hooks', async () => {
await injectHooks('/test/settings.json', 'claude');
Expand Down
Loading
Loading