Skip to content

Commit 2c7b85f

Browse files
committed
Feat: add copilot cli support
Signed-off-by: dankress <113473726+dankress@users.noreply.github.com>
1 parent 4c1dcae commit 2c7b85f

19 files changed

Lines changed: 2521 additions & 34 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ resolver = "2"
33
members = [
44
"hooks/claude-code",
55
"hooks/codex",
6+
"hooks/copilot",
67
"plugins/coding-agents-plugin",
78
"tools/premptictl",
89
"tests",
@@ -34,6 +35,11 @@ codegen-units = 1
3435
opt-level = "z"
3536
codegen-units = 1
3637

38+
39+
[profile.release.package.copilot-interceptor]
40+
opt-level = "z"
41+
codegen-units = 1
42+
3743
[profile.release.package.premptictl]
3844
opt-level = "z"
3945
codegen-units = 1

Makefile

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ ARCH := $(shell uname -m)
99
download-falco-linux falco-linux-bin-dir \
1010
falco-macos falco-macos-bin-dir \
1111
falco-windows falco-windows-x64 falco-windows-arm64 \
12-
test test-plugin-unit test-interceptor test-codex-interceptor test-e2e test-codex-e2e \
12+
test test-plugin-unit test-interceptor test-codex-interceptor test-copilot-interceptor test-e2e test-codex-e2e \
1313
linux linux-x86_64 linux-aarch64 \
1414
macos macos-aarch64 macos-x86_64 macos-universal \
1515
windows windows-x64 windows-arm64 \
@@ -18,7 +18,7 @@ ARCH := $(shell uname -m)
1818
all: linux
1919

2020
## Build all components for the native architecture (no packaging)
21-
build: build-interceptor build-codex-interceptor build-plugin build-ctl
21+
build: build-interceptor build-codex-interceptor build-copilot-interceptor build-plugin build-ctl
2222

2323
## Build the Claude Code interceptor
2424
build-interceptor:
@@ -28,6 +28,9 @@ build-interceptor:
2828
build-codex-interceptor:
2929
cd hooks/codex && cargo build --release
3030

31+
build-copilot-interceptor:
32+
cd hooks/copilot && cargo build --release
33+
3134
## Build the plugin
3235
build-plugin:
3336
cd plugins/coding-agents-plugin && cargo build --release
@@ -51,7 +54,7 @@ falco-linux-bin-dir:
5154
@echo "build/falco-$(FALCO_VERSION)-$(ARCH)/usr/bin"
5255

5356
## Run all tests
54-
test: test-plugin-unit test-interceptor test-codex-interceptor test-e2e test-codex-e2e
57+
test: test-plugin-unit test-interceptor test-codex-interceptor test-copilot-interceptor test-e2e test-codex-e2e
5558

5659
## Run plugin unit tests (event parsing, verdict resolution, broker logic — no Falco needed)
5760
test-plugin-unit:
@@ -65,6 +68,9 @@ test-interceptor: build-interceptor
6568
test-codex-interceptor: build-codex-interceptor
6669
cd tests && cargo test --test codex_interceptor -- --nocapture
6770

71+
test-copilot-interceptor: build-copilot-interceptor
72+
cd tests && cargo test --test copilot_interceptor -- --nocapture
73+
6874
## Run end-to-end tests (Rust, cross-platform, requires Falco built)
6975
test-e2e: build
7076
cd tests && cargo test --test e2e --test e2e_monitor --test e2e_concurrent -- --nocapture
@@ -73,6 +79,9 @@ test-e2e: build
7379
test-codex-e2e: build-codex-interceptor build-plugin
7480
cd tests && cargo test --test e2e_codex -- --nocapture
7581

82+
test-copilot-e2e: build-copilot-interceptor build-plugin
83+
cd tests && cargo test --test e2e_copilot -- --nocapture
84+
7685
## Build Linux packages for all architectures
7786
linux: linux-x86_64 linux-aarch64
7887

@@ -133,6 +142,7 @@ falco-windows-arm64:
133142
clean:
134143
rm -rf build/
135144
-cd hooks/claude-code && cargo clean
145+
-cd hooks/copilot && cargo clean
136146
-cd plugins/coding-agents-plugin && cargo clean
137147
-cd tools/premptictl && cargo clean
138148
-cd tests && cargo clean

hooks/copilot/Cargo.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[package]
2+
name = "copilot-interceptor"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
7+
[[bin]]
8+
name = "copilot-interceptor"
9+
path = "src/main.rs"
10+
11+
[dependencies]
12+
serde = { version = "1", features = ["derive"] }
13+
serde_json = { version = "1", features = ["raw_value"] }
14+
15+
[target.'cfg(unix)'.dependencies]
16+
# `process` feature enables `rustix::process::getppid`. `std` is already in
17+
# the default feature set; we keep defaults to avoid surprising no_std-style
18+
# build modes.
19+
rustix = { version = "1", features = ["process"] }
20+
21+
[target.'cfg(windows)'.dependencies]
22+
uds_windows = "1.2"

hooks/copilot/Makefile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
PREFIX ?= $(HOME)/.prempti
2+
3+
.PHONY: all linux macos install clean test
4+
5+
all:
6+
cargo build --release
7+
8+
linux: all
9+
10+
macos:
11+
cargo build --release --target aarch64-apple-darwin
12+
cargo build --release --target x86_64-apple-darwin
13+
lipo -create -output target/release/copilot-interceptor \
14+
target/aarch64-apple-darwin/release/copilot-interceptor \
15+
target/x86_64-apple-darwin/release/copilot-interceptor
16+
17+
install: all
18+
install -d $(PREFIX)/bin
19+
install -m 755 target/release/copilot-interceptor $(PREFIX)/bin/
20+
21+
clean:
22+
cargo clean
23+
24+
test: all
25+
@cd ../.. && bash tests/test_interceptor.sh

hooks/copilot/README.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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

Comments
 (0)