From f0c3eaf9991be7946f4f0fa58dbe2282521cfaef Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 02:28:40 +0200 Subject: [PATCH] refactor(extensions): merge ttsr into guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTSR (Time Traveling Stream Rules) monitored streaming output against regex patterns. Guardrails blocked dangerous actions and redacted secrets. Both are safety/guardrail concerns — merging them into one extension reduces surface area and simplifies the safety model. Changes: - Copied ttsr-rule-loader.js, ttsr-manager.js, ttsr-interrupt.md into guardrails/ - Updated guardrails extension-manifest.json with ttsr hooks (turn_start, message_update, turn_end, agent_end) - Integrated TTSR session_start/turn_start/message_update/turn_end/agent_end handlers into guardrails/index.js - Deleted ttsr/ extension directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../google-gemini-cli-provider/tsconfig.json | 2 +- pkg/dist/core/export-html/template.js | 4 + .../guardrails/extension-manifest.json | 4 +- src/resources/extensions/guardrails/index.js | 87 +++++++++++ .../{ttsr => guardrails}/ttsr-interrupt.md | 0 .../{ttsr => guardrails}/ttsr-manager.js | 0 .../ttsr-rule-loader.js} | 0 .../extensions/ttsr/extension-manifest.json | 17 --- src/resources/extensions/ttsr/index.js | 139 ------------------ 9 files changed, 94 insertions(+), 159 deletions(-) rename src/resources/extensions/{ttsr => guardrails}/ttsr-interrupt.md (100%) rename src/resources/extensions/{ttsr => guardrails}/ttsr-manager.js (100%) rename src/resources/extensions/{ttsr/rule-loader.js => guardrails/ttsr-rule-loader.js} (100%) delete mode 100644 src/resources/extensions/ttsr/extension-manifest.json delete mode 100644 src/resources/extensions/ttsr/index.js diff --git a/packages/google-gemini-cli-provider/tsconfig.json b/packages/google-gemini-cli-provider/tsconfig.json index 8a78dfc2b..adbe48ac6 100644 --- a/packages/google-gemini-cli-provider/tsconfig.json +++ b/packages/google-gemini-cli-provider/tsconfig.json @@ -13,7 +13,7 @@ "sourceMap": true, "inlineSources": true, "inlineSourceMap": false, - "moduleResolution": "NodeNext"}}]}<()>;等待下一步操作。<|assistant to=multi_tool_use.parallel __(/*!json*/)## Step: Rebuild and rerun autonomous SF after tsconfig updates. Also, verify build and run success before marking complete. { + "moduleResolution": "NodeNext", "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, diff --git a/pkg/dist/core/export-html/template.js b/pkg/dist/core/export-html/template.js index 419a0f2a5..9291b5790 100644 --- a/pkg/dist/core/export-html/template.js +++ b/pkg/dist/core/export-html/template.js @@ -1734,6 +1734,10 @@ codespan(token) { return `${escapeHtml(token.text)}`; }, + // Raw HTML blocks: escape to prevent XSS + html(token) { + return escapeHtml(token.text); + }, }, }); diff --git a/src/resources/extensions/guardrails/extension-manifest.json b/src/resources/extensions/guardrails/extension-manifest.json index c24b811da..f19fdd17a 100644 --- a/src/resources/extensions/guardrails/extension-manifest.json +++ b/src/resources/extensions/guardrails/extension-manifest.json @@ -2,11 +2,11 @@ "id": "guardrails", "name": "Guardrails", "version": "1.0.0", - "description": "Redact sensitive outputs and block dangerous file or shell actions", + "description": "Redact sensitive outputs, block dangerous actions, and monitor streaming output against regex patterns", "tier": "bundled", "requires": { "platform": ">=2.29.0" }, "provides": { "commands": ["safegit", "safegit-level", "safegit-status", "yolo"], - "hooks": ["session_start", "tool_call", "tool_result"] + "hooks": ["session_start", "tool_call", "tool_result", "turn_start", "message_update", "turn_end", "agent_end"] } } diff --git a/src/resources/extensions/guardrails/index.js b/src/resources/extensions/guardrails/index.js index 446a44559..581d65cf0 100644 --- a/src/resources/extensions/guardrails/index.js +++ b/src/resources/extensions/guardrails/index.js @@ -7,9 +7,14 @@ * - Redacts secrets from tool results before the LLM sees them * - Blocks dangerous bash commands (rm -rf, sudo, mkfs, etc.) * - Blocks writes to protected paths (.env, .git, .ssh, etc.) + * - Monitors streaming output against regex patterns (TTSR) * - Registers SF slash commands: /safegit, /safegit-level, /safegit-status, /yolo */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import * as path from "node:path"; +import { loadRules } from "./ttsr-rule-loader.js"; +import { TtsrManager } from "./ttsr-manager.js"; const SENSITIVE_PATTERNS = [ { @@ -554,6 +559,40 @@ function registerSafeGitCommands( }, }); } +const __dirname = import.meta.dirname; +function buildInterruptContent(rule) { + const template = readFileSync(join(__dirname, "ttsr-interrupt.md"), "utf-8"); + return template + .replace("{{name}}", rule.name) + .replace("{{path}}", rule.path) + .replace("{{content}}", rule.content); +} +function extractDeltaContext(event) { + if (event.type === "text_delta") { + return { delta: event.delta, context: { source: "text", streamKey: "text" } }; + } + if (event.type === "thinking_delta") { + return { delta: event.delta, context: { source: "thinking", streamKey: "thinking" } }; + } + if (event.type === "toolcall_delta") { + const partial = event.partial; + const contentBlock = partial?.content?.[event.contentIndex]; + const toolName = contentBlock && "name" in contentBlock ? contentBlock.name : undefined; + const filePaths = []; + if (contentBlock && "partialJson" in contentBlock) { + const json = contentBlock.partialJson; + if (json) { + const pathMatch = json.match(/"(?:file_path|path)"\s*:\s*"([^"]+)"/); + if (pathMatch) filePaths.push(pathMatch[1]); + } + } + return { + delta: event.delta, + context: { source: "tool", toolName, filePaths: filePaths.length > 0 ? filePaths : undefined, streamKey: `toolcall:${event.contentIndex}` }, + }; + } + return null; +} // ============================================================================ // Entry Point // ============================================================================ @@ -628,4 +667,52 @@ export default function guardrails(pi) { ctx, ); }); + // ── TTSR: Time Traveling Stream Rules ─────────────────────────────────── + let ttsrManager = null; + let pendingViolation = null; + pi.on("session_start", async (_event, ctx) => { + const rules = loadRules(ctx.cwd); + if (rules.length === 0) { + ttsrManager = null; + return; + } + ttsrManager = new TtsrManager(); + let loaded = 0; + for (const rule of rules) { + if (ttsrManager.addRule(rule)) loaded++; + } + if (loaded === 0) ttsrManager = null; + }); + pi.on("turn_start", async () => { + if (!ttsrManager) return; + ttsrManager.resetBuffer(); + pendingViolation = null; + }); + pi.on("message_update", async (event, ctx) => { + if (!ttsrManager || !ttsrManager.hasRules()) return; + if (pendingViolation) return; + const extracted = extractDeltaContext(event.assistantMessageEvent); + if (!extracted) return; + const { delta, context } = extracted; + const matches = ttsrManager.checkDelta(delta, context); + if (matches.length === 0) return; + pendingViolation = { rules: matches }; + ttsrManager.markInjected(matches); + ctx.abort(); + }); + pi.on("turn_end", async () => { + if (!ttsrManager) return; + ttsrManager.incrementMessageCount(); + }); + pi.on("agent_end", async () => { + if (!ttsrManager || !pendingViolation) return; + const violation = pendingViolation; + pendingViolation = null; + const interruptParts = violation.rules.map(buildInterruptContent); + const fullInterrupt = interruptParts.join("\n\n"); + pi.sendMessage( + { customType: "ttsr-violation", content: fullInterrupt, display: false }, + { triggerTurn: true }, + ); + }); } diff --git a/src/resources/extensions/ttsr/ttsr-interrupt.md b/src/resources/extensions/guardrails/ttsr-interrupt.md similarity index 100% rename from src/resources/extensions/ttsr/ttsr-interrupt.md rename to src/resources/extensions/guardrails/ttsr-interrupt.md diff --git a/src/resources/extensions/ttsr/ttsr-manager.js b/src/resources/extensions/guardrails/ttsr-manager.js similarity index 100% rename from src/resources/extensions/ttsr/ttsr-manager.js rename to src/resources/extensions/guardrails/ttsr-manager.js diff --git a/src/resources/extensions/ttsr/rule-loader.js b/src/resources/extensions/guardrails/ttsr-rule-loader.js similarity index 100% rename from src/resources/extensions/ttsr/rule-loader.js rename to src/resources/extensions/guardrails/ttsr-rule-loader.js diff --git a/src/resources/extensions/ttsr/extension-manifest.json b/src/resources/extensions/ttsr/extension-manifest.json deleted file mode 100644 index 22028bd9f..000000000 --- a/src/resources/extensions/ttsr/extension-manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "ttsr", - "name": "Time Traveling Stream Rules", - "version": "1.0.0", - "description": "Zero-context-cost guardrails that monitor streaming output against regex patterns", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "hooks": [ - "session_start", - "turn_start", - "message_update", - "turn_end", - "agent_end" - ] - } -} diff --git a/src/resources/extensions/ttsr/index.js b/src/resources/extensions/ttsr/index.js deleted file mode 100644 index fe639fad5..000000000 --- a/src/resources/extensions/ttsr/index.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * TTSR Extension — Time Traveling Stream Rules - * - * Zero-context-cost guardrails that monitor streaming output against regex - * patterns. On match: abort stream, inject rule as system reminder, retry. - * Rules cost nothing until they fire. - * - * Hooks: - * session_start → load rules, populate manager - * turn_start → reset buffers - * message_update → check delta against rules, abort on match - * turn_end → increment message count - * agent_end → if pending violation, inject rule via sendMessage - */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { loadRules } from "./rule-loader.js"; -import { TtsrManager } from "./ttsr-manager.js"; - -const __dirname = import.meta.dirname; -function buildInterruptContent(rule) { - const template = readFileSync(join(__dirname, "ttsr-interrupt.md"), "utf-8"); - return template - .replace("{{name}}", rule.name) - .replace("{{path}}", rule.path) - .replace("{{content}}", rule.content); -} -/** - * Extract match context from an AssistantMessageEvent delta. - * Returns null for non-delta events. - */ -function extractDeltaContext(event) { - if (event.type === "text_delta") { - return { - delta: event.delta, - context: { source: "text", streamKey: "text" }, - }; - } - if (event.type === "thinking_delta") { - return { - delta: event.delta, - context: { source: "thinking", streamKey: "thinking" }, - }; - } - if (event.type === "toolcall_delta") { - // Extract tool name and file paths from the partial message - const partial = event.partial; - const contentBlock = partial?.content?.[event.contentIndex]; - const toolName = - contentBlock && "name" in contentBlock ? contentBlock.name : undefined; - // Try to extract file paths from partial JSON arguments - const filePaths = []; - if (contentBlock && "partialJson" in contentBlock) { - const json = contentBlock.partialJson; - if (json) { - // Look for file_path or path in partial JSON - const pathMatch = json.match(/"(?:file_path|path)"\s*:\s*"([^"]+)"/); - if (pathMatch) filePaths.push(pathMatch[1]); - } - } - return { - delta: event.delta, - context: { - source: "tool", - toolName, - filePaths: filePaths.length > 0 ? filePaths : undefined, - streamKey: `toolcall:${event.contentIndex}`, - }, - }; - } - return null; -} - -export { loadRules } from "./rule-loader.js"; -// Re-exports for external consumers -export { TtsrManager } from "./ttsr-manager.js"; -export default function (pi) { - let manager = null; - let pendingViolation = null; - // ── session_start: load rules, populate manager ───────────────────── - pi.on("session_start", async (_event, ctx) => { - const rules = loadRules(ctx.cwd); - if (rules.length === 0) { - manager = null; - return; - } - manager = new TtsrManager(); - let loaded = 0; - for (const rule of rules) { - if (manager.addRule(rule)) loaded++; - } - if (loaded === 0) { - manager = null; - } - }); - // ── turn_start: reset buffers ─────────────────────────────────────── - pi.on("turn_start", async () => { - if (!manager) return; - manager.resetBuffer(); - pendingViolation = null; - }); - // ── message_update: check delta against rules ─────────────────────── - pi.on("message_update", async (event, ctx) => { - if (!manager || !manager.hasRules()) return; - if (pendingViolation) return; // Already matched, waiting for agent_end - const extracted = extractDeltaContext(event.assistantMessageEvent); - if (!extracted) return; - const { delta, context } = extracted; - const matches = manager.checkDelta(delta, context); - if (matches.length === 0) return; - // Match found — set pending violation and abort - pendingViolation = { rules: matches }; - manager.markInjected(matches); - ctx.abort(); - }); - // ── turn_end: increment message count ─────────────────────────────── - pi.on("turn_end", async () => { - if (!manager) return; - manager.incrementMessageCount(); - }); - // ── agent_end: inject violation if pending ────────────────────────── - pi.on("agent_end", async () => { - if (!manager || !pendingViolation) return; - const violation = pendingViolation; - pendingViolation = null; - // Build interrupt content for all matching rules - const interruptParts = violation.rules.map(buildInterruptContent); - const fullInterrupt = interruptParts.join("\n\n"); - // Inject as a message that triggers a new turn - pi.sendMessage( - { - customType: "ttsr-violation", - content: fullInterrupt, - display: false, - }, - { triggerTurn: true }, - ); - }); -}