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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.3.1",
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+$/, "");
@@ -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
- const url = new URL(req.url);
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") {