Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Features
- Add GitHub Copilot CLI hook integration **(beta)**. Install hooks for Copilot via `failproofai policies --install --cli copilot` (or `--cli claude codex copilot` for all three). Supports the six PascalCase Copilot hook events (`SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`) and writes to `~/.copilot/hooks/failproofai.json` (user) / `<cwd>/.github/hooks/failproofai.json` (project). Copilot's hook protocol exposes a "VS Code compatible" PascalCase mode whose stdin payload (snake_case fields) and stdout output shape (`hookSpecificOutput.permissionDecision`) match Claude Code's, so the existing handler and policy-evaluator work unchanged. Hook entries use Copilot's OS-keyed `bash`/`powershell` command fields with a `timeoutSec` (vs Claude's single `command` + `timeout` ms) and the file carries a top-level `version: 1` marker like Codex's `hooks.json`. New `--cli copilot` flag is wired through the existing CLI parser, install prompt (now picks "All" when 3+ agent CLIs are detected), telemetry, and activity store. `isAgentInternalPath` / `isAgentSettingsFile` extended to cover `~/.copilot/`, `.copilot/hooks/*.json`, and `.github/hooks/*.json` so existing path-protection rules apply to Copilot out of the box. Activity dashboard CLI filter and per-row badge accept `cli=copilot`, the session viewer falls back to `~/.copilot/session-state/<id>/events.jsonl`, and `/projects` lists Copilot projects alongside Claude / Codex via a new `lib/copilot-projects.ts`. Marked beta because Copilot's `events.jsonl` record schema is not publicly documented; the parser is best-effort and based on the documented hook payload shapes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## 0.0.9 — 2026-04-28

### Features
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

**Translations**: [简体中文](docs/i18n/README.zh.md) | [日本語](docs/i18n/README.ja.md) | [한국어](docs/i18n/README.ko.md) | [Español](docs/i18n/README.es.md) | [Português](docs/i18n/README.pt-br.md) | [Deutsch](docs/i18n/README.de.md) | [Français](docs/i18n/README.fr.md) | [Русский](docs/i18n/README.ru.md) | [हिन्दी](docs/i18n/README.hi.md) | [Türkçe](docs/i18n/README.tr.md) | [Tiếng Việt](docs/i18n/README.vi.md) | [Italiano](docs/i18n/README.it.md) | [العربية](docs/i18n/README.ar.md) | [עברית](docs/i18n/README.he.md)

The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex** & the **Agents SDK**.
The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code**, **OpenAI Codex**, **GitHub Copilot CLI** _(beta)_ & the **Agents SDK**.

<p align="center">
<img src="failproofai-hq.gif" alt="Failproof AI in action" width="800" />
Expand All @@ -37,10 +37,14 @@ The easiest way to manage policies that keep your AI agents reliable, on-task, a
</picture>
</a>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks" title="GitHub Copilot CLI">
<img src="assets/logos/copilot.svg" alt="GitHub Copilot" width="64" height="64" />
</a>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<strong>+ more coming soon</strong>
</p>

> Install hooks for one or both: `failproofai policies --install --cli codex` (or `--cli claude codex`). Omit `--cli` to auto-detect installed CLIs and prompt.
> Install hooks for one, two, or all three: `failproofai policies --install --cli copilot` (or `--cli claude codex copilot`). Omit `--cli` to auto-detect installed CLIs and prompt. **GitHub Copilot CLI support is in beta.**

- **39 Built-in Policies** - Catch common agent failure modes out of the box. Block destructive commands, prevent secret leakage, keep agents inside project boundaries, detect loops, and more.
- **Custom Policies** - Write your own reliability rules in JavaScript. Use the `allow`/`deny`/`instruct` API to enforce conventions, prevent drift, gate operations, or integrate with external systems.
Expand Down
135 changes: 129 additions & 6 deletions __tests__/components/project-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ describe("ProjectList", () => {
expect(screen.getByText("C:/code/myapp")).toBeInTheDocument();
});

// Helper: find badges (span elements with title="Agent CLI: X") to disambiguate
// from the new CLI filter dropdown's <option> elements (which share the same text).
function badgeNodes(label: string): Element[] {
return Array.from(document.querySelectorAll(`span[title="Agent CLI: ${label}"]`));
}

it("renders a Claude Code badge for cli=['claude']", () => {
const folders = makeFolders(1);
render(<ProjectList folders={folders} />);
expect(screen.getByText("Claude Code")).toBeInTheDocument();
expect(screen.queryByText("OpenAI Codex")).not.toBeInTheDocument();
expect(badgeNodes("Claude Code")).toHaveLength(1);
expect(badgeNodes("OpenAI Codex")).toHaveLength(0);
expect(badgeNodes("GitHub Copilot")).toHaveLength(0);
});

