refactor(extensions): merge ttsr into guardrails
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>
This commit is contained in:
parent
2d5a05a48b
commit
f0c3eaf999
9 changed files with 94 additions and 159 deletions
|
|
@ -13,7 +13,7 @@
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"inlineSourceMap": false,
|
"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,
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": false,
|
"allowImportingTsExtensions": false,
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
|
|
|
||||||
4
pkg/dist/core/export-html/template.js
vendored
4
pkg/dist/core/export-html/template.js
vendored
|
|
@ -1734,6 +1734,10 @@
|
||||||
codespan(token) {
|
codespan(token) {
|
||||||
return `<code>${escapeHtml(token.text)}</code>`;
|
return `<code>${escapeHtml(token.text)}</code>`;
|
||||||
},
|
},
|
||||||
|
// Raw HTML blocks: escape to prevent XSS
|
||||||
|
html(token) {
|
||||||
|
return escapeHtml(token.text);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
"id": "guardrails",
|
"id": "guardrails",
|
||||||
"name": "Guardrails",
|
"name": "Guardrails",
|
||||||
"version": "1.0.0",
|
"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",
|
"tier": "bundled",
|
||||||
"requires": { "platform": ">=2.29.0" },
|
"requires": { "platform": ">=2.29.0" },
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": ["safegit", "safegit-level", "safegit-status", "yolo"],
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@
|
||||||
* - Redacts secrets from tool results before the LLM sees them
|
* - Redacts secrets from tool results before the LLM sees them
|
||||||
* - Blocks dangerous bash commands (rm -rf, sudo, mkfs, etc.)
|
* - Blocks dangerous bash commands (rm -rf, sudo, mkfs, etc.)
|
||||||
* - Blocks writes to protected paths (.env, .git, .ssh, 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
|
* - 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 * as path from "node:path";
|
||||||
|
import { loadRules } from "./ttsr-rule-loader.js";
|
||||||
|
import { TtsrManager } from "./ttsr-manager.js";
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS = [
|
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
|
// Entry Point
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -628,4 +667,52 @@ export default function guardrails(pi) {
|
||||||
ctx,
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue