opencode-self-improvement 0.3.0 → 0.3.2
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/undo.ts +9 -2
- package/src/viewer/server.ts +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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/undo.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// must survive" — that call belongs to the caller, so the default mode CHECK
|
|
10
10
|
// refuses and lists the divergent rows; MERGE replays them on top of the
|
|
11
11
|
// snapshot; FORCE is the old exact rollback.
|
|
12
|
-
import { join,
|
|
12
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
13
13
|
import { snapshotDir } from "./config.ts";
|
|
14
14
|
import { ensureDir } from "./logfmt.ts";
|
|
15
15
|
|
|
@@ -78,7 +78,14 @@ export async function restoreSnapshot(
|
|
|
78
78
|
if (!(await f.exists())) throw new Error(`snapshot not found: ${snapshot}`);
|
|
79
79
|
|
|
80
80
|
const original = name.replace(/^\d+-/, "");
|
|
81
|
-
|
|
81
|
+
// Defense in depth (issue #21): `name` is basenamed above, so `original`
|
|
82
|
+
// carries no separators — but a restore must never write outside the memory
|
|
83
|
+
// root no matter what a future caller passes.
|
|
84
|
+
const rootAbs = resolve(memoryRootDir);
|
|
85
|
+
const target = resolve(rootAbs, original);
|
|
86
|
+
if (!target.startsWith(rootAbs + sep)) {
|
|
87
|
+
throw new Error(`snapshot target escapes the memory root: ${original}`);
|
|
88
|
+
}
|
|
82
89
|
const snapLines = rows(await f.text());
|
|
83
90
|
const cur = Bun.file(target);
|
|
84
91
|
const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
|
package/src/viewer/server.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// through the same loadAll() used by recall/inject (so blocked entries are
|
|
5
5
|
// hidden), and all mutations are non-destructive (snapshot + audit + supersede).
|
|
6
6
|
// I2: serves only file data; never writes into any repo.
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
8
|
import { serve, type Server } from "bun";
|
|
9
9
|
import { deletionsFile, memoryRoot, snapshotDir } from "../config.ts";
|
|
10
10
|
import { blockedEntries } from "../blocklist.ts";
|
|
@@ -213,7 +213,14 @@ async function restoreEntryRoute(body: Record<string, unknown>): Promise<Respons
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
async function restoreRoute(body: Record<string, unknown>): Promise<Response> {
|
|
216
|
-
|
|
216
|
+
// Path containment (issue #21): name arrives raw from the wire. Basename it,
|
|
217
|
+
// then pin it to the real snapshot-name grammar — `<epoch-ms>-<YYYY-MM-DD>.logfmt`
|
|
218
|
+
// — so neither path join below can be steered outside snapshots/ and the
|
|
219
|
+
// store root with a `..` or absolute name.
|
|
220
|
+
const name = basename(String(body.name ?? ""));
|
|
221
|
+
if (!/^\d+-\d{4}-\d{2}-\d{2}\.logfmt$/.test(name)) {
|
|
222
|
+
return error("invalid snapshot name", 400);
|
|
223
|
+
}
|
|
217
224
|
const original = name.replace(/^\d+-/, "");
|
|
218
225
|
// Explicit human admin action: exact rollback — but the rollback is itself
|
|
219
226
|
// rollback-able. Snapshot the current file first so concurrent-session rows
|