it("renders an OpenAI Codex badge for cli=['codex']", () => {
Expand All @@ -86,8 +93,8 @@ describe("ProjectList", () => {
},
];
render(<ProjectList folders={folders} />);
expect(screen.getByText("OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByText("Claude Code")).not.toBeInTheDocument();
expect(badgeNodes("OpenAI Codex")).toHaveLength(1);
expect(badgeNodes("Claude Code")).toHaveLength(0);
});

it("renders both badges when cli=['claude','codex']", () => {
Expand All @@ -102,8 +109,23 @@ describe("ProjectList", () => {
},
];
render(<ProjectList folders={folders} />);
expect(screen.getByText("Claude Code")).toBeInTheDocument();
expect(screen.getByText("OpenAI Codex")).toBeInTheDocument();
expect(badgeNodes("Claude Code")).toHaveLength(1);
expect(badgeNodes("OpenAI Codex")).toHaveLength(1);
});

it("renders a GitHub Copilot badge for cli=['copilot']", () => {
const folders: ProjectFolder[] = [
{
name: "-home-u-copilot",
path: "/home/u/copilot",
isDirectory: true,
lastModified: new Date(),
lastModifiedFormatted: "Jun 15, 2024",
cli: ["copilot"],
},
];
render(<ProjectList folders={folders} />);
expect(badgeNodes("GitHub Copilot")).toHaveLength(1);
});

it("links to /project/[name]", () => {
Expand Down Expand Up @@ -134,4 +156,105 @@ describe("ProjectList", () => {
render(<ProjectList folders={folders} />);
expect(screen.getByText(/Showing 1-25 of 30 projects/)).toBeInTheDocument();
});

it("CLI filter dropdown shows All CLIs + each known CLI label", () => {
render(<ProjectList folders={[]} />);
const select = screen.getByLabelText("Filter by CLI") as HTMLSelectElement;
const optionLabels = Array.from(select.querySelectorAll("option")).map((o) => o.textContent);
expect(optionLabels).toEqual([
"All CLIs",
"Claude Code",
"OpenAI Codex",
"GitHub Copilot",
]);
});

it("CLI filter narrows the visible rows to the chosen CLI", async () => {
const user = userEvent.setup();
const mixed: ProjectFolder[] = [
{
name: "-home-u-claude-only",
path: "/home/u/claude-only",
isDirectory: true,
lastModified: new Date("2026-04-01T00:00:00Z"),
lastModifiedFormatted: "Apr 1",
cli: ["claude"],
},
{
name: "-home-u-codex-only",
path: "/home/u/codex-only",
isDirectory: true,
lastModified: new Date("2026-04-02T00:00:00Z"),
lastModifiedFormatted: "Apr 2",
cli: ["codex"],
},
{
name: "-home-u-copilot-only",
path: "/home/u/copilot-only",
isDirectory: true,
lastModified: new Date("2026-04-03T00:00:00Z"),
lastModifiedFormatted: "Apr 3",
cli: ["copilot"],
},
];
render(<ProjectList folders={mixed} />);
expect(screen.getByText(/Showing 1-3 of 3 projects/)).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Filter by CLI"), "copilot");
expect(screen.getByText(/Showing 1-1 of 1 projects/)).toBeInTheDocument();
expect(screen.queryByText("/home/u/claude-only")).not.toBeInTheDocument();
expect(screen.queryByText("/home/u/codex-only")).not.toBeInTheDocument();
expect(screen.getByText("/home/u/copilot-only")).toBeInTheDocument();
});

it("CLI filter set to 'All CLIs' (empty value) shows all rows", async () => {
const user = userEvent.setup();
const mixed: ProjectFolder[] = [
{
name: "-home-u-claude",
path: "/home/u/claude",
isDirectory: true,
lastModified: new Date(),
cli: ["claude"],
},
{
name: "-home-u-codex",
path: "/home/u/codex",
isDirectory: true,
lastModified: new Date(),
cli: ["codex"],
},
];
render(<ProjectList folders={mixed} />);
const select = screen.getByLabelText("Filter by CLI");
await user.selectOptions(select, "claude");
expect(screen.getByText(/Showing 1-1 of 1 projects/)).toBeInTheDocument();
await user.selectOptions(select, "");
expect(screen.getByText(/Showing 1-2 of 2 projects/)).toBeInTheDocument();
});

it("CLI filter matches multi-CLI rows", async () => {
const user = userEvent.setup();
const folders: ProjectFolder[] = [
{
name: "-home-u-shared",
path: "/home/u/shared",
isDirectory: true,
lastModified: new Date(),
cli: ["claude", "copilot"],
},
{
name: "-home-u-codex-only",
path: "/home/u/codex-only",
isDirectory: true,
lastModified: new Date(),
cli: ["codex"],
},
];
render(<ProjectList folders={folders} />);
await user.selectOptions(screen.getByLabelText("Filter by CLI"), "copilot");
// shared has copilot in its cli array; codex-only does not.
// Path renders both as a link (Agent Root col) and plain text (Path col), hence getAllByText.
expect(screen.getAllByText("/home/u/shared").length).toBeGreaterThan(0);
expect(screen.queryByText("/home/u/codex-only")).not.toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion __tests__/e2e/helpers/hook-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface HookRunResult {
export function runHook(
event: string,
payload: Record<string, unknown>,
opts?: { homeDir?: string; cli?: "claude" | "codex" },
opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" },
): HookRunResult {
const binaryPath = getBinaryPath();

Expand Down
72 changes: 72 additions & 0 deletions __tests__/e2e/helpers/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,75 @@ export const CodexPayloads = {
};
},
};

/**
* Copilot CLI-accurate payload factories. We install Copilot hooks in
* "VS Code compatible" PascalCase mode, so Copilot delivers PascalCase
* `hook_event_name` plus snake_case fields (the same shape Claude uses).
*/
const COPILOT_SESSION_ID = "test-session-copilot-001";

export const CopilotPayloads = {
preToolUse: {
bash(command: string, cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command },
};
},
write(filePath: string, content: string, cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_input: { file_path: filePath, content },
};
},
read(filePath: string, cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "PreToolUse",
tool_name: "Read",
tool_input: { file_path: filePath },
};
},
},
postToolUse: {
bash(command: string, output: string, cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command },
tool_response: output,
};
},
},
userPromptSubmit(prompt: string, cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "UserPromptSubmit",
prompt,
};
},
stop(cwd: string): Record<string, unknown> {
return {
session_id: COPILOT_SESSION_ID,
transcript_path: TRANSCRIPT_PATH,
cwd,
hook_event_name: "Stop",
};
},
};
Loading