|
| 1 | +# Copilot Interceptor |
| 2 | + |
| 3 | +Stateless CLI binary invoked by GitHub Copilot CLI's hook system on every tool call. It sends the hook event to the Prempti plugin broker via Unix socket and maps the verdict back to Copilot's per-event hook response format. |
| 4 | + |
| 5 | +The interceptor is a thin passthrough — all field extraction, path resolution, and policy evaluation happens in the [plugin broker](../../plugins/coding-agents-plugin/). |
| 6 | + |
| 7 | +> **Status: experimental.** Copilot support is in early development and not yet wired into the installers. Manual hook registration only. |
| 8 | +
|
| 9 | +## Build |
| 10 | + |
| 11 | +```bash |
| 12 | +cargo build --release |
| 13 | +``` |
| 14 | + |
| 15 | +Binary: `target/release/copilot-interceptor` |
| 16 | + |
| 17 | +From the workspace root: |
| 18 | + |
| 19 | +```bash |
| 20 | +make build-copilot-interceptor |
| 21 | +``` |
| 22 | + |
| 23 | +## How It Works |
| 24 | + |
| 25 | +The interceptor mounts on **two** Copilot hook events: |
| 26 | + |
| 27 | +| Hook event | Copilot purpose | Interceptor responsibility | |
| 28 | +|------------|----------------|---------------------------| |
| 29 | +| `preToolUse` | Fires before every tool dispatch | Block deny rules with the rule reason; pass allow/ask/defer through | |
| 30 | +| `permissionRequest` | Fires in the approval path before permission prompts | Block deny/ask rules with the rule reason; pass allow through; defer if no rule matches | |
| 31 | + |
| 32 | +Both hooks send the same wire envelope to the broker (`agent_name = "copilot"`); the broker runs the same Falco rules regardless of which event arrived. Only the **output translation** is per-event. |
| 33 | + |
| 34 | +### Verdict translation |
| 35 | + |
| 36 | +| Falco verdict | `preToolUse` | `permissionRequest` | |
| 37 | +|---------------|--------------|---------------------| |
| 38 | +| `allow` | {"permissionDecision":"allow","permissionDecisionReason":""} | {"behavior":"allow"} | |
| 39 | +| `deny` | {"permissionDecision":"deny","permissionDecisionReason":"..."} | {"behavior":"deny","message":"..."} | |
| 40 | +| `ask` | {"permissionDecision":"ask","permissionDecisionReason":"..."} | {"behavior":"deny","message":"..."} | |
| 41 | +| `defer` | *(no output — falls through to normal permission flow)* | *(no output — falls through to normal permission flow)* | |
| 42 | + |
| 43 | +### Defer: step aside for both hooks |
| 44 | + |
| 45 | +The broker's `defer` (the no-rule-match floor when `default_action = defer`, and the monitor/passthrough resolution) emits **no output** from the interceptor for both `preToolUse` and `permissionRequest`. This causes Copilot to fall through to its own permission flow (prompting, auto-allow, etc.). By contrast, when Falco rules return `allow` or the broker's `default_action = allow`, the interceptor emits an explicit allow (`{"behavior":"allow"}` for permissionRequest, `{"permissionDecision":"allow","permissionDecisionReason":""}` for preToolUse), which short-circuits the normal permission flow — useful in `-p` (pipe) mode and other non-interactive CI usages. |
| 46 | + |
| 47 | +## Wire shape (Copilot → interceptor) |
| 48 | + |
| 49 | +Copilot sends **camelCase** JSON over stdin. |
| 50 | + |
| 51 | +`PreToolUse`: |
| 52 | +```json |
| 53 | +{ |
| 54 | + "sessionId": "…", |
| 55 | + "timestamp": 1783795023994, |
| 56 | + "cwd": "/home/user", |
| 57 | + "toolName": "create", |
| 58 | + "toolArgs": "{\"path\":\"/home/user/file.txt\",\"file_text\":\"…\"}" |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +`PermissionRequest`: |
| 63 | +```json |
| 64 | +{ |
| 65 | + "hookName": "permissionRequest", |
| 66 | + "sessionId": "…", |
| 67 | + "timestamp": 1783795024001, |
| 68 | + "cwd": "/home/user", |
| 69 | + "toolName": "edit", |
| 70 | + "toolInput": { |
| 71 | + "file_path": "/home/user/file.txt", |
| 72 | + "diff": "diff --git a/… b/…" |
| 73 | + }, |
| 74 | + "permissionSuggestions": [] |
| 75 | +} |
| 76 | +``` |
| 77 | + |
| 78 | +Note the structural difference: `permissionRequest` uses a top-level `hookName` field (instead of `sessionId`-level naming), and its `toolName` + `toolInput` fields parallel the `tool_name`/`tool_input` shape from the Codex wire format. The interceptor normalizes both shapes into the same internal representation before forwarding to the broker: |
| 79 | + |
| 80 | +- `hookName` → `hook_event_name` (PascalCase: `"PreToolUse"` or `"PermissionRequest"`) |
| 81 | +- `toolArgs` (JSON string) → `tool_input` (parsed JSON object). If parsing fails, the original string value is preserved as a fallback. |
| 82 | +- `toolInput` (parsed object) → `tool_input` (passed through as-is) |
| 83 | +- `sessionId` → `id` (correlation ID; falls back to `"unknown"` if empty) |
| 84 | +- `toolName` → `tool_name`, `cwd` → `cwd` (passed through) |
| 85 | +- `permission_mode` and `tool_use_id` are set to empty strings (not provided by Copilot) |
| 86 | + |
| 87 | +The wire envelope also includes `version: 1` and `agent_pid` |
| 88 | + |
| 89 | +## Wire shape (interceptor → Copilot) |
| 90 | + |
| 91 | +Copilot expects **camelCase** JSON on stdout. |
| 92 | + |
| 93 | +`PreToolUse`: |
| 94 | +```json |
| 95 | +{ |
| 96 | + "permissionDecision": "deny", |
| 97 | + "permissionDecisionReason": "Falco blocked …" |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +`PermissionRequest`: |
| 102 | +```json |
| 103 | +{ |
| 104 | + "behavior": "deny", |
| 105 | + "message": "Falco blocked …" |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +> **Progress messages** (optional). The interceptor may emit transient progress lines before the final decision object to give the user visibility into hook execution. Each progress line is a single-line JSON object: `{"type":"progress","message":"…","temporary":true}`. Copilot strips these from the output stream before parsing the final decision. |
| 110 | +
|
| 111 | +## Hook registration (Copilot CLI) |
| 112 | + |
| 113 | +Register the interceptor with: |
| 114 | + |
| 115 | +```bash |
| 116 | +premptictl hook add copilot |
| 117 | +``` |
| 118 | + |
| 119 | +This writes a `~/.copilot/hooks/prempti.json` file that mounts the packaged `copilot-interceptor` binary on both `preToolUse` and `permissionRequest` (matcher `.*`, 30s timeout). Remove with `premptictl hook remove copilot`; status with `premptictl hook status copilot`. |
| 120 | + |
| 121 | +The hook remains opt-in. Once enabled, the Prempti supervisor manages its lifecycle alongside the Claude Code and Codex hooks: it re-asserts the hook configuration on service start and removes it on service stop so Copilot does not fail closed against a dead broker. The opt-in marker remains until `premptictl hook remove copilot`. |
| 122 | + |
| 123 | +If you'd rather hand-roll the config — for example to bind it to a specific tool matcher or to combine with hooks you already have — the canonical shape for `~/.copilot/hooks/prempti.json` is: |
| 124 | + |
| 125 | +```json |
| 126 | +{ |
| 127 | + "version": 1, |
| 128 | + "hooks": { |
| 129 | + "preToolUse": [ |
| 130 | + { |
| 131 | + "matcher": ".*", |
| 132 | + "type": "command", |
| 133 | + "command": "/abs/path/to/copilot-interceptor", |
| 134 | + "timeoutSec": 30 |
| 135 | + } |
| 136 | + ], |
| 137 | + "permissionRequest": [ |
| 138 | + { |
| 139 | + "matcher": ".*", |
| 140 | + "type": "command", |
| 141 | + "command": "/abs/path/to/copilot-interceptor", |
| 142 | + "timeoutSec": 30 |
| 143 | + } |
| 144 | + ] |
| 145 | + } |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | + |
| 150 | +## Configuration |
| 151 | + |
| 152 | +| Variable | Default | Description | |
| 153 | +|----------|---------|-------------| |
| 154 | +| `PREMPTI_SOCKET` | `~/.prempti/run/broker.sock` (Unix) / `%LOCALAPPDATA%/prempti/run/broker.sock` (Windows) | Broker socket path | |
| 155 | +| `PREMPTI_TIMEOUT_MS` | `5000` | Socket timeout in ms | |
| 156 | +| `PREMPTI_INPUT_MAX_BYTES` | `4194304` (4 MiB) | Stdin cap. Clamped to `[4 KiB, 64 MiB]`. Raise this (and the plugin's `max_request_bytes`) to support large tool input envelopes. | |
| 157 | +| `PREMPTI_FAIL_OPEN` | `0` | When set to `1`/`true`, broker communication failures emit empty stdout (defer) instead of denying it | |
| 158 | + |
| 159 | +Boolean values accept `1`, `true`, `yes`, `on` (case-insensitive, whitespace trimmed). |
| 160 | + |
| 161 | +## Error handling |
| 162 | + |
| 163 | +- **Fail-closed by default.** Broker communication failures emit a `deny` (with the failure reason) unless `PREMPTI_FAIL_OPEN=1` is set. The deny is emitted in the output shape matching the Copilot hook event that fired. |
| 164 | +- **Exit code 2** for malformed input (empty stdin, invalid JSON, unsupported `hook_event_name`). Copilot treats exit 2 + stderr as a hard block. |
| 165 | +- **Stdout safety.** If serialization fails, the interceptor writes a hardcoded deny literal in the correct shape for the event. The only path that produces empty stdout with exit 0 is the intentional `defer` verdict, which correctly falls through to Copilot's normal permission flow. All error paths emit an explicit deny. |
| 166 | +- **Timeout.** A timed-out hook (exceeding `timeoutSec`) surfaces a warning and lets the tool call proceed through the normal permission flow, per Copilot's `preToolUse` timeout semantics. |
| 167 | + |
| 168 | +## Known v1 limitations |
| 169 | + |
| 170 | +- **`PermissionRequest` hook event name.** Copilot uses `hookName: "permissionRequest"` (lowercase `p`, no underscore) but hook config keys are `camelCase` (`permissionRequest`). The interceptor normalizes these at read time. If a future Copilot version changes the event name, the interceptor must be updated. |
| 171 | +- **`toolInput` vs `toolArgs`.** The two events use different field names for tool arguments: `PreToolUse` uses `toolArgs` (a JSON string), `PermissionRequest` uses `toolInput` (a parsed JSON object). The interceptor normalizes both to `tool_input` internally before forwarding to the broker. |
| 172 | +- **Progress messages not yet implemented.** The interceptor does not currently emit progress lines. This is non-blocking — Copilot tolerates hook silence — but would improve UX when the broker takes >1s to respond. |
| 173 | +- **`ask` on permissionRequest is lossy.** Copilot's `preToolUse` supports `"ask"` and maps Falco `ask` rules directly. However, `permissionRequest` does not support an ask behavior, so Falco `ask` rules are mapped to `deny` with the rule reason as the message for that hook. |
| 174 | + |
| 175 | +## Supported Copilot hook events |
| 176 | + |
| 177 | +Only `preToolUse` and `permissionRequest` are handled. Copilot has 10 other hook events (`agentStop`, `errorOccurred`, `notification`, `postToolUse`, `postToolUseFailure`, `preCompact`, `sessionEnd`, `sessionStart`, `subagentStart`, `subagentStop`, `userPromptSubmitted`) — registering this interceptor for those is a configuration error and exits 2 with a clear stderr message. |
| 178 | + |
| 179 | +## See also |
| 180 | + |
| 181 | +- [Codex interceptor README](../codex/README.md) — analogous design for OpenAI Codex CLI |
| 182 | +- [Claude Code interceptor README](../claude-code/README.md) — analogous design for Anthropic Claude Code |
| 183 | +- [Plugin broker](../../plugins/coding-agents-plugin/) — policy evaluation engine |
| 184 | +- [Copilot hooks reference](https://docs.github.com/en/copilot/copilot-cli/reference/copilot-hooks-reference) — official Copilot CLI hooks documentation |
0 commit comments