opencode-self-improvement 0.3.1 → 0.3.3
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.
- package/package.json +1 -1
- package/src/gates.ts +19 -0
- package/src/memory.ts +15 -0
- package/src/resolver.ts +10 -0
- package/src/viewer/server.ts +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
package/src/gates.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
loadScope,
|
|
11
11
|
markSuperseded,
|
|
12
12
|
snapshotFiles,
|
|
13
|
+
MAX_CONTENT_CHARS,
|
|
13
14
|
type Memory,
|
|
14
15
|
} from "./memory.ts";
|
|
15
16
|
import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
|
|
@@ -196,6 +197,24 @@ export async function runWriteGate(
|
|
|
196
197
|
ctx: GateContext,
|
|
197
198
|
): Promise<GateOutcome> {
|
|
198
199
|
const chain = chainId();
|
|
200
|
+
|
|
201
|
+
// Size/type floor BEFORE any scanning (issue #20): gate analysis regexes
|
|
202
|
+
// over megabyte content backtracked super-linearly and froze the service
|
|
203
|
+
// event loop. Measured pre-fix: 100KB write = 6s, 2MB = never returned.
|
|
204
|
+
if (typeof candidate.content !== "string") {
|
|
205
|
+
await recordWriteBlocked(4, "invalid_content", [`content_type=${typeof candidate.content}`], chain);
|
|
206
|
+
return { action: "blocked", reason: "invalid_content", chain, hint: "content must be a string." };
|
|
207
|
+
}
|
|
208
|
+
if (candidate.content.length > MAX_CONTENT_CHARS) {
|
|
209
|
+
await recordWriteBlocked(4, "content_too_long", [`len=${candidate.content.length}`, `max=${MAX_CONTENT_CHARS}`], chain);
|
|
210
|
+
return {
|
|
211
|
+
action: "blocked",
|
|
212
|
+
reason: "content_too_long",
|
|
213
|
+
chain,
|
|
214
|
+
hint: `content is ${candidate.content.length} chars (max ${MAX_CONTENT_CHARS}). Distill the memory to its durable core and retry.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
199
218
|
const ch = contentHash(candidate.content);
|
|
200
219
|
await record("gate_start", {
|
|
201
220
|
chain,
|
package/src/memory.ts
CHANGED
|
@@ -10,6 +10,14 @@ import { blockedTs } from "./blocklist.ts";
|
|
|
10
10
|
|
|
11
11
|
export type MemoryStatus = "open" | "settled";
|
|
12
12
|
|
|
13
|
+
// Hard ceiling on stored content (issue #20). No entry point may persist a
|
|
14
|
+
// line longer than this: injection ever shows ~1200 chars, the gate runs on
|
|
15
|
+
// the shared service event loop, and regex scanning over megabyte-scale
|
|
16
|
+
// strings froze every session (measured: 100KB write = 6s, 2MB = hang).
|
|
17
|
+
// Enforced twice — politely in runWriteGate (blocked outcome + evidence),
|
|
18
|
+
// absolutely in appendMemory (throws, covering gate-bypassing callers).
|
|
19
|
+
export const MAX_CONTENT_CHARS = 4000;
|
|
20
|
+
|
|
13
21
|
export interface Memory {
|
|
14
22
|
ts: string;
|
|
15
23
|
type: string;
|
|
@@ -109,6 +117,13 @@ async function existingTs(): Promise<Set<string>> {
|
|
|
109
117
|
|
|
110
118
|
// Append a durable memory. Only the gate's GATE-4 promotion step calls this.
|
|
111
119
|
export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
120
|
+
// Absolute floor (issue #20): every write SHOULD arrive through the gate,
|
|
121
|
+
// which rejects oversized content politely; the viewer's restore/edit paths
|
|
122
|
+
// do not, so nothing oversized leaves this function either.
|
|
123
|
+
if (typeof input.content !== "string") throw new Error("memory content must be a string");
|
|
124
|
+
if (input.content.length > MAX_CONTENT_CHARS) {
|
|
125
|
+
throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
|
|
126
|
+
}
|
|
112
127
|
const taken = await existingTs();
|
|
113
128
|
let ts = new Date().toISOString();
|
|
114
129
|
let guard = 0;
|
package/src/resolver.ts
CHANGED
|
@@ -82,7 +82,15 @@ function looksProseArgs(inner: string): boolean {
|
|
|
82
82
|
return s.split(/\s+/).length >= 2;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Regex scanning ceiling (issue #20): PATH_RE backtracks super-linearly on
|
|
86
|
+
// long word-character runs (200KB of base64 as memory content hung the gate
|
|
87
|
+
// on the service event loop; ~6s already at 100KB). The write gate rejects
|
|
88
|
+
// content far above this ceiling upstream; the bails protect every scanner
|
|
89
|
+
// from legacy oversized rows and any direct caller.
|
|
90
|
+
export const REGEX_SCAN_MAX_CHARS = 10_000;
|
|
91
|
+
|
|
85
92
|
export function extractCallForms(content: string): CallForm[] {
|
|
93
|
+
if (content.length > REGEX_SCAN_MAX_CHARS) return [];
|
|
86
94
|
const forms: CallForm[] = [];
|
|
87
95
|
CALL_RE.lastIndex = 0;
|
|
88
96
|
for (;;) {
|
|
@@ -111,6 +119,7 @@ export function extractCallForms(content: string): CallForm[] {
|
|
|
111
119
|
const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?::\d+)?/g;
|
|
112
120
|
|
|
113
121
|
export function extractPathRefs(content: string): string[] {
|
|
122
|
+
if (content.length > REGEX_SCAN_MAX_CHARS) return [];
|
|
114
123
|
return [...content.matchAll(PATH_RE)].map((m) => m[0]);
|
|
115
124
|
}
|
|
116
125
|
|
|
@@ -121,6 +130,7 @@ export function extractPathRefs(content: string): string[] {
|
|
|
121
130
|
// another machine, e.g. in `$BUILD_ROOT/src/a.ts` the regex match is
|
|
122
131
|
// `BUILD_ROOT/src/a.ts`, which the preceding-char check drops.
|
|
123
132
|
export function checkablePathRefs(content: string): string[] {
|
|
133
|
+
if (content.length > REGEX_SCAN_MAX_CHARS) return [];
|
|
124
134
|
const out: string[] = [];
|
|
125
135
|
for (const m of content.matchAll(PATH_RE)) {
|
|
126
136
|
const ref = m[0].replace(/:\d+$/, "");
|
package/src/viewer/server.ts
CHANGED
|
@@ -17,10 +17,20 @@ const PAGE = join(import.meta.dir, "page.html");
|
|
|
17
17
|
const HOST = process.env.OPENCODE_VIEWER_HOST || "127.0.0.1";
|
|
18
18
|
const PORT = Number(process.env.OPENCODE_VIEWER_PORT || 8787);
|
|
19
19
|
|
|
20
|
+
// Defense-in-depth headers on every response. CSP: the page uses only inline
|
|
21
|
+
// script/style and same-origin fetch, so everything else is off — a stored-XSS
|
|
22
|
+
// payload (see issue #11) gains no exfil channel and no framing.
|
|
23
|
+
const SECURITY_HEADERS: Record<string, string> = {
|
|
24
|
+
"x-content-type-options": "nosniff",
|
|
25
|
+
"content-security-policy":
|
|
26
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
|
27
|
+
"connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
28
|
+
};
|
|
29
|
+
|
|
20
30
|
function json(body: unknown, status = 200): Response {
|
|
21
31
|
return new Response(JSON.stringify(body), {
|
|
22
32
|
status,
|
|
23
|
-
headers: { "content-type": "application/json" },
|
|
33
|
+
headers: { ...SECURITY_HEADERS, "content-type": "application/json" },
|
|
24
34
|
});
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -116,13 +126,21 @@ export async function startViewer(port: number = PORT): Promise<ViewerServer> {
|
|
|
116
126
|
hostname: HOST,
|
|
117
127
|
port,
|
|
118
128
|
fetch(req, srv) {
|
|
119
|
-
// Bun builds req.url from the request's Host header.
|
|
120
|
-
|
|
129
|
+
// Bun builds req.url from the request's Host header. A malformed Host
|
|
130
|
+
// makes the URL parse THROW — before rejectRequest could answer. Letting
|
|
131
|
+
// that escape hands the caller Bun's dev error page (stack + absolute
|
|
132
|
+
// paths), so the parse itself is the first guarded step (issue #12).
|
|
133
|
+
let url: URL;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(req.url);
|
|
136
|
+
} catch {
|
|
137
|
+
return error("malformed request URL (bad Host header?)", 400);
|
|
138
|
+
}
|
|
121
139
|
const rejected = rejectRequest(req, url, server.port ?? port);
|
|
122
140
|
if (rejected) return rejected;
|
|
123
141
|
if (url.pathname === "/") {
|
|
124
142
|
return new Response(page, {
|
|
125
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
143
|
+
headers: { ...SECURITY_HEADERS, "content-type": "text/html; charset=utf-8" },
|
|
126
144
|
});
|
|
127
145
|
}
|
|
128
146
|
if (req.method === "GET" && url.pathname === "/api/data") {
|