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,
|
||||
"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,
|
||||
|
|
|
|||
4
pkg/dist/core/export-html/template.js
vendored
4
pkg/dist/core/export-html/template.js
vendored
|
|
@ -1734,6 +1734,10 @@
|
|||
codespan(token) {
|
||||
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",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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