opencode-self-improvement 0.8.7 → 0.8.8
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 +27 -0
- package/package.json +1 -1
- package/src/capture.ts +14 -5
- package/src/gates.ts +29 -0
- package/src/index.ts +3 -1
- package/src/memory.ts +22 -9
- package/src/secrets.ts +39 -0
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.
|
|
3
|
+
"version": "0.8.8",
|
|
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
|
|
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
|
|
@@ -939,7 +940,8 @@ const impl = {
|
|
|
939
940
|
);
|
|
940
941
|
await record(
|
|
941
942
|
`auto_capture_${outcome.action}`,
|
|
942
|
-
|
|
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.
|
|
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
|
|
166
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
+
}
|