opencode-secrets-obfuscation 0.1.0 → 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -31
  3. package/package.json +29 -23
  4. package/plugin.mjs +3841 -0
  5. package/.editorconfig +0 -12
  6. package/.github/workflows/ci.yml +0 -75
  7. package/.github/workflows/release.yml +0 -50
  8. package/.node-version +0 -1
  9. package/AGENTS.md +0 -23
  10. package/INSTALL.md +0 -104
  11. package/docs/checkpoint.md +0 -68
  12. package/docs/history/1-spec-review.md +0 -233
  13. package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
  14. package/docs/history/11-p0-job-containment-review.md +0 -188
  15. package/docs/history/12-p0-api-client.md +0 -15
  16. package/docs/history/13-p0-status-review.md +0 -122
  17. package/docs/history/14-p0-containment-final-review.md +0 -84
  18. package/docs/history/15-config-source-implementation.md +0 -27
  19. package/docs/history/16-sanitizer-implementation.md +0 -65
  20. package/docs/history/17-store-policy-implementation.md +0 -36
  21. package/docs/history/18-pure-mvp-review.md +0 -182
  22. package/docs/history/19-pure-mvp-re-review.md +0 -142
  23. package/docs/history/2-p0-runtime-prerequisites.md +0 -163
  24. package/docs/history/20-pure-mvp-final-review.md +0 -116
  25. package/docs/history/21-pure-mvp-review-final.md +0 -109
  26. package/docs/history/22-pure-mvp-signoff-review.md +0 -113
  27. package/docs/history/23-pure-mvp-signoff-review.md +0 -75
  28. package/docs/history/24-owned-tools-implementation.md +0 -27
  29. package/docs/history/25-v2-plugin-integration.md +0 -53
  30. package/docs/history/26-owned-tools-review.md +0 -138
  31. package/docs/history/27-owned-tools-final-review.md +0 -93
  32. package/docs/history/28-owned-tools-signoff.md +0 -98
  33. package/docs/history/3-p0-harness-foundation.md +0 -105
  34. package/docs/history/4-p0-source-opening.md +0 -78
  35. package/docs/history/5-qualification-gate-review.md +0 -133
  36. package/docs/history/6-p0-infrastructure-review.md +0 -275
  37. package/docs/history/7-p0-job-containment.md +0 -29
  38. package/docs/history/8-p0-evidence-support.md +0 -52
  39. package/docs/history/9-p0-launch-plan.md +0 -88
  40. package/docs/spec/design.md +0 -111
  41. package/docs/spec/intent.md +0 -81
  42. package/docs/spec/plan.md +0 -78
  43. package/docs/testing.md +0 -157
  44. package/scripts/build-plugin.mjs +0 -84
  45. package/scripts/install-opencode-ci.ps1 +0 -39
  46. package/scripts/install-opencode-ci.sh +0 -27
  47. package/scripts/smoke.mjs +0 -105
  48. package/scripts/verify-package.mjs +0 -18
  49. package/src/README.md +0 -22
  50. package/src/budgets/index.ts +0 -49
  51. package/src/config/README.md +0 -4
  52. package/src/config/options.ts +0 -76
  53. package/src/diagnostics/README.md +0 -4
  54. package/src/opencode/v2/filter.ts +0 -350
  55. package/src/opencode/v2/index.ts +0 -86
  56. package/src/opencode/v2/runtime.ts +0 -207
  57. package/src/opencode/v2/transports/README.md +0 -5
  58. package/src/runtime/http-transport.ts +0 -20
  59. package/src/sanitize/README.md +0 -4
  60. package/src/sanitize/index.ts +0 -454
  61. package/src/sources/README.md +0 -5
  62. package/src/sources/dotenv.ts +0 -133
  63. package/src/sources/native/README.md +0 -5
  64. package/src/store/index.ts +0 -64
  65. package/tests/README.md +0 -26
  66. package/tests/contract/plugin-v2.test.ts +0 -29
  67. package/tests/fixtures/README.md +0 -9
  68. package/tests/fixtures/job-child.mjs +0 -18
  69. package/tests/fixtures/plugins/README.md +0 -8
  70. package/tests/fixtures/process-child.mjs +0 -20
  71. package/tests/fixtures/services/README.md +0 -10
  72. package/tests/integration/components.test.ts +0 -5
  73. package/tests/integration/transparent-runtime.test.ts +0 -97
  74. package/tests/integration/windows-job.test.ts +0 -71
  75. package/tests/p0-host/README.md +0 -24
  76. package/tests/p0-host/linux-job.sh +0 -61
  77. package/tests/p0-host/windows-job.ps1 +0 -51
  78. package/tests/p0-native/README.md +0 -33
  79. package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
  80. package/tests/scaffold/repository.test.ts +0 -105
  81. package/tests/smoke/README.md +0 -37
  82. package/tests/smoke/cases.json +0 -17
  83. package/tests/smoke/qualification.json +0 -20
  84. package/tests/smoke/runtime-candidate.json +0 -111
  85. package/tests/support/README.md +0 -32
  86. package/tests/support/child-process.ts +0 -235
  87. package/tests/support/host-api.ts +0 -123
  88. package/tests/support/opencode-environment.ts +0 -239
  89. package/tests/support/qualification.ts +0 -154
  90. package/tests/support/recording-endpoint.ts +0 -242
  91. package/tests/support/run-directory.ts +0 -179
  92. package/tests/support/smoke-cases.ts +0 -289
  93. package/tests/unit/budgets.test.ts +0 -35
  94. package/tests/unit/config.test.ts +0 -40
  95. package/tests/unit/dotenv.test.ts +0 -72
  96. package/tests/unit/filter-size.test.ts +0 -132
  97. package/tests/unit/harness.test.ts +0 -228
  98. package/tests/unit/host-api.test.ts +0 -122
  99. package/tests/unit/opencode-environment.test.ts +0 -161
  100. package/tests/unit/qualification.test.ts +0 -128
  101. package/tests/unit/recording-endpoint.test.ts +0 -188
  102. package/tests/unit/sanitize.test.ts +0 -159
  103. package/tests/unit/smoke-cases.test.ts +0 -20
  104. package/tsconfig.json +0 -20
