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
18 changes: 17 additions & 1 deletion adapters/cline/project/.cline/hooks/broker-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -41,4 +57,4 @@ for (const bin of [
}
}

process.stdout.write(JSON.stringify({ permission: "allow" }));
denyCliUnavailable();
18 changes: 17 additions & 1 deletion adapters/cursor/project/.cursor/hooks/broker-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -41,4 +57,4 @@ for (const bin of [
}
}

process.stdout.write(JSON.stringify({ permission: "allow" }));
denyCliUnavailable();
18 changes: 17 additions & 1 deletion adapters/roo-code/project/.roo/hooks/broker-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -41,4 +57,4 @@ for (const bin of [
}
}

process.stdout.write(JSON.stringify({ permission: "allow" }));
denyCliUnavailable();
30 changes: 15 additions & 15 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
56 changes: 45 additions & 11 deletions packages/cli/src/broker-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
Expand Down
54 changes: 47 additions & 7 deletions packages/cli/src/broker-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ function asRecord(value: unknown): Record<string, unknown> | 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<string, unknown>,
): { capability: string; target?: string } | null {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
64 changes: 2 additions & 62 deletions packages/core/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { canonicalJson } from "./canonical-json.js";
import { SqliteLedger } from "./ledger.js";
import {
authorizeAction,
emptyPermissionSet,
intersectStructuredPermissions,
normalizeNetworkReadDomain,
permissionSetIsSubset,
policyForMode,
Expand Down Expand Up @@ -159,17 +161,6 @@ const MAX_TOOL_CALLS_PER_RUN = 4_000;
const REFERENCE_URI_PATTERN =
/^(?:artifact|trace|repo):\/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/u;

function emptyPermissionSet(): StructuredPermissionSet {
return {
repository: { read: [], write: [], delete: [] },
shell: { inspect: false, executeAllowlist: [] },
runtime: { inspect: [], restart: [] },
browser: { inspect: false, mutateState: false },
network: { readDomains: [], externalWrite: false },
subagents: { spawn: false, maximumChildren: 0, maximumDepth: 0 },
};
}

function capabilitiesToPermissionSet(
capabilities: string[],
shellExecuteAllowlist: string[] = [],
Expand Down Expand Up @@ -228,57 +219,6 @@ function capabilitiesToPermissionSet(
return permissions;
}

function intersectStructuredPermissions(
projection: ReturnType<typeof projectPermissions>,
granted: StructuredPermissionSet,
denied: StructuredPermissionSet,
): StructuredPermissionSet {
const effective = emptyPermissionSet();
if (projection.repository_read && denied.repository.read.length === 0) {
effective.repository.read = [...granted.repository.read];
}
if (projection.repository_write && denied.repository.write.length === 0) {
effective.repository.write = [...granted.repository.write];
}
if (projection.repository_delete && denied.repository.delete.length === 0) {
effective.repository.delete = [...granted.repository.delete];
}
if (projection.shell_execute && denied.shell.executeAllowlist.length === 0) {
effective.shell.executeAllowlist = [...granted.shell.executeAllowlist];
}
effective.shell.inspect =
projection.shell_inspect && granted.shell.inspect && !denied.shell.inspect;
if (projection.runtime_inspect && denied.runtime.inspect.length === 0) {
effective.runtime.inspect = [...granted.runtime.inspect];
}
if (projection.runtime_mutate && denied.runtime.restart.length === 0) {
effective.runtime.restart = [...granted.runtime.restart];
}
effective.browser.inspect =
projection.browser_inspect &&
granted.browser.inspect &&
!denied.browser.inspect;
effective.browser.mutateState =
projection.browser_mutate &&
granted.browser.mutateState &&
!denied.browser.mutateState;
if (projection.network_read && denied.network.readDomains.length === 0) {
effective.network.readDomains = [...granted.network.readDomains];
}
effective.network.externalWrite =
projection.external_write &&
granted.network.externalWrite &&
!denied.network.externalWrite;
if (
projection.subagent_spawn &&
granted.subagents.spawn &&
!denied.subagents.spawn
) {
effective.subagents = { ...granted.subagents };
}
return effective;
}

function explicitPermissionProjection(
mode: IntentMode,
envelope: unknown,
Expand Down
Loading