opencode-self-improvement 0.8.7 → 0.8.9

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/README.md CHANGED
@@ -50,6 +50,33 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
50
50
  - Automatically looks for insights when you complete the verification gates (tests, linting, etc)
51
51
  - Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
52
52
 
53
+ ## Secret redaction
54
+
55
+ `src/secrets.ts` provides one best-effort credential rule set. It runs over the
56
+ auto-capture prompt before that prompt leaves the plugin, at the write gate
57
+ before content is hashed or analysed, and again in `appendMemory`, the final
58
+ write choke point used by gated writes and the viewer's edit/restore-entry
59
+ paths. Credential-shaped values in memory content become `[redacted]`.
60
+ Credential-shaped `scope`, `tags`, or `issue` metadata is rejected instead of
61
+ being silently rewritten.
62
+
63
+ The rules cover OpenAI `sk-` tokens; GitHub `ghp_`, `gho_`, and `github_pat_`
64
+ tokens; Slack `xoxb-` and `xoxp-` tokens; AWS `AKIA` access-key IDs; Google
65
+ `AIza` API keys; bearer tokens containing digits; PEM private-key blocks; and
66
+ high-entropy values assigned to names ending in `_KEY`, `_TOKEN`, `_SECRET`,
67
+ or `_PASSWORD` (plus `password`). This is deliberately best-effort, not a
68
+ guarantee: it can miss hex-only secrets, passwords with few character classes,
69
+ and credentials written in prose without an assignment.
70
+
71
+ `memory_remember` still replies `Remembered` when content was redacted, so the
72
+ stored text can differ from what was sent. Redaction is forward-only: existing
73
+ stores are not scrubbed, and `memory_export` writes whatever is already stored
74
+ verbatim. See [issue #74](https://github.com/abogartz/opencode-self-improvement/issues/74)
75
+ for the separate existing-store work.
76
+
77
+ Auto-capture is off by default, so capture-prompt redaction protects opt-in
78
+ installs. The write-gate and `appendMemory` checks apply to every install.
79
+
53
80
  ## Token cost, on purpose
54
81
 
55
82
  Memory plugins have a reputation for burning tokens and busting the provider's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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/capture.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // never a throw.
9
9
  import { STATUSES, TYPES } from "./types.ts";
10
10
  import { flattenMemoryText } from "./sanitize.ts";
11
+ import { redactSecrets } from "./secrets.ts";
11
12
 
12
13
  export interface CaptureOptions {
13
14
  model: string; // "provider/id"
@@ -46,7 +47,7 @@ export function buildCapturePrompt(transcript: string, max = 3): string {
46
47
  "- If nothing is worth remembering, output [ ].",
47
48
  "",
48
49
  "Transcript:",
49
- transcript,
50
+ redactSecrets(transcript),
50
51
  ].join("\n");
51
52
  }
52
53
 
@@ -152,15 +153,23 @@ export function flattenTranscript(msgs: unknown): string {
152
153
  const role =
153
154
  typeof info?.role === "string" ? info.role : typeof m.role === "string" ? m.role : "";
154
155
  let text = "";
156
+ let hadPrivatePart = false;
155
157
  if (Array.isArray(m.parts)) {
156
158
  text = m.parts
157
- .map((p) =>
158
- p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined,
159
- )
159
+ .map((p) => {
160
+ if (!p || typeof p !== "object") return undefined;
161
+ const part = p as Record<string, unknown>;
162
+ const partType = typeof part.type === "string" ? part.type.toLowerCase() : "";
163
+ if (partType === "reasoning" || partType === "thinking") {
164
+ hadPrivatePart = true;
165
+ return undefined;
166
+ }
167
+ return part.text;
168
+ })
160
169
  .filter((t): t is string => typeof t === "string" && t.length > 0)
161
170
  .join(" ");
162
171
  }
163
- if (!text) {
172
+ if (!text && !hadPrivatePart) {
164
173
  for (const k of ["text", "content"]) {
165
174
  if (typeof m[k] === "string") {
166
175
  text = m[k] as string;
package/src/gates.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./memory.ts";
16
16
  import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
17
17
  import { flattenMemoryText } from "./sanitize.ts";
18
+ import { containsSecret, redactSecrets } from "./secrets.ts";
18
19
  import {
19
20
  displayCall,
20
21
  extractCallForms,
@@ -251,6 +252,34 @@ export async function runWriteGate(
251
252
  };
252
253
  }
253
254
 
255
+ const credentialMetadata = [
256
+ containsSecret(candidate.scope) ? "scope" : undefined,
257
+ candidate.tags?.some(containsSecret) ? "tags" : undefined,
258
+ containsSecret(candidate.issue) ? "issue" : undefined,
259
+ ].filter((field): field is string => field !== undefined);
260
+ if (credentialMetadata.length) {
261
+ await recordWriteBlocked(
262
+ 4,
263
+ "credential_in_metadata",
264
+ credentialMetadata.map((field) => `field=${field}`),
265
+ chain,
266
+ );
267
+ return {
268
+ action: "blocked",
269
+ reason: "credential_in_metadata",
270
+ chain,
271
+ hint: `Credential-shaped value in ${credentialMetadata.join(", ")}; remove it and retry. Only memory content is redacted automatically.`,
272
+ };
273
+ }
274
+
275
+ // This is the common boundary for manual, imported and auto-captured writes.
276
+ // Scrub content before hashing, analysis and persistence. Metadata is
277
+ // rejected above rather than silently changing where a row can be found.
278
+ candidate = {
279
+ ...candidate,
280
+ content: redactSecrets(candidate.content),
281
+ };
282
+
254
283
  const ch = contentHash(candidate.content);
255
284
  await record("gate_start", {
256
285
  chain,
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ import { getCurrent, setCurrent } from "./state.ts";
66
66
  import { resolveRepo, setRepoScope, rememberSessionDir, sessionDir } from "./repokey.ts";
67
67
  import { readLines } from "./logfmt.ts";
68
68
  import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
69
+ import { redactSecrets } from "./secrets.ts";
69
70
  import { flattenMemoryText } from "./sanitize.ts";
70
71
 
71
72
  // Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
@@ -852,7 +853,7 @@ const impl = {
852
853
  continue;
853
854
  }
854
855
  if (ev?.type !== "message.updated") continue;
855
- const info = ev.properties?.info ?? ev.info;
856
+ const info = ev.properties?.info ?? ev.info ?? ev.data?.info;
856
857
  if (!info || info.role !== "assistant") continue;
857
858
  const sid = info.sessionID;
858
859
  const t = info.tokens;
@@ -915,7 +916,7 @@ const impl = {
915
916
  for await (const raw of eventApi.subscribe({ signal: controller.signal })) {
916
917
  const event = raw as Record<string, any>;
917
918
  if (event?.type !== "session.idle") continue;
918
- const sid = event.sessionID ?? event.properties?.sessionID;
919
+ const sid = event.sessionID ?? event.properties?.sessionID ?? event.data?.sessionID;
919
920
  if (typeof sid !== "string") continue;
920
921
  // Bind the idle session so captures stamp (and gate against)
921
922
  // that session's repo, not whatever tool call ran last.
@@ -939,7 +940,8 @@ const impl = {
939
940
  );
940
941
  await record(
941
942
  `auto_capture_${outcome.action}`,
942
- { session: sid, type: c.type, scope: c.scope, reason: outcome.reason ?? "" },
943
+ // A blocked credential-shaped scope must not reach the evidence log either.
944
+ { session: sid, type: c.type, scope: redactSecrets(c.scope), reason: outcome.reason ?? "" },
943
945
  false,
944
946
  );
945
947
  }
package/src/memory.ts CHANGED
@@ -11,6 +11,7 @@ import { getCurrent } from "./state.ts";
11
11
  import { appendLog, ensureDir, formatLine, parseLine, readLines, writeAtomic, type Fields } from "./logfmt.ts";
12
12
  import { withStoreLock, LOCK_WRITE } from "./lock.ts";
13
13
  import { blockedTs } from "./blocklist.ts";
14
+ import { containsSecret, redactSecrets } from "./secrets.ts";
14
15
  import { flattenMemoryText } from "./sanitize.ts";
15
16
 
16
17
  export type MemoryStatus = "open" | "settled";
@@ -159,11 +160,12 @@ async function existingTs(file: string): Promise<Set<string>> {
159
160
  return set;
160
161
  }
161
162
 
162
- // Append a durable memory. Only the gate's GATE-4 promotion step calls this.
163
+ // Append a durable memory. GATE-4 promotions and the viewer's edit/restore-entry
164
+ // paths all call this single write choke point.
163
165
  export async function appendMemory(input: AppendMemoryInput): Promise<string> {
164
166
  // Absolute floor: every write SHOULD arrive through the gate,
165
- // which rejects oversized content politely; the viewer's restore/edit paths
166
- // do not, so nothing oversized leaves this function either.
167
+ // which rejects invalid input politely; viewer writes do not, so enforce the
168
+ // same storage rules here before anything reaches disk.
167
169
  if (typeof input.content !== "string") throw new Error("memory content must be a string");
168
170
  if (input.content.length > MAX_CONTENT_CHARS) {
169
171
  throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
@@ -180,11 +182,22 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
180
182
  if (input.tags?.some((tag) => flattenMemoryText(tag) !== tag)) {
181
183
  throw new Error("memory tags must not contain control characters");
182
184
  }
183
- // Repo stamp at the single write choke point: gated writes, viewer restores
184
- // and edits all pass through here. Explicit "global" survives; an unscoped
185
- // process (viewer) stamps nothing = global. Session identity wins over the
186
- // module scope so a server hosting several workspaces stamps the writing
187
- // session's repo.
185
+ const credentialMetadata = [
186
+ containsSecret(input.scope) ? "scope" : undefined,
187
+ input.tags?.some(containsSecret) ? "tags" : undefined,
188
+ containsSecret(input.issue) ? "issue" : undefined,
189
+ ].filter((field): field is string => field !== undefined);
190
+ if (credentialMetadata.length) {
191
+ throw new Error(
192
+ `memory metadata contains credential-shaped value in: ${credentialMetadata.join(", ")}`,
193
+ );
194
+ }
195
+ const content = redactSecrets(input.content);
196
+ // Repo stamp at the single write choke point: gated writes plus viewer edits
197
+ // and restore-entry writes all pass through here. Explicit "global" survives;
198
+ // an unscoped process (viewer) stamps nothing = global. Session identity wins
199
+ // over the module scope so a server hosting several workspaces stamps the
200
+ // writing session's repo.
188
201
  let repo = input.repo;
189
202
  let repoPath = input.repoPath;
190
203
  if (repo === undefined) {
@@ -214,7 +227,7 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
214
227
  ts,
215
228
  type: input.type,
216
229
  scope: input.scope,
217
- content: input.content,
230
+ content,
218
231
  issue: input.issue,
219
232
  tags: input.tags?.length ? input.tags.join(",") : undefined,
220
233
  status: input.status,
package/src/secrets.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Small, conservative credential scrubber shared by capture egress and the
2
+ // write gate. Prefix patterns are intentionally limited to well-known token
3
+ // families; generic values are redacted only when assigned to a credential-
4
+ // named field and they look high-entropy, so ordinary identifiers survive.
5
+ export const REDACTED_SECRET = "[redacted]";
6
+
7
+ const PRIVATE_KEY_BLOCK =
8
+ /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/g;
9
+ const PREFIXED_SECRET =
10
+ /\b(?:sk-[A-Za-z0-9_-]{16,}|gh[po]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[bp]-[A-Za-z0-9-]{16,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,})\b/g;
11
+ // A real bearer token has digits; prose like "Bearer authentication-scheme" doesn't.
12
+ const BEARER_SECRET = /\bBearer\s+(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{16,}/gi;
13
+ const ASSIGNED_SECRET =
14
+ /(\b(?:[A-Za-z][A-Za-z0-9_]*(?:_KEY|_TOKEN|_SECRET|_PASSWORD)|password)\b["']?\s*(?::|=)\s*["']?)([A-Za-z0-9+/_=.-]{16,})/gi;
15
+ // `API_KEY=process.env.OPENAI_API_KEY` names where the key lives; keep it.
16
+ const DOTTED_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/;
17
+
18
+ function looksHighEntropy(value: string): boolean {
19
+ if (value.length < 16 || new Set(value).size < 10) return false;
20
+ if (!/[0-9]/.test(value) || DOTTED_IDENTIFIER.test(value)) return false;
21
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) =>
22
+ re.test(value),
23
+ ).length;
24
+ return classes >= 3;
25
+ }
26
+
27
+ export function redactSecrets(text: string): string {
28
+ if (!text) return text;
29
+ let out = text.replace(PRIVATE_KEY_BLOCK, REDACTED_SECRET);
30
+ out = out.replace(PREFIXED_SECRET, REDACTED_SECRET);
31
+ out = out.replace(BEARER_SECRET, REDACTED_SECRET);
32
+ return out.replace(ASSIGNED_SECRET, (match, prefix: string, value: string) =>
33
+ looksHighEntropy(value) ? `${prefix}${REDACTED_SECRET}` : match,
34
+ );
35
+ }
36
+
37
+ export function containsSecret(text: string | undefined): boolean {
38
+ return typeof text === "string" && redactSecrets(text) !== text;
39
+ }