@@ -1,454 +0,0 @@
1
- /** Pure, bounded sanitization primitives. Host adapters must validate their own final bytes. */
2
-
3
- export interface Pattern {
4
- readonly text: string
5
- readonly replacement?: string
6
- readonly entry?: string
7
- /** Additional lexical/transport spellings belonging to this same entry. */
8
- readonly representations?: readonly string[]
9
- }
10
-
11
- export interface Interval {
12
- start: number
13
- end: number
14
- patterns: string[]
15
- }
16
-
17
- export interface Budget {
18
- readonly maxOutputBytes?: number
19
- readonly maxDepth?: number
20
- readonly maxNodes?: number
21
- readonly maxMatches?: number
22
- }
23
-
24
- const DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024
25
- const DEFAULT_MAX_DEPTH = 32
26
- const DEFAULT_MAX_NODES = 50_000
27
- const DEFAULT_MAX_MATCHES = 50_000
28
- // These are hard operation ceilings, not merely defaults. Explicit callers may
29
- // request less, but may not opt out of bounded work by supplying a larger value.
30
- const MAX_OUTPUT_BYTES = DEFAULT_MAX_OUTPUT_BYTES
31
- const MAX_DEPTH = DEFAULT_MAX_DEPTH
32
- const MAX_NODES = DEFAULT_MAX_NODES
33
- const MAX_MATCHES = DEFAULT_MAX_MATCHES
34
- const MAX_OBJECT_KEYS = MAX_NODES
35
- const MAX_PATTERN_COUNT = 4_096
36
- const MAX_PATTERN_TEXT = 65_536
37
- const MAX_REPRESENTATIONS = 16
38
- const MAX_INDEX_ENTRIES = 65_536
39
- const MAX_IDENTITY_ENTRIES = 50_000
40
- const MAX_PATTERN_BYTES = 4 * 1024 * 1024
41
- const MAX_TEMP_BYTES = 8 * 1024 * 1024
42
-
43
- type Reservation = { release(): void }
44
- let reservedPatternBytes = 0
45
- let reservedIndexEntries = 0
46
- let reservedIdentityEntries = 0
47
- let reservedTempBytes = 0
48
-
49
- function reserveOperation(patterns: readonly (Pattern | string)[] | undefined, inputBytes: number): Reservation | null {
50
- try {
51
- if (patterns !== undefined && !Array.isArray(patterns)) return null
52
- const count = patterns?.length ?? 0
53
- if (count > MAX_PATTERN_COUNT) return null
54
- let patternBytes = 0
55
- for (const item of patterns ?? []) {
56
- const text = typeof item === "string" ? item : (() => {
57
- if (item === null || typeof item !== "object") return null
58
- const descriptor = Object.getOwnPropertyDescriptor(item, "text")
59
- return descriptor && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : null
60
- })()
61
- if (text === null) return null
62
- patternBytes += encoder.encode(text).byteLength
63
- if (patternBytes > MAX_PATTERN_BYTES) return null
64
- }
65
- const indexEntries = count * MAX_REPRESENTATIONS
66
- const identityEntries = count
67
- const tempBytes = inputBytes + patternBytes
68
- if (!Number.isSafeInteger(inputBytes) || inputBytes < 0 || indexEntries > MAX_INDEX_ENTRIES || identityEntries > MAX_IDENTITY_ENTRIES || tempBytes > MAX_TEMP_BYTES) return null
69
- if (reservedPatternBytes > MAX_PATTERN_BYTES - patternBytes || reservedIndexEntries > MAX_INDEX_ENTRIES - indexEntries || reservedIdentityEntries > MAX_IDENTITY_ENTRIES - identityEntries || reservedTempBytes > MAX_TEMP_BYTES - tempBytes) return null
70
- reservedPatternBytes += patternBytes; reservedIndexEntries += indexEntries; reservedIdentityEntries += identityEntries; reservedTempBytes += tempBytes
71
- let released = false
72
- return { release: () => { if (released) return; released = true; reservedPatternBytes -= patternBytes; reservedIndexEntries -= indexEntries; reservedIdentityEntries -= identityEntries; reservedTempBytes -= tempBytes } }
73
- } catch { return null }
74
- }
75
-
76
- export const RESERVED_OUTPUT_INVENTORY = Object.freeze([
77
- "true", "false", "null", "REDACTED", "SANITIZATION_UNAVAILABLE",
78
- "INVALID_CREDENTIAL", "ACCESS_DENIED", "UNAVAILABLE", "error", "content", "text",
79
- ])
80
-
81
- export interface Representations {
82
- readonly semantic: string
83
- readonly dotenv: readonly string[]
84
- readonly json: string
85
- readonly percent: readonly string[]
86
- readonly base64: readonly string[]
87
- }
88
-
89
- function base64(bytes: Uint8Array): string {
90
- let binary = ""
91
- for (const byte of bytes) binary += String.fromCharCode(byte)
92
- return Buffer.from(binary, "binary").toString("base64")
93
- }
94
-
95
- /** The finite, versioned representation inventory used by the pure MVP matcher. */
96
- export function representationsFor(value: string): Representations | null {
97
- const reservation = typeof value === "string" ? reserveOperation(undefined, encoder.encode(value).byteLength) : null
98
- try {
99
- if (typeof value !== "string" || value.length > MAX_PATTERN_TEXT) return null
100
- const bytes = encoder.encode(value)
101
- if (bytes.byteLength > MAX_PATTERN_TEXT * 3) return null
102
- const encoded = Array.from(bytes, (byte) => {
103
- const character = String.fromCharCode(byte)
104
- return /[A-Za-z0-9\-_.!~*'()]/u.test(character) ? character : `%${byte.toString(16).padStart(2, "0")}`
105
- }).join("")
106
- const percentUpper = encoded.replace(/%[0-9a-f]{2}/g, (part) => part.toUpperCase())
107
- const padded = base64(bytes)
108
- const url = padded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
109
- const doubleQuoted = `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t")}"`
110
- const singleQuoted = `'${value}'`
111
- const dotenv = [doubleQuoted, singleQuoted]
112
- if (/^[^\s#'"\\\r\n]+$/u.test(value)) dotenv.push(value)
113
- const result = {
114
- semantic: value,
115
- dotenv,
116
- json: JSON.stringify(value).slice(1, -1),
117
- percent: [encoded, percentUpper],
118
- base64: [padded, padded.replace(/=+$/u, ""), url],
119
- }
120
- return result.dotenv.length + result.percent.length + result.base64.length > MAX_REPRESENTATIONS ? null : result
121
- } catch {
122
- return null
123
- } finally { reservation?.release() }
124
- }
125
-
126
- export function patternRepresentations(pattern: Pattern): string[] | null {
127
- const reservation = reserveOperation([pattern], 0)
128
- try {
129
- if (typeof pattern?.text !== "string") return null
130
- const derived = representationsFor(pattern.text)
131
- if (derived === null) return null
132
- const result = [...new Set([pattern.text, ...derived.dotenv, derived.json, ...derived.percent, ...derived.base64])]
133
- return result.length > MAX_REPRESENTATIONS ? null : result
134
- } catch {
135
- return null
136
- } finally { reservation?.release() }
137
- }
138
-
139
- const encoder = new TextEncoder()
140
- const REDACTION = "[REDACTED]"
141
- const UUID_TEMPLATE = [
142
- ..."secret-",
143
- ...Array.from({ length: 8 }, () => "h"), "-",
144
- ...Array.from({ length: 4 }, () => "h"), "-", "4", ...Array.from({ length: 3 }, () => "h"), "-",
145
- "v", ...Array.from({ length: 3 }, () => "h"), "-", ...Array.from({ length: 12 }, () => "h"),
146
- ]
147
-
148
- function uuidLanguageContains(value: string): boolean {
149
- if (value.length === 0 || value.length > UUID_TEMPLATE.length) return false
150
- const hex = /^[0-9a-f]$/i
151
- for (let start = 0; start <= UUID_TEMPLATE.length - value.length; start++) {
152
- let fits = true
153
- for (let i = 0; i < value.length; i++) {
154
- const slot = UUID_TEMPLATE[start + i]
155
- const character = value[i]
156
- const slotMatches = slot === "h" ? hex.test(character ?? "") : slot === "v" ? /^[89ab]$/i.test(character ?? "") : slot === character
157
- if (slot === undefined || character === undefined || !slotMatches) {
158
- fits = false
159
- break
160
- }
161
- }
162
- if (fits) return true
163
- }
164
- return false
165
- }
166
-
167
- export interface AdmissionResult {
168
- readonly ok: boolean
169
- readonly code?: "SANITIZATION_UNAVAILABLE"
170
- }
171
-
172
- /** Admission is deliberately all-or-nothing and emits only a fixed safe failure. */
173
- export function admitPatterns(patterns: readonly Pattern[]): AdmissionResult {
174
- const reservation = reserveOperation(patterns, 0)
175
- if (reservation === null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
176
- try {
177
- for (const pattern of patterns) {
178
- if (pattern === null || typeof pattern !== "object" || Array.isArray(pattern)) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
179
- const prototype = Object.getPrototypeOf(pattern)
180
- if (prototype !== Object.prototype && prototype !== null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
181
- const names = Object.getOwnPropertyNames(pattern)
182
- if (Object.getOwnPropertySymbols(pattern).length !== 0 || names.some((name) => !["text", "replacement", "entry", "representations"].includes(name))) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
183
- for (const name of names) {
184
- const descriptor = Object.getOwnPropertyDescriptor(pattern, name)
185
- if (descriptor === undefined || !("value" in descriptor)) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
186
- }
187
- const representations = patternRepresentations(pattern)
188
- if (representations === null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
189
- for (const representation of representations) {
190
- if (representation.length === 0 || uuidLanguageContains(representation) || RESERVED_OUTPUT_INVENTORY.some((item) => item.includes(representation))) {
191
- return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
192
- }
193
- }
194
- }
195
- return { ok: true }
196
- } catch { return { ok: false, code: "SANITIZATION_UNAVAILABLE" } } finally { reservation.release() }
197
- }
198
-
199
- function normalizedBudget(budget: Budget): Required<Budget> | null {
200
- try {
201
- if (budget === null || typeof budget !== "object" || Array.isArray(budget)) return null
202
- const prototype = Object.getPrototypeOf(budget)
203
- if (prototype !== Object.prototype && prototype !== null) return null
204
- const names = Object.getOwnPropertyNames(budget)
205
- if (Object.getOwnPropertySymbols(budget).length !== 0 || names.some((name) => !["maxOutputBytes", "maxDepth", "maxNodes", "maxMatches"].includes(name))) return null
206
- const data: Record<string, unknown> = {}
207
- for (const name of names) {
208
- const descriptor = Object.getOwnPropertyDescriptor(budget, name)
209
- if (descriptor === undefined || !("value" in descriptor)) return null
210
- data[name] = descriptor.value
211
- }
212
- const values = {
213
- maxOutputBytes: (data.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) as number,
214
- maxDepth: (data.maxDepth ?? DEFAULT_MAX_DEPTH) as number,
215
- maxNodes: (data.maxNodes ?? DEFAULT_MAX_NODES) as number,
216
- maxMatches: (data.maxMatches ?? DEFAULT_MAX_MATCHES) as number,
217
- }
218
- const valid =
219
- typeof values.maxOutputBytes === "number" && Number.isSafeInteger(values.maxOutputBytes) && values.maxOutputBytes >= 0 && values.maxOutputBytes <= MAX_OUTPUT_BYTES &&
220
- typeof values.maxDepth === "number" && Number.isSafeInteger(values.maxDepth) && values.maxDepth >= 0 && values.maxDepth <= MAX_DEPTH &&
221
- typeof values.maxNodes === "number" && Number.isSafeInteger(values.maxNodes) && values.maxNodes >= 0 && values.maxNodes <= MAX_NODES &&
222
- typeof values.maxMatches === "number" && Number.isSafeInteger(values.maxMatches) && values.maxMatches >= 0 && values.maxMatches <= MAX_MATCHES
223
- return valid ? values as Required<Budget> : null
224
- } catch { return null }
225
- }
226
-
227
- function collectIntervalsBounded(input: string, patterns: readonly (Pattern | string)[], budget: Required<Budget>): Interval[] | null {
228
- if (typeof input !== "string" || !Array.isArray(patterns) || patterns.length > MAX_PATTERN_COUNT) return null
229
- const found: Array<{ start: number; end: number; pattern: string }> = []
230
- let matchCount = 0
231
- for (const item of patterns) {
232
- const texts = typeof item === "string" ? [item] : patternRepresentations(item)
233
- if (texts === null) return null
234
- for (const text of texts) {
235
- if (text.length === 0) continue
236
- let at = input.indexOf(text)
237
- while (at !== -1) {
238
- matchCount++
239
- if (matchCount > budget.maxMatches) return null
240
- found.push({ start: at, end: at + text.length, pattern: text })
241
- at = input.indexOf(text, at + 1)
242
- }
243
- }
244
- }
245
- found.sort((a, b) => a.start - b.start || a.end - b.end)
246
- const result: Interval[] = []
247
- for (const match of found) {
248
- const previous = result[result.length - 1]
249
- if (previous !== undefined && match.start <= previous.end) {
250
- previous.end = Math.max(previous.end, match.end)
251
- if (!previous.patterns.includes(match.pattern)) previous.patterns.push(match.pattern)
252
- } else {
253
- result.push({ start: match.start, end: match.end, patterns: [match.pattern] })
254
- }
255
- }
256
- return result
257
- }
258
-
259
- export function collectIntervals(input: string, patterns: readonly (Pattern | string)[], budget: Budget = {}): Interval[] {
260
- const reservation = typeof input === "string" ? reserveOperation(patterns, encoder.encode(input).byteLength) : null
261
- if (reservation === null) return []
262
- try {
263
- if (typeof input !== "string") return []
264
- const normalized = normalizedBudget(budget)
265
- return normalized === null ? [] : (collectIntervalsBounded(input, patterns, normalized) ?? [])
266
- } catch { return [] } finally { reservation.release() }
267
- }
268
-
269
- function withinBytes(value: string, budget: Required<Budget>): boolean {
270
- return encoder.encode(value).byteLength <= budget.maxOutputBytes
271
- }
272
-
273
- function bytes(value: string): number {
274
- return encoder.encode(value).byteLength
275
- }
276
-
277
- /** One-pass replacement followed by a final boundary scan; null means safely withheld. */
278
- export function sanitizeText(input: string, patterns: readonly Pattern[], budget: Budget = {}): string | null {
279
- const reservation = typeof input === "string" ? reserveOperation(patterns, encoder.encode(input).byteLength) : null
280
- if (reservation === null) return null
281
- try {
282
- if (typeof input !== "string") return null
283
- const normalized = normalizedBudget(budget)
284
- if (normalized === null || !withinBytes(input, normalized)) return null
285
- const intervals = collectIntervalsBounded(input, patterns, normalized)
286
- if (intervals === null) return null
287
- let output = ""
288
- let cursor = 0
289
- for (const interval of intervals) {
290
- const prefix = input.slice(cursor, interval.start)
291
- const matches = patterns.filter((pattern) => {
292
- const representations = patternRepresentations(pattern)
293
- return representations !== null && representations.some((text) => interval.patterns.includes(text))
294
- })
295
- const entries = new Set(matches.map((pattern) => pattern.entry ?? pattern.text))
296
- const replacement = entries.size === 1 && matches[0]?.replacement !== undefined ? matches[0].replacement : REDACTION
297
- if (bytes(output) + bytes(prefix) + bytes(replacement) > normalized.maxOutputBytes) return null
298
- const next = output + prefix + replacement
299
- if (!withinBytes(next, normalized)) return null
300
- output = next
301
- cursor = interval.end
302
- }
303
- const suffix = input.slice(cursor)
304
- if (bytes(output) + bytes(suffix) > normalized.maxOutputBytes) return null
305
- const final = output + suffix
306
- if (!withinBytes(final, normalized) || collectIntervalsBounded(final, patterns, normalized)?.length !== 0) return null
307
- return final
308
- } catch { return null } finally { reservation.release() }
309
- }
310
-
311
- export type SecretScan = "clean" | "match" | "unverifiable"
312
-
313
- /**
314
- * Bounded containment scan that materializes no output: `"clean"` means every
315
- * representation was verified absent, `"match"` means at least one occurrence
316
- * exists (including when the match budget is exhausted), and `"unverifiable"`
317
- * means the scan could not run to completion (oversize input, unadmitted
318
- * patterns, exhausted reservations). Fail-closed callers must treat
319
- * `"unverifiable"` exactly like `"match"`.
320
- */
321
- export function scanForSecrets(input: string, patterns: readonly Pattern[], budget: Budget = {}): SecretScan {
322
- try {
323
- // UTF-8 bytes are always >= UTF-16 units; bound the encoding work first.
324
- if (typeof input !== "string" || input.length > MAX_TEMP_BYTES) return "unverifiable"
325
- const reservation = reserveOperation(patterns, encoder.encode(input).byteLength)
326
- if (reservation === null) return "unverifiable"
327
- try {
328
- const normalized = normalizedBudget(budget)
329
- if (normalized === null || !Array.isArray(patterns) || patterns.length > MAX_PATTERN_COUNT) return "unverifiable"
330
- for (const item of patterns) {
331
- const texts = patternRepresentations(item as Pattern)
332
- if (texts === null) return "unverifiable"
333
- for (const text of texts) {
334
- // One occurrence decides the verdict; no matches are accumulated.
335
- if (text.length > 0 && input.indexOf(text) !== -1) return "match"
336
- }
337
- }
338
- return "clean"
339
- } finally {
340
- reservation.release()
341
- }
342
- } catch {
343
- return "unverifiable"
344
- }
345
- }
346
-
347
- /**
348
- * Largest input (bytes) a scan can verify for this pattern set: the operation
349
- * ceiling (`MAX_TEMP_BYTES`) minus the admitted pattern bytes. Invalid pattern
350
- * sets collapse to 0 so every non-empty body is withheld rather than passed.
351
- */
352
- export function verifyCeilingBytes(patterns: readonly Pattern[]): number {
353
- try {
354
- if (!Array.isArray(patterns)) return 0
355
- let patternBytes = 0
356
- for (const item of patterns) {
357
- if (item === null || typeof item !== "object" || typeof (item as Pattern).text !== "string") return 0
358
- patternBytes += encoder.encode((item as Pattern).text).byteLength
359
- if (patternBytes > MAX_PATTERN_BYTES) return 0
360
- }
361
- return MAX_TEMP_BYTES - patternBytes
362
- } catch {
363
- return 0
364
- }
365
- }
366
-
367
- export interface TextSink { readonly content: readonly [{ readonly type: "text"; readonly text: string }] }
368
-
369
- export function canonicalTextSink(parts: readonly string[], patterns: readonly Pattern[], budget: Budget = {}): TextSink | null {
370
- let reservation: Reservation | null = null
371
- try {
372
- if (!Array.isArray(parts)) return null
373
- let inputBytes = 0
374
- for (const part of parts) {
375
- if (typeof part !== "string") return null
376
- inputBytes += encoder.encode(part).byteLength
377
- }
378
- reservation = reserveOperation(patterns, inputBytes)
379
- if (reservation === null) return null
380
- const normalized = normalizedBudget(budget)
381
- if (normalized === null) return null
382
- let joined = ""
383
- for (let i = 0; i < parts.length; i++) {
384
- const part = parts[i]
385
- if (typeof part !== "string") return null
386
- const separatorBytes = i > 0 ? bytes("\n") : 0
387
- if (bytes(joined) + separatorBytes + bytes(part) > normalized.maxOutputBytes) return null
388
- if (i > 0) joined += "\n"
389
- joined += part
390
- }
391
- const text = sanitizeText(joined, patterns, budget)
392
- return text === null ? null : { content: [{ type: "text", text }] }
393
- } catch { return null } finally { reservation?.release() }
394
- }
395
-
396
- function isPlainObject(value: unknown): value is Record<string, unknown> {
397
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false
398
- const prototype = Object.getPrototypeOf(value)
399
- return prototype === Object.prototype || prototype === null
400
- }
401
-
402
- /** Canonical JSON-like projection for plain data; accessors, arrays and non-finite numbers are withheld. */
403
- export function projectPlain(value: unknown, patterns: readonly Pattern[], budget: Budget = {}): string | null {
404
- const reservation = reserveOperation(patterns, 0)
405
- if (reservation === null) return null
406
- try {
407
- const normalized = normalizedBudget(budget)
408
- if (normalized === null) return null
409
- let nodes = 0
410
- const active = new Set<object>()
411
- const visit = (current: unknown, depth: number): string | null => {
412
- nodes++
413
- if (nodes > normalized.maxNodes || depth > normalized.maxDepth) return null
414
- if (current === null) return "null"
415
- if (typeof current === "string") {
416
- const safe = sanitizeText(current, patterns, normalized)
417
- return safe === null ? null : JSON.stringify(safe)
418
- }
419
- if (typeof current === "boolean") return current ? "true" : "false"
420
- if (typeof current === "number") return Number.isFinite(current) ? String(current) : null
421
- if (!isPlainObject(current)) return null
422
- if (active.has(current)) return null
423
- active.add(current)
424
- const keys: string[] = []
425
- // Do not materialize/sort an unbounded own-key array. The extra key is a
426
- // bounded rejection probe; maxNodes also bounds the useful key set.
427
- for (const key in current) {
428
- if (!Object.prototype.propertyIsEnumerable.call(current, key)) continue
429
- keys.push(key)
430
- if (keys.length > normalized.maxNodes || keys.length > MAX_OBJECT_KEYS) return null
431
- }
432
- keys.sort()
433
- const chunks: string[] = ["{"]
434
- for (let i = 0; i < keys.length; i++) {
435
- const key = keys[i]
436
- if (key === undefined || collectIntervalsBounded(key, patterns, normalized)?.length !== 0) return null
437
- const descriptor = Object.getOwnPropertyDescriptor(current, key)
438
- if (descriptor === undefined || !("value" in descriptor)) return null
439
- const child = visit(descriptor.value, depth + 1)
440
- if (child === null) return null
441
- if (i > 0) chunks.push(",")
442
- chunks.push(JSON.stringify(key), ":", child)
443
- if (!withinBytes(chunks.join(""), normalized)) return null
444
- }
445
- chunks.push("}")
446
- const result = chunks.join("")
447
- active.delete(current)
448
- return withinBytes(result, normalized) ? result : null
449
- }
450
- const result = visit(value, 0)
451
- if (result === null || collectIntervalsBounded(result, patterns, normalized)?.length !== 0 || !withinBytes(result, normalized)) return null
452
- return result
453
- } catch { return null } finally { reservation.release() }
454
- }
@@ -1,5 +0,0 @@
1
- # Placeholder: sources
2
-
3
- Non-executing dotenv loader plus the native opened-object adapter below.
4
- Expected failures reject the whole snapshot. Never use shell sourcing or dotenv
5
- expansion into `process.env`. No implementation yet.
@@ -1,133 +0,0 @@
1
- import * as fs from "node:fs"
2
- import * as path from "node:path"
3
- import * as os from "node:os"
4
-
5
- export type DotenvEntry = Readonly<{ key: string; value: string; lexicalValue: string }>
6
- export type DotenvSnapshot = Readonly<{ entries: readonly DotenvEntry[]; bytes: number }>
7
- export class SourceParseError extends Error { readonly code = "SOURCE_INVALID"; constructor() { super("SOURCE_INVALID"); this.name = "SourceParseError" } }
8
-
9
- export function parseDotenv(source: string, options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}): DotenvSnapshot {
10
- try {
11
- const limits = normalizeOptions(options)
12
- if (typeof source !== "string") throw new SourceParseError()
13
- const bytes = Buffer.byteLength(source, "utf8")
14
- if (bytes > limits.maxBytes) throw new SourceParseError()
15
- const entries: DotenvEntry[] = []; const names = new Set<string>(); let index = 0
16
- while (index < source.length) {
17
- const lineStart = index; const end = source.indexOf("\n", index); const lineEnd = end < 0 ? source.length : end; let line = source.slice(index, lineEnd)
18
- if (line.endsWith("\r")) line = line.slice(0, -1); index = end < 0 ? source.length : end + 1
19
- const trimmed = line.trim(); if (trimmed === "" || trimmed.startsWith("#")) continue
20
- const assignment = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(line)
21
- if (!assignment) throw new SourceParseError()
22
- const key = assignment[1] as string; if (names.has(key)) throw new SourceParseError(); names.add(key)
23
- let rest = line.slice(assignment[0].length); let lexical = rest; let value: string
24
- if (rest.startsWith("'") || rest.startsWith('"')) {
25
- const quote = rest[0] as string; let content = rest.slice(1); let close = quotedClose(content, quote)
26
- while (close < 0) { if (index >= source.length) throw new SourceParseError(); const nextEnd = source.indexOf("\n", index); const nextLineEnd = nextEnd < 0 ? source.length : nextEnd; let next = source.slice(index, nextLineEnd); if (next.endsWith("\r")) next = next.slice(0, -1); content += "\n" + next; index = nextEnd < 0 ? source.length : nextEnd + 1; close = quotedClose(content, quote) }
27
- const body = content.slice(0, close); const after = content.slice(close + 1).trim(); if (after !== "" && !after.startsWith("#")) throw new SourceParseError()
28
- value = quote === '"' ? decodeDouble(body) : body; lexical = quote + body + quote
29
- } else { const comment = rest.indexOf("#"); if (comment >= 0) rest = rest.slice(0, comment); value = rest.trimEnd(); lexical = value }
30
- if (Buffer.byteLength(value, "utf8") > limits.maxValueBytes || entries.length >= limits.maxEntries) throw new SourceParseError()
31
- entries.push(Object.freeze({ key, value, lexicalValue: lexical }))
32
- void lineStart
33
- }
34
- return Object.freeze({ entries: Object.freeze(entries), bytes })
35
- } catch (error) { if (error instanceof SourceParseError) throw error; throw new SourceParseError() }
36
- }
37
-
38
- function normalizeOptions(options: unknown): { maxBytes: number; maxEntries: number; maxValueBytes: number } {
39
- if (options === null || typeof options !== "object" || Array.isArray(options)) throw new SourceParseError()
40
- const prototype = Object.getPrototypeOf(options)
41
- if (prototype !== Object.prototype && prototype !== null) throw new SourceParseError()
42
- const names = Object.getOwnPropertyNames(options)
43
- if (Object.getOwnPropertySymbols(options).length !== 0 || names.some(name => !["maxBytes", "maxEntries", "maxValueBytes"].includes(name))) throw new SourceParseError()
44
- const values: Record<string, unknown> = {}
45
- for (const name of names) {
46
- const descriptor = Object.getOwnPropertyDescriptor(options, name)
47
- if (!descriptor || !("value" in descriptor)) throw new SourceParseError()
48
- values[name] = descriptor.value
49
- }
50
- const result: { maxBytes: number; maxEntries: number; maxValueBytes: number } = {
51
- maxBytes: values.maxBytes === undefined ? 1_048_576 : values.maxBytes as number,
52
- maxEntries: values.maxEntries === undefined ? 1_000 : values.maxEntries as number,
53
- maxValueBytes: values.maxValueBytes === undefined ? 16_384 : values.maxValueBytes as number,
54
- }
55
- if (![result.maxBytes, result.maxEntries, result.maxValueBytes].every(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0)) throw new SourceParseError()
56
- return result
57
- }
58
-
59
- function decodeDouble(value: string): string {
60
- return value.replace(/\\([\\"nrt])/g, (_match, escaped: string) => ({ "\\": "\\", '"': '"', n: "\n", r: "\r", t: "\t" })[escaped] ?? escaped)
61
- }
62
-
63
- function quotedClose(value: string, quote: string): number {
64
- for (let i = 0; i < value.length; i++) {
65
- if (value[i] === quote) return i
66
- if (value[i] !== "\\") continue
67
- const escaped = value[++i]
68
- if (escaped === undefined) throw new SourceParseError()
69
- const valid = quote === '"' ? '\\"nrt'.includes(escaped) : escaped === quote || escaped === "\\"
70
- if (!valid) throw new SourceParseError()
71
- }
72
- return -1
73
- }
74
-
75
- /** Load a dotenv file from the filesystem, returning a parsed snapshot.
76
- *
77
- * Fail-closed: returns `SourceParseError` if the path is inaccessible, the
78
- * file cannot be read, the content is not a valid dotnet snapshot, or the
79
- * options violate limits. No external state or credentials are exposed in
80
- * error messages.
81
- *
82
- * The caller is responsible for ensuring the path is authorized; this function
83
- * does not perform ACL checks itself and assumes the caller has permission to
84
- * read the nominated location.
85
- */
86
- export function loadDotenvFile(
87
- filePath: string,
88
- options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}
89
- ): DotenvSnapshot {
90
- try {
91
- const resolved = path.resolve(filePath)
92
- const stat = fs.lstatSync(resolved)
93
- if (!stat.isFile() || stat.isSymbolicLink()) throw new SourceParseError()
94
- const noFollow = (fs.constants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0
95
- const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
96
- try {
97
- const opened = fs.fstatSync(descriptor)
98
- if (!opened.isFile()) throw new SourceParseError()
99
- const raw = fs.readFileSync(descriptor, "utf8")
100
- return parseDotenv(raw, options)
101
- } finally {
102
- fs.closeSync(descriptor)
103
- }
104
- } catch (e) {
105
- throw new SourceParseError()
106
- }
107
- }
108
-
109
- /** Load dotenv from the process's resolved configuration directory.
110
- *
111
- * Attempts `~/.opencode-dotenv/.env`, `~/.env`, and `./.env` in order, returning
112
- * the first successfully parsed snapshot. Returns `SourceParseError` if no
113
- * file is found or any file is unparseable.
114
- */
115
- export function loadDefaultDotenv(
116
- options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}
117
- ): DotenvSnapshot | SourceParseError {
118
- const home = os.homedir()
119
- const candidates: string[] = [
120
- path.resolve(home, ".opencode-dotenv", ".env"),
121
- path.resolve(home, ".env"),
122
- path.resolve(process.cwd(), ".env"),
123
- ]
124
- for (const p of candidates) {
125
- try {
126
- const raw = fs.readFileSync(p, "utf8")
127
- return parseDotenv(raw, options)
128
- } catch { /* try next candidate */ }
129
- }
130
- return new SourceParseError()
131
- }
132
-
133
- export default { parseDotenv, loadDotenvFile, loadDefaultDotenv }
@@ -1,5 +0,0 @@
1
- # Placeholder: native source opening
2
-
3
- P0 must prove descriptor/handle-relative opening and ACL/ancestor checks on
4
- Linux/ext4 and Windows/NTFS before choosing a native dependency. Unsupported
5
- platforms remain unsupported; no insecure path-based fallback.
@@ -1,64 +0,0 @@
1
- import { randomBytes as systemRandomBytes } from "node:crypto"
2
- import { BudgetLedger, type BudgetValues } from "../budgets/index.ts"
3
-
4
- export interface StoreOptions {
5
- readonly location: { readonly directory: string }
6
- readonly generation: number
7
- readonly randomBytes?: () => Uint8Array
8
- readonly budget?: BudgetLedger
9
- }
10
-
11
- type Reservation = { dispose(): void }
12
-
13
- /** Private in-memory value → opaque-handle map. It has no authorization or I/O behavior. */
14
- export class ActivationStore {
15
- private readonly entries = new Map<string, Reservation>()
16
- private readonly redactions = new Map<string, string>()
17
- private readonly budget: BudgetLedger
18
- private readonly random: () => Uint8Array
19
- private active = true
20
-
21
- constructor(options: StoreOptions) {
22
- if (!options || typeof options.location?.directory !== "string" || !Number.isInteger(options.generation) || options.generation < 0) throw new Error("invalid")
23
- this.budget = options.budget ?? new BudgetLedger({ activeSessions: 1, handles: 65_536, handlesPerSession: 1, secretCount: 65_536, secretBytes: 128 * 1024 * 1024, indexBytes: 128 * 1024 * 1024, workingBytes: 128 * 1024 * 1024 })
24
- this.random = options.randomBytes ?? (() => new Uint8Array(systemRandomBytes(16)))
25
- }
26
-
27
- register(value: string): string {
28
- if (!this.active || typeof value !== "string" || value.length === 0) throw new Error("denied")
29
- const existing = this.redactions.get(value)
30
- if (existing !== undefined) return existing
31
- const reservation = this.reserve({ handles: 1, secretCount: 1, secretBytes: Buffer.byteLength(value), indexBytes: Buffer.byteLength(value), workingBytes: Buffer.byteLength(value) })
32
- if (reservation === undefined) throw new Error("denied")
33
- const handle = this.newHandle()
34
- this.entries.set(handle, reservation)
35
- this.redactions.set(value, handle)
36
- return handle
37
- }
38
-
39
- teardown(): void {
40
- this.active = false
41
- for (const reservation of this.entries.values()) reservation.dispose()
42
- this.entries.clear()
43
- this.redactions.clear()
44
- }
45
-
46
- private reserve(request: BudgetValues): Reservation | undefined {
47
- const result = this.budget.reserve(request)
48
- return result.ok ? result.value : undefined
49
- }
50
-
51
- private newHandle(): string {
52
- for (let attempt = 0; attempt < 32; attempt += 1) {
53
- const bytes = this.random()
54
- if (!(bytes instanceof Uint8Array) || bytes.length !== 16) throw new Error("denied")
55
- const copy = new Uint8Array(bytes)
56
- copy[6] = (copy[6]! & 0x0f) | 0x40
57
- copy[8] = (copy[8]! & 0x3f) | 0x80
58
- const hex = Buffer.from(copy).toString("hex")
59
- const handle = `secret-${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
60
- if (!this.entries.has(handle)) return handle
61
- }
62
- throw new Error("denied")
63
- }
64
- }