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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.91] - 2026-09-01

### Fixed

- Complete native Cursor hook handling and harden confirmation and shell-write enforcement.

## [0.1.90] - 2026-08-12

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ assuming a gate that works on Claude Code also works elsewhere.
|---|---|---|---|---|
| **claude-code** | `adapters/claude/index.ts` | Full: `evaluate` + APEX gates via `handleHook` | 14 event types implemented in `runtime/lifecycle/dispatch.ts` (SessionStart, SessionEnd, SubagentStart/Stop, Stop, PreCompact, PostCompact, TaskCompleted, TeammateIdle, PostToolUseFailure, InstructionsLoaded, UserPromptSubmit, plus Pre/PostToolUse) | Only PreToolUse+PostToolUse are wired by `harness init` (`init/templates.ts:18-27`); the other 12 event types require the consumer's own `.claude/settings.json` to route them. |
| **codex** | `adapters/codex/index.ts` + `adapters/codex/apply-patch.ts` | `Bash \| apply_patch` matcher, PostToolUse (`init/templates.ts:29-38`). **`apply_patch` edits are gated**: the patch text is parsed per file, each hunk runs the file gates (protected-path, file-size, DRY) and one violating hunk denies the whole patch (`runtime/apply-patch-gate.ts`, sim scenarios 22-23). `ask` is downgraded to an explicit deny (`respond.ts`) — Codex fails open on unsupported shapes; the deny now carries a `CONFIRM <code>` recourse (`runtime/confirm/`, see below). | none wired | Upstream: Codex does not always enforce a correct `apply_patch` deny (openai/codex#27833) — the harness emits the right verdict, enforcement is Codex's. Do not add a Codex `PermissionRequest` path until `respond()` emits Codex's own wire shape (`codex/index.ts`). |
| **cursor** | `adapters/cursor/index.ts` | `beforeShellExecution` can deny/ask (shell only, lines 16-21) | none | File edits are **advisory only**: `afterFileEdit` always returns `allow` + a `user_message` correction on violation — a `deny` there has no proven effect (hook launched "informational only"; Cursor's deny-enforcement for file ops is confirmed broken upstream, forum.cursor.com/t/154377). The human sees the message; the model is never re-informed. Platform ceiling, sourced in the adapter JSDoc. |
| **cursor** | `adapters/cursor/index.ts` | The full runtime normalizes native `beforeShellExecution.command` and `preToolUse` `Shell` as `Bash`; `Write` maps to `Edit`. Policy `ask` degrades to native `permission:"deny"` because Cursor does not reliably apply `ask` on `preToolUse`. The public shell/tool adapter uses the same extractor. | `afterFileEdit` observation | `afterFileEdit.edits[]` is fanned out for post consumers and framework visibility, but the edit has already happened: this path remains **advisory only** and cannot retroactively block or roll back the write. |
| **gemini-cli** | `adapters/gemini/index.ts` | `BeforeTool` denies via `{decision:"deny",reason}` (lines 22-36) | none | Thin stateless adapter — no session track, no APEX gates reachable through it. |
| **cline** | `adapters/cline/index.ts` | `PreToolUse` only; block → `{cancel:true}`, non-block → `contextModification` (lines 24-36) | none | Same as gemini-cli: stateless guard only, `PreToolUse` cannot modify tool parameters (per docs.cline.bot). |
| **hermes** | `adapters/hermes/index.ts` | `pre_tool_call` proven: reuses the Claude stdin reader, blocks via `{decision:"block",reason}` (lines 12-36) | untested — no lifecycle dispatch wired for Hermes in this repo | `ask`/`inform` degrade to non-blocking `{context}` — Hermes "has no interactive ask state" (lines 27-28). |
Expand Down
20 changes: 14 additions & 6 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ const { stdout, exit } = await handleHook(id, payload, {
content blocks (`[{type,text}]`), which is flattened by joining each
block's `.text` with `"\n"`; anything else yields `""`, never a throw — then
`detectCreationIntent` → `recordBrainstormRequired` (`brainstormGate` fires
on the next edit) and `handleConfirmSubmit` (`./confirm/confirm-submit.ts`)
parses the same text for a `CONFIRM <code>` reply or an explicit refusal —
see [adapters.md](./adapters.md#confirm-code--recourse-for-a-degraded-ask).

`normalizeEvent(id, payload)` unifies the payload shapes (Claude/Codex/Gemini/
Cursor `tool_name`+`tool_input`; Cline nested `preToolUse`).
on the next edit). Kimi then uses the legacy `handleConfirmSubmit`; Codex
processes the same reply before scope-specific early returns and binds it to
a versioned action identity (tool, canonical cwd, canonical command). Its
atomic consumed receipt authorizes sibling callbacks sharing one
`tool_use_id`; it proves authorization at PreToolUse, not command execution.
See [adapters.md](./adapters.md#confirm-code--recourse-for-a-degraded-ask).

`normalizeEvent(id, payload)` unifies the payload shapes. Cursor has a dedicated
branch for its native events: top-level `beforeShellExecution.command` and
`preToolUse` `Shell` both become `Bash`, `Write` becomes `Edit`, and
`afterFileEdit.edits[]` becomes ordered per-edit post fan-out. That post fan-out
improves observation and framework detection only; it cannot block an edit that
already happened. Claude/Codex/Gemini keep `tool_name`+`tool_input`, and Cline
keeps its nested `preToolUse` shape.

## `gate(input)`

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/harness",
"version": "0.1.90",
"version": "0.1.91",
"description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
"type": "module",
"module": "src/index.ts",
Expand Down Expand Up @@ -121,6 +121,7 @@
}
},
"files": [
"src/**/*.ts",
"dist",
"assets",
"README.md",
Expand All @@ -138,7 +139,7 @@
"test": "bun test",
"sim": "bun test test/sim/",
"typecheck": "tsc --noEmit",
"docs:api": "typedoc",
"docs:api": "bun install --cwd tools/api-docs --frozen-lockfile && bun run --cwd tools/api-docs generate",
"build": "tsdown src/index.ts src/config/index.ts src/util/index.ts src/detect/index.ts src/policy/index.ts src/prompt/index.ts src/memory/index.ts src/cache/index.ts src/freshness/index.ts src/refs/index.ts src/statusline/index.ts src/cli/index.ts src/cli/bin.ts src/init/index.ts src/tracking/index.ts src/runtime/index.ts src/adapters/claude/index.ts src/adapters/codex/index.ts src/adapters/cursor/index.ts src/adapters/cline/index.ts src/adapters/gemini/index.ts src/adapters/hermes/index.ts src/adapters/kimi/index.ts --dts --format esm --clean --out-dir dist",
"prepublishOnly": "bun test && tsc --noEmit && bun run build"
},
Expand Down
46 changes: 46 additions & 0 deletions src/adapters/cursor/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isAbsolute, normalize, relative } from "node:path";
import { realpathSync } from "node:fs";

/** Preserve a Cursor path value only when it is non-empty and NUL-free. */
export function cursorPath(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 && !value.includes("\0") ? value : undefined;
}

/** Validate and normalize an absolute path supplied by Cursor. */
export function cursorAbsolutePath(value: unknown): string | undefined {
const candidate = cursorPath(value);
if (!candidate || !isAbsolute(candidate)) return undefined;
const path = normalize(candidate);
try {
return realpathSync.native(path);
} catch {
return path;
}
}

/** Preserve distinct, validated Cursor workspace roots in wire order. */
export function cursorWorkspaceRoots(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const roots = value.map(cursorAbsolutePath).filter((root): root is string => root !== undefined);
return [...new Set(roots)];
}

function contains(root: string, filePath: string): boolean {
const rel = relative(root, filePath);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}

/** Select Cursor's project scope without replacing a valid payload cwd. */
export function cursorProjectCwd(
cwd: string | undefined,
workspaceRoots: readonly string[],
filePath: string | undefined,
fallback: string,
): string {
if (cwd) return cwd;
if (filePath) {
const matches = workspaceRoots.filter((root) => contains(root, filePath));
if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!;
}
return workspaceRoots[0] ?? fallback;
}
38 changes: 38 additions & 0 deletions src/adapters/cursor/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { CursorEventContract } from "./interfaces/types";

const EVENT_CONTRACTS = {
sessionStart: { phase: "pre", lifecycle: "SessionStart", response: "session-context", blockable: false, known: true },
sessionEnd: { phase: "post", lifecycle: "SessionEnd", response: "neutral", blockable: false, known: true },
beforeSubmitPrompt: { phase: "pre", lifecycle: "UserPromptSubmit", response: "submit-control", blockable: true, known: true },
preCompact: { phase: "pre", lifecycle: "PreCompact", response: "compact-notice", blockable: false, known: true },
subagentStart: { phase: "pre", lifecycle: "SubagentStart", response: "permission", blockable: true, known: true },
subagentStop: { phase: "post", lifecycle: "SubagentStop", response: "followup", blockable: false, known: true },
preToolUse: { phase: "pre", lifecycle: "PreToolUse", response: "permission", blockable: true, known: true },
postToolUse: { phase: "post", lifecycle: "PostToolUse", response: "post-context", blockable: false, known: true },
postToolUseFailure: { phase: "post", lifecycle: "PostToolUseFailure", response: "neutral", blockable: false, known: true },
beforeShellExecution: { phase: "pre", lifecycle: "BeforeShellExecution", response: "permission", blockable: true, known: true },
afterShellExecution: { phase: "post", lifecycle: "AfterShellExecution", response: "neutral", blockable: false, known: true },
beforeMCPExecution: { phase: "pre", lifecycle: "BeforeMCPExecution", response: "permission", blockable: true, known: true },
afterMCPExecution: { phase: "post", lifecycle: "AfterMCPExecution", response: "neutral", blockable: false, known: true },
beforeReadFile: { phase: "pre", lifecycle: "BeforeReadFile", response: "permission", blockable: true, known: true },
afterFileEdit: { phase: "post", lifecycle: "AfterFileEdit", response: "neutral", blockable: false, known: true },
beforeTabFileRead: { phase: "pre", lifecycle: "BeforeTabFileRead", response: "permission", blockable: true, known: true },
afterTabFileEdit: { phase: "post", lifecycle: "AfterTabFileEdit", response: "neutral", blockable: false, known: true },
afterAgentResponse: { phase: "post", lifecycle: "AfterAgentResponse", response: "neutral", blockable: false, known: true },
afterAgentThought: { phase: "post", lifecycle: "AfterAgentThought", response: "neutral", blockable: false, known: true },
stop: { phase: "post", lifecycle: "Stop", response: "followup", blockable: false, known: true },
workspaceOpen: { phase: "pre", lifecycle: "WorkspaceOpen", response: "plugin-paths", blockable: false, known: true },
} as const satisfies Record<string, CursorEventContract>;

const UNKNOWN_EVENT: CursorEventContract = {
phase: "post",
lifecycle: null,
response: "neutral",
blockable: false,
known: false,
};

/** Return explicit routing and response metadata for a Cursor event name. */
export function cursorEventContract(eventName: string): CursorEventContract {
return EVENT_CONTRACTS[eventName as keyof typeof EVENT_CONTRACTS] ?? UNKNOWN_EVENT;
}
61 changes: 29 additions & 32 deletions src/adapters/cursor/index.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,43 @@
/**
* Cursor adapter (hook-mode). Schemas per cursor.com/docs/hooks (2026):
* `beforeShellExecution` can block; `afterFileEdit` is observe-only.
*/
/** Cursor hook adapter; post-edit handling remains observe-only. */
import { evaluate } from "../../policy/evaluate";
import { formatPrompt, type PromptKind } from "../../prompt/types";
import type { CursorShellPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types";
import { formatPrompt } from "../../prompt/types";
import { extractCursorEvent } from "./normalize";
import type { CursorShellPayload, CursorToolPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types";

export type { CursorShellPayload, CursorToolPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types";

export type { CursorShellPayload, CursorEditPayload, CursorResponse, CursorEditResponse } from "./interfaces/types";
function guardCursor(payload: object): CursorResponse {
const event = extractCursorEvent(payload);
const result = evaluate({ tool: event.tool, filePath: event.filePath, content: event.content, oldString: event.oldString, command: event.command });
if (result.decision === "allow" || !result.prompt) return { permission: "allow" };
const message = formatPrompt(result.prompt);
const userMessage = result.prompt.kind === "ask"
? `[downgraded from ask — Cursor preToolUse does not reliably enforce ask]\n${message}`
: message;
return { permission: "deny", user_message: userMessage, agent_message: message };
}

function toPermission(kind: PromptKind): "allow" | "deny" | "ask" {
return kind === "block" ? "deny" : kind === "ask" ? "ask" : "allow";
function namedPayload(payload: object, eventName: string): object {
return Object.hasOwn(payload, "hook_event_name") ? payload : { ...payload, hook_event_name: eventName };
}

/** Guard a shell command (git/install policies). */
export function beforeShellExecution(payload: CursorShellPayload): CursorResponse {
const r = evaluate({ tool: "Bash", command: payload.command });
if (r.decision === "allow" || !r.prompt) return { permission: "allow" };
const msg = formatPrompt(r.prompt);
return { permission: toPermission(r.prompt.kind), continue: false, user_message: msg, agent_message: msg };
return guardCursor(namedPayload(payload, "beforeShellExecution"));
}

/** Guard a generic Cursor tool call using the same extraction as the runtime. */
export function preToolUse(payload: CursorToolPayload): CursorResponse {
return guardCursor(namedPayload(payload, "preToolUse"));
}

/**
* Advise on a file edit AFTER Cursor has written it — a HUMAN-VISIBLE audit note,
* never a gate. This is an "after" hook: the edit is already on disk. On a
* SOLID/DRY violation we surface the correction through `user_message` (the only
* channel afterFileEdit exposes — no `agent_message`, so the model is never
* re-informed) while ALWAYS returning `permission: "allow"`.
*
* We deliberately never emit `permission: "deny"` here, for two distinct reasons:
* (1) structural — afterFileEdit was "informational only" at launch (Chacon,
* Cursor hooks beta 1.7, 2025-09: no channel to stop the agent), and a post-write
* deny has no documented rollback; (2) empirical — Cursor staff confirm the
* deny-enforcement path is broken for file operations (forum.cursor.com/t/154377,
* v2.6.18, 2026-03, open) — proven for file READS, plausibly the same for writes.
* So a `deny` would be a false blocking signal; `allow` + `user_message` is the
* only proven-safe shape.
* Complete an observed file edit without emitting pre-execution permission
* fields. Cursor does not define a callback schema for this post hook.
* @param payload - The `afterFileEdit` stdin payload.
* @returns Always an allow; carries the user-visible correction on a violation.
* @returns An empty successful response.
*/
export function afterFileEdit(payload: CursorEditPayload): CursorEditResponse {
const content = payload.edits?.map((e) => e.new_string).join("\n") ?? "";
const r = evaluate({ tool: "Edit", filePath: payload.file_path, content });
if (r.decision !== "deny" || !r.prompt) return { permission: "allow" };
return { permission: "allow", user_message: formatPrompt(r.prompt) };
void payload;
return {};
}
70 changes: 58 additions & 12 deletions src/adapters/cursor/interfaces/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,76 @@
export interface CursorShellPayload {
command?: string;
cwd?: string;
sandbox?: boolean;
workspace_roots?: string[];
hook_event_name?: string;
}

/** `preToolUse` stdin payload subset consumed by the adapter. */
export interface CursorToolPayload {
tool_name?: string;
tool_input?: Record<string, unknown>;
}

/** `afterFileEdit` stdin payload (subset). */
export interface CursorEditPayload {
file_path?: string;
edits?: { old_string: string; new_string: string }[];
}

/** One extracted Cursor file edit used by runtime post fan-out. */
export interface CursorExtractedFile {
filePath: string;
oldString?: string;
content: string;
op: "update";
}

/** Shared Cursor extraction result consumed by runtime and public adapter. */
export interface CursorExtractedEvent {
eventName: string;
lifecycleEvent: string | null;
responseKind: CursorResponseKind;
blockable: boolean;
cwd?: string;
workspaceRoots?: string[];
phase: "pre" | "post";
tool: string;
input: Record<string, unknown>;
filePath?: string;
content?: string;
oldString?: string;
command?: string;
/** Distinct beforeMCPExecution root and nested commands, in wire order. */
commandCandidates?: string[];
files?: CursorExtractedFile[];
}

/** Native Cursor stdout contract selected for one hook event. */
export type CursorResponseKind =
| "permission"
| "post-context"
| "session-context"
| "submit-control"
| "followup"
| "compact-notice"
| "plugin-paths"
| "neutral";

/** Routing metadata for a documented Cursor lifecycle event. */
export interface CursorEventContract {
phase: "pre" | "post";
lifecycle: string | null;
response: CursorResponseKind;
blockable: boolean;
known: boolean;
}

/**
* `afterFileEdit` stdout response. Its schema (cursor.com/docs/hooks#afterFileEdit)
* is DELIBERATELY narrower than the "before" hooks: `permission` + `user_message`
* only — there is NO `agent_message` and NO `updated_input`. Since the edit is
* already on disk when this "after" hook fires, `deny` cannot revert it and the
* correction reaches only the HUMAN (`user_message`), never the model — so this
* path is strictly ADVISORY, not an enforceable gate.
* Empty afterFileEdit callback. Cursor documents no output fields for this
* post hook, so pre-execution permission fields are intentionally impossible.
*/
export interface CursorEditResponse {
permission: "allow" | "deny";
/** User-visible correction — snake_case (#141516); the only channel afterFileEdit exposes. */
user_message?: string;
}
export type CursorEditResponse = Record<string, never>;

/**
* `beforeShellExecution` stdout response. Message keys are snake_case:
Expand All @@ -34,7 +81,6 @@ export interface CursorEditResponse {
*/
export interface CursorResponse {
permission: "allow" | "deny" | "ask";
continue?: boolean;
/** User-visible message — snake_case required (#141516, #142589). */
user_message?: string;
/** Agent-visible message — snake_case required (#141516, #142589). */
Expand Down
Loading