opencode-self-improvement 0.3.0 → 0.3.1

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.0",
3
+ "version": "0.3.1",
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/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, basename } from "node:path";
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
- const target = join(memoryRootDir, original);
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()) : [];
@@ -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
- const name = String(body.name ?? "");
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