opencode-castlegate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ export interface IntentValidatePromptArgs {
2
+ digest: string;
3
+ tool: string;
4
+ argsSummary: string;
5
+ }
6
+
7
+ export function buildIntentValidatePrompt(args: IntentValidatePromptArgs): string {
8
+ const { digest, tool, argsSummary } = args;
9
+
10
+ return `You are the castlegate intent validator. Decide whether a proposed tool call matches the user's stated session intent.
11
+
12
+ # User intent digest
13
+ ${digest || "(no digest yet — answer conservatively, prefer mismatch)"}
14
+
15
+ # Proposed tool call
16
+ - Tool: ${tool}
17
+ - Arguments: ${argsSummary}
18
+
19
+ # Decision rules
20
+ - "match" = the call directly advances, supports, or is a routine side-effect of the intent in the digest.
21
+ - "mismatch" = the call is unrelated, contradicts, or exceeds the scope of the stated intent.
22
+ - When in doubt, prefer mismatch and assign severity "medium".
23
+ - Severity: "low" (mismatch but reversible + low blast radius), "medium" (file writes, network, install), "high" (destructive, irreversible, secret-bearing).
24
+
25
+ # Output format (REQUIRED)
26
+ Respond with a single JSON object, no prose, no fences:
27
+
28
+ {"match": <bool>, "reason": "<short rationale>", "severity": "low" | "medium" | "high"}
29
+
30
+ JSON:`;
31
+ }
32
+
33
+ export interface IntentValidateResult {
34
+ match: boolean;
35
+ reason: string;
36
+ severity: "low" | "medium" | "high";
37
+ }
38
+
39
+ export function parseIntentValidateResponse(raw: string): IntentValidateResult {
40
+ const trimmed = raw.trim();
41
+
42
+ const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
43
+ const candidate = fenceMatch ? fenceMatch[1]!.trim() : trimmed;
44
+
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(candidate);
48
+ } catch {
49
+ const objectMatch = /\{[\s\S]*?\}/.exec(candidate);
50
+ if (!objectMatch) {
51
+ throw new Error("castlegate: validator response is not valid JSON");
52
+ }
53
+ parsed = JSON.parse(objectMatch[0]);
54
+ }
55
+
56
+ if (!parsed || typeof parsed !== "object") {
57
+ throw new Error("castlegate: validator response is not a JSON object");
58
+ }
59
+
60
+ const obj = parsed as Record<string, unknown>;
61
+ const match = obj.match;
62
+ if (typeof match !== "boolean") {
63
+ throw new Error("castlegate: validator response missing boolean 'match'");
64
+ }
65
+
66
+ const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
67
+ let severity: "low" | "medium" | "high" = "medium";
68
+ if (obj.severity === "low" || obj.severity === "medium" || obj.severity === "high") {
69
+ severity = obj.severity;
70
+ }
71
+
72
+ return { match, reason, severity };
73
+ }
74
+
75
+ export function summarizeArgs(tool: string, args: unknown): string {
76
+ if (args === undefined || args === null) return "(no arguments)";
77
+ try {
78
+ let raw = "";
79
+ if (typeof args === "string") raw = args;
80
+ else raw = JSON.stringify(args);
81
+
82
+ if (raw.length <= 1200) return raw;
83
+
84
+ if (tool === "bash" && typeof args === "object" && args !== null) {
85
+ const cmd = (args as Record<string, unknown>).command;
86
+ if (typeof cmd === "string") {
87
+ return JSON.stringify({ command: cmd.length > 1200 ? cmd.slice(0, 1200) + "..." : cmd });
88
+ }
89
+ }
90
+ if (tool === "read" && typeof args === "object" && args !== null) {
91
+ const fp = (args as Record<string, unknown>).filePath;
92
+ if (typeof fp === "string") return JSON.stringify({ filePath: fp });
93
+ }
94
+ if (tool === "edit" && typeof args === "object" && args !== null) {
95
+ const fp = (args as Record<string, unknown>).filePath;
96
+ if (typeof fp === "string") return JSON.stringify({ filePath: fp });
97
+ }
98
+ if (tool === "webfetch" && typeof args === "object" && args !== null) {
99
+ const url = (args as Record<string, unknown>).url;
100
+ if (typeof url === "string") return JSON.stringify({ url });
101
+ }
102
+ return raw.slice(0, 1200) + "...";
103
+ } catch {
104
+ return "(unserializable arguments)";
105
+ }
106
+ }
package/src/store.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+
5
+ export interface DigestHeader {
6
+ digestVersion: number;
7
+ lastMessageId: string | null;
8
+ updatedAt: string;
9
+ charCount: number;
10
+ }
11
+
12
+ export interface DigestFile {
13
+ header: DigestHeader;
14
+ body: string;
15
+ }
16
+
17
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
18
+
19
+ function nowIso(): string {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ export function sanitizeId(id: string): string {
24
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
25
+ }
26
+
27
+ export function digestPath(dir: string, sessionId: string): string {
28
+ return path.join(dir, `${sanitizeId(sessionId)}.md`);
29
+ }
30
+
31
+ export function needsCompact(body: string, maxChars: number, threshold: number): boolean {
32
+ if (maxChars <= 0) return false;
33
+ return body.length >= Math.floor(maxChars * threshold);
34
+ }
35
+
36
+ function parseFrontmatter(raw: string): DigestFile | null {
37
+ const match = FRONTMATTER_RE.exec(raw);
38
+ if (!match) return null;
39
+
40
+ const fmBlock = match[1] ?? "";
41
+ const body = match[2] ?? "";
42
+
43
+ const header: Partial<DigestHeader> = {};
44
+ for (const line of fmBlock.split("\n")) {
45
+ const idx = line.indexOf(":");
46
+ if (idx === -1) continue;
47
+ const key = line.slice(0, idx).trim();
48
+ const value = line.slice(idx + 1).trim();
49
+ if (key === "digestVersion") {
50
+ const n = Number(value);
51
+ if (Number.isFinite(n)) header.digestVersion = n;
52
+ } else if (key === "lastMessageId") {
53
+ header.lastMessageId = value === "" || value === "null" ? null : value;
54
+ } else if (key === "updatedAt") {
55
+ header.updatedAt = value;
56
+ } else if (key === "charCount") {
57
+ const n = Number(value);
58
+ if (Number.isFinite(n)) header.charCount = n;
59
+ }
60
+ }
61
+
62
+ if (
63
+ typeof header.digestVersion !== "number" ||
64
+ typeof header.updatedAt !== "string" ||
65
+ typeof header.charCount !== "number"
66
+ ) {
67
+ return null;
68
+ }
69
+
70
+ return {
71
+ header: header as DigestHeader,
72
+ body,
73
+ };
74
+ }
75
+
76
+ function serialize(header: DigestHeader, body: string): string {
77
+ const fm = [
78
+ "---",
79
+ `digestVersion: ${header.digestVersion}`,
80
+ `lastMessageId: ${header.lastMessageId ?? "null"}`,
81
+ `updatedAt: ${header.updatedAt}`,
82
+ `charCount: ${header.charCount}`,
83
+ "---",
84
+ "",
85
+ ].join("\n");
86
+ return `${fm}${body}`;
87
+ }
88
+
89
+ export async function loadDigest(dir: string, sessionId: string): Promise<DigestFile | null> {
90
+ try {
91
+ const raw = await fs.readFile(digestPath(dir, sessionId), "utf8");
92
+ return parseFrontmatter(raw);
93
+ } catch (err) {
94
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ export interface SaveOptions {
100
+ baseVersion?: number;
101
+ lastMessageId?: string | null;
102
+ }
103
+
104
+ export async function saveDigest(
105
+ dir: string,
106
+ sessionId: string,
107
+ body: string,
108
+ opts: SaveOptions = {},
109
+ ): Promise<DigestHeader> {
110
+ await fs.mkdir(dir, { recursive: true });
111
+
112
+ const previous = await loadDigest(dir, sessionId);
113
+ const header: DigestHeader = {
114
+ digestVersion: (opts.baseVersion ?? previous?.header.digestVersion ?? 0) + 1,
115
+ lastMessageId: opts.lastMessageId ?? previous?.header.lastMessageId ?? null,
116
+ updatedAt: nowIso(),
117
+ charCount: body.length,
118
+ };
119
+
120
+ const file = digestPath(dir, sessionId);
121
+ const tmp = path.join(
122
+ dir,
123
+ `.${sanitizeId(sessionId)}.${process.pid}.${Date.now()}.tmp`,
124
+ );
125
+
126
+ const handle = await fs.open(tmp, "w");
127
+ try {
128
+ await handle.writeFile(serialize(header, body), "utf8");
129
+ await handle.sync();
130
+ } finally {
131
+ await handle.close();
132
+ }
133
+
134
+ await fs.rename(tmp, file);
135
+ return header;
136
+ }
137
+
138
+ export async function deleteDigest(dir: string, sessionId: string): Promise<void> {
139
+ try {
140
+ await fs.unlink(digestPath(dir, sessionId));
141
+ } catch (err) {
142
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
143
+ }
144
+ }
145
+
146
+ export function resolveStorageDir(directory: string, storageDir: string): string {
147
+ if (path.isAbsolute(storageDir)) return storageDir;
148
+ return path.resolve(directory, storageDir);
149
+ }
150
+
151
+ export function tmpPathFor(dir: string, sessionId: string): string {
152
+ return path.join(
153
+ dir,
154
+ `.${sanitizeId(sessionId)}.${process.pid}.${Date.now()}.tmp`,
155
+ );
156
+ }
157
+
158
+ export const _internal = {
159
+ parseFrontmatter,
160
+ serialize,
161
+ os,
162
+ };
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { tool } from "@opencode-ai/plugin";
3
+ import { loadDigest, resolveStorageDir } from "../store.ts";
4
+ import type { CastlegateConfig } from "../config.ts";
5
+
6
+ export function createIntentContextTool(getCfg: () => CastlegateConfig) {
7
+ return tool({
8
+ description:
9
+ "Return the current castlegate intent digest for this session. Use this to inspect what the auto-approve guardrail currently believes the session's intent is.",
10
+ args: {
11
+ format: z.enum(["summary", "raw"]).default("summary"),
12
+ },
13
+ async execute(args, context) {
14
+ const cfg = getCfg();
15
+ const dir = resolveStorageDir(context.directory, cfg.storage.directory);
16
+ const file = await loadDigest(dir, context.sessionID);
17
+
18
+ if (!file) {
19
+ const out = "(no intent digest yet for this session)";
20
+ return { title: "castlegate intent_context", output: out, metadata: { exists: false } };
21
+ }
22
+
23
+ if (args.format === "raw") {
24
+ return {
25
+ title: "castlegate intent_context",
26
+ output: `# castlegate intent digest\n\n${file.body}`,
27
+ metadata: {
28
+ version: file.header.digestVersion,
29
+ lastMessageId: file.header.lastMessageId,
30
+ updatedAt: file.header.updatedAt,
31
+ charCount: file.header.charCount,
32
+ },
33
+ };
34
+ }
35
+
36
+ const out = [
37
+ `# castlegate intent digest`,
38
+ ``,
39
+ `Version: ${file.header.digestVersion}`,
40
+ `Updated: ${file.header.updatedAt}`,
41
+ `Chars: ${file.header.charCount}`,
42
+ `LastMsg: ${file.header.lastMessageId ?? "(none)"}`,
43
+ ``,
44
+ file.body,
45
+ ].join("\n");
46
+
47
+ return {
48
+ title: "castlegate intent_context",
49
+ output: out,
50
+ metadata: {
51
+ version: file.header.digestVersion,
52
+ lastMessageId: file.header.lastMessageId,
53
+ updatedAt: file.header.updatedAt,
54
+ charCount: file.header.charCount,
55
+ },
56
+ };
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,151 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+ import type { CastlegateConfig } from "./config.ts";
4
+ import {
5
+ buildIntentValidatePrompt,
6
+ parseIntentValidateResponse,
7
+ summarizeArgs,
8
+ type IntentValidateResult,
9
+ } from "./prompts/intent-validate.ts";
10
+ import { buildSidecarPromptBody } from "./prompt-body.ts";
11
+
12
+ type PluginClient = PluginInput["client"];
13
+
14
+ export interface ValidateToolCallArgs {
15
+ client: PluginClient;
16
+ cfg: CastlegateConfig;
17
+ digest: string;
18
+ digestVersion: number;
19
+ tool: string;
20
+ callArgs: unknown;
21
+ sidecar: { id: string | null; pending: Promise<string> | null };
22
+ }
23
+
24
+ export interface ValidatorDecision {
25
+ result: IntentValidateResult;
26
+ cached: boolean;
27
+ }
28
+
29
+ interface CacheEntry {
30
+ result: IntentValidateResult;
31
+ expiresAt: number;
32
+ }
33
+
34
+ const cacheState = new Map<string, CacheEntry>();
35
+
36
+ function cacheKey(tool: string, args: unknown, digestVersion: number): string {
37
+ const argsStr = (() => {
38
+ try {
39
+ return JSON.stringify(args);
40
+ } catch {
41
+ return String(args);
42
+ }
43
+ })();
44
+ return createHash("sha256")
45
+ .update(tool)
46
+ .update("\u0000")
47
+ .update(argsStr)
48
+ .update("\u0000")
49
+ .update(String(digestVersion))
50
+ .digest("hex");
51
+ }
52
+
53
+ function cacheGet(key: string, ttlMs: number): IntentValidateResult | null {
54
+ if (ttlMs <= 0) return null;
55
+ const entry = cacheState.get(key);
56
+ if (!entry) return null;
57
+ if (entry.expiresAt < Date.now()) {
58
+ cacheState.delete(key);
59
+ return null;
60
+ }
61
+ return entry.result;
62
+ }
63
+
64
+ function cachePut(key: string, result: IntentValidateResult, ttlMs: number): void {
65
+ if (ttlMs <= 0) return;
66
+ cacheState.set(key, { result, expiresAt: Date.now() + ttlMs });
67
+ }
68
+
69
+ export function clearValidatorCache(): void {
70
+ cacheState.clear();
71
+ }
72
+
73
+ export function shouldValidate(tool: string, cfg: CastlegateConfig): boolean {
74
+ if (cfg.validate.skip.includes(tool)) return false;
75
+ if (cfg.validate.tools && cfg.validate.tools.length > 0) {
76
+ return cfg.validate.tools.includes(tool);
77
+ }
78
+ return true;
79
+ }
80
+
81
+ async function ensureSidecar(client: PluginClient): Promise<string> {
82
+ const created = await client.session.create({
83
+ body: { title: "[castlegate] validator sidecar" },
84
+ });
85
+ if (!created.data) throw new Error("castlegate: failed to create validator sidecar session");
86
+ return created.data.id;
87
+ }
88
+
89
+ async function getSidecarId(
90
+ client: PluginClient,
91
+ sidecar: { id: string | null; pending: Promise<string> | null },
92
+ ): Promise<string> {
93
+ if (sidecar.id) return sidecar.id;
94
+ if (!sidecar.pending) {
95
+ sidecar.pending = ensureSidecar(client).then((id) => {
96
+ sidecar.id = id;
97
+ return id;
98
+ });
99
+ }
100
+ return sidecar.pending;
101
+ }
102
+
103
+ async function callValidator(
104
+ client: PluginClient,
105
+ sidecarId: string,
106
+ cfg: CastlegateConfig,
107
+ prompt: string,
108
+ ): Promise<string> {
109
+ const result = await client.session.prompt({
110
+ path: { id: sidecarId },
111
+ body: buildSidecarPromptBody(cfg, prompt),
112
+ });
113
+
114
+ const parts = result.data?.parts ?? [];
115
+ return parts
116
+ .map((p: { type?: string; text?: string }) => {
117
+ if (p.type === "text" && typeof p.text === "string") return p.text;
118
+ return "";
119
+ })
120
+ .filter(Boolean)
121
+ .join("\n")
122
+ .trim();
123
+ }
124
+
125
+ export async function validateToolCall(args: ValidateToolCallArgs): Promise<ValidatorDecision> {
126
+ const { client, cfg, digest, digestVersion, tool, callArgs, sidecar } = args;
127
+
128
+ const key = cacheKey(tool, callArgs, digestVersion);
129
+ const cached = cacheGet(key, cfg.validate.cacheTtlMs);
130
+ if (cached) return { result: cached, cached: true };
131
+
132
+ const argsSummary = summarizeArgs(tool, callArgs);
133
+ const prompt = buildIntentValidatePrompt({ digest, tool, argsSummary });
134
+ const sidecarId = await getSidecarId(client, sidecar);
135
+ const raw = await callValidator(client, sidecarId, cfg, prompt);
136
+ const result = parseIntentValidateResponse(raw);
137
+
138
+ cachePut(key, result, cfg.validate.cacheTtlMs);
139
+ return { result, cached: false };
140
+ }
141
+
142
+ export function fallbackDecision(tool: string, cfg: CastlegateConfig): IntentValidateResult {
143
+ if (cfg.validate.failOpenOnError) {
144
+ return { match: true, reason: "validator unavailable (fail-open)", severity: "low" };
145
+ }
146
+ return {
147
+ match: false,
148
+ reason: "validator unavailable (fail-closed)",
149
+ severity: tool === "bash" ? "high" : "medium",
150
+ };
151
+ }