diff --git a/adapters/cline/project/.cline/hooks/broker-gate.mjs b/adapters/cline/project/.cline/hooks/broker-gate.mjs index f1ee864..516dc36 100644 --- a/adapters/cline/project/.cline/hooks/broker-gate.mjs +++ b/adapters/cline/project/.cline/hooks/broker-gate.mjs @@ -4,14 +4,30 @@ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +process.env.TELIC_BROKER_STRICT ??= "1"; + const stdin = readFileSync(0, "utf8"); const repositoryRoot = process.cwd(); +function denyCliUnavailable() { + process.stdout.write( + JSON.stringify({ + permission: "deny", + user_message: + "Telic broker-gate is unavailable in strict mode; install telic-mcp or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + agent_message: + "Telic broker-gate CLI is missing. Do not bypass this tool call without Telic permission evaluation.", + }), + ); + process.exit(0); +} + function tryBrokerGate(command, args) { const result = spawnSync(command, args, { input: stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env: process.env, }); if (result.error?.code === "ENOENT") return false; process.stdout.write(result.stdout ?? ""); @@ -41,4 +57,4 @@ for (const bin of [ } } -process.stdout.write(JSON.stringify({ permission: "allow" })); +denyCliUnavailable(); diff --git a/adapters/cursor/project/.cursor/hooks/broker-gate.mjs b/adapters/cursor/project/.cursor/hooks/broker-gate.mjs index f1ee864..516dc36 100644 --- a/adapters/cursor/project/.cursor/hooks/broker-gate.mjs +++ b/adapters/cursor/project/.cursor/hooks/broker-gate.mjs @@ -4,14 +4,30 @@ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +process.env.TELIC_BROKER_STRICT ??= "1"; + const stdin = readFileSync(0, "utf8"); const repositoryRoot = process.cwd(); +function denyCliUnavailable() { + process.stdout.write( + JSON.stringify({ + permission: "deny", + user_message: + "Telic broker-gate is unavailable in strict mode; install telic-mcp or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + agent_message: + "Telic broker-gate CLI is missing. Do not bypass this tool call without Telic permission evaluation.", + }), + ); + process.exit(0); +} + function tryBrokerGate(command, args) { const result = spawnSync(command, args, { input: stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env: process.env, }); if (result.error?.code === "ENOENT") return false; process.stdout.write(result.stdout ?? ""); @@ -41,4 +57,4 @@ for (const bin of [ } } -process.stdout.write(JSON.stringify({ permission: "allow" })); +denyCliUnavailable(); diff --git a/adapters/roo-code/project/.roo/hooks/broker-gate.mjs b/adapters/roo-code/project/.roo/hooks/broker-gate.mjs index f1ee864..516dc36 100644 --- a/adapters/roo-code/project/.roo/hooks/broker-gate.mjs +++ b/adapters/roo-code/project/.roo/hooks/broker-gate.mjs @@ -4,14 +4,30 @@ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +process.env.TELIC_BROKER_STRICT ??= "1"; + const stdin = readFileSync(0, "utf8"); const repositoryRoot = process.cwd(); +function denyCliUnavailable() { + process.stdout.write( + JSON.stringify({ + permission: "deny", + user_message: + "Telic broker-gate is unavailable in strict mode; install telic-mcp or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + agent_message: + "Telic broker-gate CLI is missing. Do not bypass this tool call without Telic permission evaluation.", + }), + ); + process.exit(0); +} + function tryBrokerGate(command, args) { const result = spawnSync(command, args, { input: stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env: process.env, }); if (result.error?.code === "ENOENT") return false; process.stdout.write(result.stdout ?? ""); @@ -41,4 +57,4 @@ for (const bin of [ } } -process.stdout.write(JSON.stringify({ permission: "allow" })); +denyCliUnavailable(); diff --git a/apps/web/components/copy-button.tsx b/apps/web/components/copy-button.tsx index d90d4c8..cd96a54 100644 --- a/apps/web/components/copy-button.tsx +++ b/apps/web/components/copy-button.tsx @@ -33,4 +33,3 @@ export function CopyButton({ ); } - diff --git a/apps/web/package.json b/apps/web/package.json index 638fecf..b7e24e1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,4 +30,3 @@ "node": ">=24" } } - diff --git a/docs/STATUS.md b/docs/STATUS.md index c044f33..f9110ac 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -6,20 +6,20 @@ Telic is an executable, local source preview. It is suitable for development and ## Current vertical slice -| Area | Current behavior | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Protocol | Strict Zod v4 schemas for controller, intent, serial execution, evidence, release, and trace artifacts; canonical bodies use camelCase and `schemaVersion: "1.0"` | -| Controller | Deterministic phase ordering, stage-aware verification preflight, one user-facing clarification, one contract revision, one shared remediation, and terminal reports | -| Topology (CAGT) | `micro`, `standard`, and `forensic` classification at `startRun`; EGEL promotes insufficient micro evidence to standard instead of terminal block | -| Tool broker | `telic_check_tool_action` plus preview `telic broker-gate` hooks for Cursor, Cline, and Roo; host-native calls outside MCP remain unintercepted | -| Forensic replay | `telic replay` CLI and `telic_replay_run` MCP inspector with digest verification; micro topology returns a degraded replay flag | -| Persistence | SQLite metadata/events plus immutable SHA-256-addressed JSON bodies; digest verification occurs on read | -| Context | Bounded inventory; token-boundary path ranking; eight-file zero-score fallback cap; relevance/file/byte budgets; path, symlink, duplicate, and heuristic secret controls | -| MCP | Local STDIO server with eleven tools plus a host-neutral `telic_workflow` prompt | -| CLI | npm-packaged and source-built `doctor`, `status`, `trace`, `artifact`, `replay`, `broker-gate`, and `mcp` commands | -| Host package | Codex reference plugin plus seven experimental source packs generated from one canonical skill and MCP bundle | -| Model access | None in the runtime; the active host model authors semantic artifacts | -| Network service | None required; normal transport is local STDIO | +| Area | Current behavior | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Protocol | Strict Zod v4 schemas for controller, intent, serial execution, evidence, release, and trace artifacts; canonical bodies use camelCase and `schemaVersion: "1.0"` | +| Controller | Deterministic phase ordering, stage-aware verification preflight, one user-facing clarification, one contract revision, one shared remediation, and terminal reports | +| Topology (CAGT) | `micro`, `standard`, and `forensic` classification at `startRun`; EGEL promotes insufficient micro evidence to standard instead of terminal block | +| Tool broker | `telic_check_tool_action` plus preview `telic broker-gate` hooks for Cursor, Cline, and Roo (`TELIC_BROKER_STRICT=1` by default in hooks); host-native calls outside MCP remain unintercepted | +| Forensic replay | `telic replay` CLI and `telic_replay_run` MCP inspector with digest verification; micro topology returns a degraded replay flag | +| Persistence | SQLite metadata/events plus immutable SHA-256-addressed JSON bodies; digest verification occurs on read | +| Context | Bounded inventory; token-boundary path ranking; eight-file zero-score fallback cap; relevance/file/byte budgets; path, symlink, duplicate, and heuristic secret controls | +| MCP | Local STDIO server with eleven tools plus a host-neutral `telic_workflow` prompt | +| CLI | npm-packaged and source-built `doctor`, `status`, `trace`, `artifact`, `replay`, `broker-gate`, and `mcp` commands | +| Host package | Codex reference plugin plus seven experimental source packs generated from one canonical skill and MCP bundle | +| Model access | None in the runtime; the active host model authors semantic artifacts | +| Network service | None required; normal transport is local STDIO | Automated checks cover protocol fixtures/invariants, controller transitions, permissions, ledger behavior, context selection/security controls, MCP service/tools, CLI behavior, an end-to-end artifact pipeline, and the standalone plugin handshake. The current verification commands are listed in [Installation](INSTALLATION.md); test totals are intentionally not frozen in prose. @@ -52,7 +52,7 @@ Current controls include strict schemas, bounded inputs, missing-reference rejec Important limits: -- **Host-native actions are mostly not intercepted.** If a host uses its own shell, editor, browser, or repository tool directly, Telic is not in that call path unless preview broker-gate hooks are installed (Cursor, Cline, Roo). Even then, prevention still depends on host sandboxing, approvals, and adapter compliance. +- **Host-native actions are mostly not intercepted.** If a host uses its own shell, editor, browser, or repository tool directly, Telic is not in that call path unless preview broker-gate hooks are installed (Cursor, Cline, Roo). With hooks enabled, strict mode denies mapped mutating tools when Telic cannot evaluate an active run. Set `TELIC_BROKER_PERMISSIVE=1` only for local development. Prevention still depends on host sandboxing, approvals, and adapter compliance. - **Same-user state is not an adversarial vault.** SHA-256 and SQLite consistency detect ordinary corruption and mismatches. A malicious process with the same OS account and filesystem access may be able to replace metadata and blobs together. Use OS permissions and an isolated account/workspace for stronger separation. - **Secret scanning is heuristic.** It can miss uncommon credentials and can exclude harmless text. Do not ground repositories containing secrets you are unwilling to store locally, and do not treat the context selector as a dedicated secret scanner. - **Exact local artifacts may be sensitive.** Selected source and submitted evidence are stored exactly in the content-addressed store. Hashing is identity/integrity metadata, not anonymization. diff --git a/packages/cli/src/broker-gate.test.ts b/packages/cli/src/broker-gate.test.ts index 123fb49..02a3126 100644 --- a/packages/cli/src/broker-gate.test.ts +++ b/packages/cli/src/broker-gate.test.ts @@ -1,25 +1,59 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; -import { evaluateBrokerGate, mapHookInputToToolCall } from "./broker-gate.js"; +import { + evaluateBrokerGate, + isBrokerStrict, + mapHookInputToToolCall, +} from "./broker-gate.js"; + +const writeHook = { + tool_name: "Write", + tool_input: { path: "src/foo.ts" }, +}; describe("broker gate", () => { + const envSnapshot = { ...process.env }; + + afterEach(() => { + process.env = { ...envSnapshot }; + }); + it("maps write tools to repository.write", () => { + expect(mapHookInputToToolCall(writeHook)).toEqual({ + capability: "repository.write", + target: "src/foo.ts", + }); + }); + + it("fails open without an active session when not strict", () => { + delete process.env.TELIC_BROKER_STRICT; + delete process.env.TELIC_BROKER_PERMISSIVE; expect( - mapHookInputToToolCall({ - tool_name: "Write", - tool_input: { path: "src/foo.ts" }, + evaluateBrokerGate({ + repositoryRoot: process.cwd(), + hookInput: writeHook, }), - ).toEqual({ capability: "repository.write", target: "src/foo.ts" }); + ).toEqual({ permission: "allow" }); + }); + + it("fails closed without an active session when strict", () => { + process.env.TELIC_BROKER_STRICT = "1"; + const result = evaluateBrokerGate({ + repositoryRoot: process.cwd(), + hookInput: writeHook, + }); + expect(result.permission).toBe("deny"); + expect(result.user_message).toContain("strict mode"); }); - it("fails open without an active session", () => { + it("respects permissive override", () => { + process.env.TELIC_BROKER_STRICT = "1"; + process.env.TELIC_BROKER_PERMISSIVE = "1"; + expect(isBrokerStrict()).toBe(false); expect( evaluateBrokerGate({ repositoryRoot: process.cwd(), - hookInput: { - tool_name: "Write", - tool_input: { path: "src/foo.ts" }, - }, + hookInput: writeHook, }), ).toEqual({ permission: "allow" }); }); diff --git a/packages/cli/src/broker-gate.ts b/packages/cli/src/broker-gate.ts index fc2228d..debf7d2 100644 --- a/packages/cli/src/broker-gate.ts +++ b/packages/cli/src/broker-gate.ts @@ -27,6 +27,24 @@ function asRecord(value: unknown): Record | null { : null; } +export function isBrokerStrict(): boolean { + if (process.env.TELIC_BROKER_PERMISSIVE === "1") { + return false; + } + return process.env.TELIC_BROKER_STRICT === "1"; +} + +function denyStrict( + userMessage: string, + agentMessage: string, +): HookPermissionResponse { + return { + permission: "deny", + user_message: userMessage, + agent_message: agentMessage, + }; +} + export function mapHookInputToToolCall( hookInput: Record, ): { capability: string; target?: string } | null { @@ -60,20 +78,36 @@ export function mapHookInputToToolCall( export function evaluateBrokerGate( request: BrokerGateRequest, ): HookPermissionResponse { + const strict = isBrokerStrict(); const repositoryRoot = realpathSync(resolve(request.repositoryRoot)); const stateDirectory = process.env.TELIC_STATE_DIR ? resolve(process.env.TELIC_STATE_DIR) : defaultStateDirectory(repositoryRoot); + const mapped = mapHookInputToToolCall(request.hookInput); const session = readActiveSession(stateDirectory); - if (!session || session.repositoryRoot !== repositoryRoot) { + + if (!mapped) { return { permission: "allow" }; } - if (!existsSync(resolve(stateDirectory, "ledger.sqlite3"))) { - return { permission: "allow" }; + + if (!session || session.repositoryRoot !== repositoryRoot) { + if (!strict) { + return { permission: "allow" }; + } + return denyStrict( + "Telic broker is in strict mode but no active Telic session matches this repository.", + "Start or resume a Telic run before mutating tools, or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + ); } - const mapped = mapHookInputToToolCall(request.hookInput); - if (!mapped) { - return { permission: "allow" }; + + if (!existsSync(resolve(stateDirectory, "ledger.sqlite3"))) { + if (!strict) { + return { permission: "allow" }; + } + return denyStrict( + "Telic broker is in strict mode but no ledger exists for this repository.", + "Run telic doctor and ensure Telic state is initialized before mutating tools.", + ); } const ledger = new SqliteLedger(stateDirectory); @@ -84,7 +118,13 @@ export function evaluateBrokerGate( run.status !== "running" || run.version !== session.runVersion ) { - return { permission: "allow" }; + if (!strict) { + return { permission: "allow" }; + } + return denyStrict( + "Telic broker is in strict mode but the active session run is not running.", + "Resume or start a Telic run before mutating tools, or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + ); } const envelopeRecord = ledger.findLatestArtifact( session.runId, diff --git a/plugins/telic/hooks/broker-gate.mjs b/plugins/telic/hooks/broker-gate.mjs index f1ee864..516dc36 100644 --- a/plugins/telic/hooks/broker-gate.mjs +++ b/plugins/telic/hooks/broker-gate.mjs @@ -4,14 +4,30 @@ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +process.env.TELIC_BROKER_STRICT ??= "1"; + const stdin = readFileSync(0, "utf8"); const repositoryRoot = process.cwd(); +function denyCliUnavailable() { + process.stdout.write( + JSON.stringify({ + permission: "deny", + user_message: + "Telic broker-gate is unavailable in strict mode; install telic-mcp or set TELIC_BROKER_PERMISSIVE=1 for local development only.", + agent_message: + "Telic broker-gate CLI is missing. Do not bypass this tool call without Telic permission evaluation.", + }), + ); + process.exit(0); +} + function tryBrokerGate(command, args) { const result = spawnSync(command, args, { input: stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], + env: process.env, }); if (result.error?.code === "ENOENT") return false; process.stdout.write(result.stdout ?? ""); @@ -41,4 +57,4 @@ for (const bin of [ } } -process.stdout.write(JSON.stringify({ permission: "allow" })); +denyCliUnavailable();