opencode-secrets-obfuscation 0.1.0 → 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +11 -21
  3. package/package.json +29 -23
  4. package/plugin.mjs +3847 -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,76 +0,0 @@
1
- export type Limits = {
2
- sourceBytes: number; totalSourceBytes: number; sourceCount: number; secretCount: number
3
- secretBytes: number; indexBytes: number; inputBytes: number; outputBytes: number
4
- traversalDepth: number; traversalNodes: number; activeSessions: number
5
- handlesPerSession: number; activeCalls: number; workingBytes: number
6
- callTimeoutMs: number; idleTimeoutMs: number
7
- }
8
-
9
- export type SourceDescriptor = { id: string; type: "file"; format: "dotenv"; path: string; required: boolean; keys: string[] }
10
- export type PluginOptions = {
11
- version: 1
12
- sources: SourceDescriptor[]
13
- limits: Limits
14
- }
15
-
16
- export class OptionsError extends Error {
17
- readonly code = "CONFIG_INVALID"
18
- readonly path: string
19
- constructor(path = "options") { super("CONFIG_INVALID"); this.name = "OptionsError"; this.path = path }
20
- }
21
-
22
- const ceilings: Limits = {
23
- sourceBytes: 1_048_576, totalSourceBytes: 2_097_152, sourceCount: 4,
24
- secretCount: 1000, secretBytes: 16_384, indexBytes: 8_388_608,
25
- inputBytes: 1_048_576, outputBytes: 2_097_152, traversalDepth: 32,
26
- traversalNodes: 50_000, activeSessions: 8, handlesPerSession: 2048,
27
- activeCalls: 4, workingBytes: 33_554_432, callTimeoutMs: 30_000, idleTimeoutMs: 5_000,
28
- }
29
- const keys = (value: object, allowed: readonly string[], path: string): void => {
30
- for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new OptionsError(`${path}.${key}`)
31
- }
32
- function checked(value: unknown, path: string, seen: Set<object>): Record<string, unknown> {
33
- if (typeof value !== "object" || value === null || (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) || (Array.isArray(value) && Object.getPrototypeOf(value) !== Array.prototype)) throw new OptionsError(path)
34
- if (seen.has(value)) throw new OptionsError(path)
35
- seen.add(value)
36
- for (const key of Object.keys(value)) {
37
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
38
- if (!descriptor || !("value" in descriptor)) throw new OptionsError(`${path}.${key}`)
39
- if (typeof descriptor.value === "object" && descriptor.value !== null) checked(descriptor.value, `${path}.${key}`, seen)
40
- }
41
- seen.delete(value)
42
- return value as Record<string, unknown>
43
- }
44
- const string = (value: unknown, path: string, pattern?: RegExp): string => {
45
- if (typeof value !== "string" || value.length === 0 || value.length > 256 || (pattern && !pattern.test(value)) || /\$\{(?:env|file):/i.test(value)) throw new OptionsError(path)
46
- return value
47
- }
48
- const list = (value: unknown, path: string): unknown[] => { if (!Array.isArray(value)) throw new OptionsError(path); return value }
49
-
50
- function parseOptionsUnsafe(input: unknown): PluginOptions {
51
- const seen = new Set<object>(); const options = checked(input, "options", seen)
52
- keys(options, ["version", "sources", "limits"], "options")
53
- if (options.version !== 1) throw new OptionsError("options.version")
54
-
55
- const sources = list(options.sources, "options.sources").map((raw, i) => {
56
- const source = checked(raw, `sources[${i}]`, seen); keys(source, ["id", "type", "format", "path", "required", "keys"], `sources[${i}]`)
57
- const path = string(source.path, `sources[${i}].path`)
58
- if (path.includes("\0") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:/.test(path) || path.split(/[\\/]/).some(segment => segment === "." || segment === "..")) throw new OptionsError(`sources[${i}].path`)
59
- if (source.type !== "file" || source.format !== "dotenv" || typeof source.required !== "boolean") throw new OptionsError(`sources[${i}]`)
60
- const sourceKeys = list(source.keys, `sources[${i}].keys`).map((key, keyIndex) => string(key, `sources[${i}].keys[${keyIndex}]`, /^[A-Za-z_][A-Za-z0-9_]*$/))
61
- if (sourceKeys.length === 0 || new Set(sourceKeys).size !== sourceKeys.length) throw new OptionsError(`sources[${i}].keys`)
62
- return { id: string(source.id, `sources[${i}].id` , /^[A-Za-z][A-Za-z0-9._-]*$/), type: "file", format: "dotenv", path, required: source.required, keys: sourceKeys } as SourceDescriptor
63
- })
64
- if (sources.length === 0 || sources.length > ceilings.sourceCount || new Set(sources.map(s => s.id)).size !== sources.length) throw new OptionsError("sources")
65
- const configuredLimits = checked(options.limits, "options.limits", seen); keys(configuredLimits, Object.keys(ceilings), "limits")
66
- const limits = {} as Limits
67
- for (const key of Object.keys(ceilings) as (keyof Limits)[]) { const value = configuredLimits[key]; if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > ceilings[key]) throw new OptionsError(`limits.${key}`); limits[key] = value }
68
- return { version: 1, sources, limits }
69
- }
70
-
71
- export function parseOptions(input: unknown): PluginOptions {
72
- try { return parseOptionsUnsafe(input) } catch (error) {
73
- if (error instanceof OptionsError) throw error
74
- throw new OptionsError()
75
- }
76
- }
@@ -1,4 +0,0 @@
1
- # Placeholder: safe diagnostics
2
-
3
- Fixed non-echoing codes, manifest/coverage status and safe audit records.
4
- Never log maps, raw input, error causes or credential-derived indexes. No code yet.
@@ -1,350 +0,0 @@
1
- import { sanitizeText, scanForSecrets, verifyCeilingBytes, type Budget, type Pattern } from "../../sanitize/index.ts"
2
-
3
- /** Fixed replacement/withheld token; part of the reserved output inventory. */
4
- export const WITHHELD = "SANITIZATION_UNAVAILABLE"
5
- const encoder = new TextEncoder()
6
- const decoder = new TextDecoder()
7
- /** Provider authentication stays in its transport-auth channel (design §8). */
8
- const AUTH_CHANNEL = new Set(["authorization", "api-key", "x-api-key", "proxy-authorization"])
9
- const NULL_BODY_STATUS = new Set([101, 204, 205, 304])
10
-
11
- export interface FilterState {
12
- readonly patterns: readonly Pattern[]
13
- readonly budget: Required<Budget>
14
- readonly maxInputBytes: number
15
- }
16
-
17
- export const hasSecrets = (state: FilterState): boolean => state.patterns.length > 0
18
-
19
- interface WalkCounters { nodes: number }
20
-
21
- interface WalkResult {
22
- readonly value: unknown
23
- readonly changed: boolean
24
- }
25
-
26
- /**
27
- * In-place, bounded walker over plain JSON-like host data. Strings are run
28
- * through the reviewed sanitizer; only a fixed token ever replaces text.
29
- * Non-plain objects (signals, class instances) are never serialized into a
30
- * model payload by contract and are skipped rather than arbitrarily invoked.
31
- */
32
- function redactNode(current: unknown, depth: number, state: FilterState, counters: WalkCounters): WalkResult {
33
- counters.nodes++
34
- if (counters.nodes > state.budget.maxNodes || depth > state.budget.maxDepth) return { value: WITHHELD, changed: true }
35
- if (typeof current === "string") {
36
- if (current.length === 0) return { value: current, changed: false }
37
- const safe = sanitizeText(current, state.patterns, state.budget)
38
- if (safe === null) return { value: WITHHELD, changed: true }
39
- return { value: safe, changed: safe !== current }
40
- }
41
- if (current === null || typeof current !== "object") return { value: current, changed: false }
42
- if (Array.isArray(current)) {
43
- let changed = false
44
- for (let index = 0; index < current.length; index++) {
45
- const result = redactNode(current[index], depth + 1, state, counters)
46
- if (result.changed) {
47
- current[index] = result.value
48
- changed = true
49
- }
50
- }
51
- return { value: current, changed }
52
- }
53
- const prototype = Object.getPrototypeOf(current)
54
- if (prototype !== Object.prototype && prototype !== null) return { value: current, changed: false }
55
- const record = current as Record<string, unknown>
56
- let changed = false
57
- for (const key of Object.keys(record)) {
58
- const descriptor = Object.getOwnPropertyDescriptor(record, key)
59
- if (!descriptor || !("value" in descriptor)) continue
60
- const safeKey = redactString(key, state)
61
- if (safeKey === null) return { value: WITHHELD, changed: true }
62
- const result = redactNode(descriptor.value, depth + 1, state, counters)
63
- if (result.changed || safeKey !== key) {
64
- if (safeKey !== key) {
65
- if (Object.prototype.hasOwnProperty.call(record, safeKey)) return { value: WITHHELD, changed: true }
66
- delete record[key]
67
- record[safeKey] = result.value
68
- } else {
69
- record[key] = result.value
70
- }
71
- changed = true
72
- }
73
- }
74
- return { value: current, changed }
75
- }
76
-
77
- /** Sanitizes a host event tree in place. Returns true when anything changed. */
78
- export function redactTree(root: unknown, state: FilterState): boolean {
79
- if (!hasSecrets(state)) return false
80
- return redactNode(root, 0, state, { nodes: 0 }).changed
81
- }
82
-
83
- /** One bounded string replacement; null means safely withheld. */
84
- export function redactString(value: string, state: FilterState): string | null {
85
- if (!hasSecrets(state)) return value
86
- if (encoder.encode(value).byteLength > state.maxInputBytes) return null
87
- return sanitizeText(value, state.patterns, state.budget)
88
- }
89
-
90
- /**
91
- * Conservative presence oracle: true when a candidate still intersects a
92
- * registered interval OR cannot be verified at all (oversize beyond the scan
93
- * ceiling, unadmitted patterns, exhausted budgets). `false` therefore means
94
- * "verified clean" — for values up to `verifyCeilingBytes(patterns)` this is a
95
- * containment scan and is not gated by the rewrite/output budgets.
96
- */
97
- export function containsSecret(value: string, state: FilterState): boolean {
98
- if (!hasSecrets(state)) return false
99
- return scanForSecrets(value, state.patterns, state.budget) !== "clean"
100
- }
101
-
102
- export interface HeaderOptions {
103
- /** Skip provider transport-auth headers (request side only). */
104
- readonly skipAuth: boolean
105
- /** Withhold the whole record on the first unsafe pair instead of dropping it. */
106
- readonly strict: boolean
107
- }
108
-
109
- /**
110
- * Sanitizes a plain header record in place (values, then names). Returns
111
- * `"withheld"` only in strict mode when any pair cannot be made safe.
112
- */
113
- export function redactHeaders(headers: Record<string, string>, state: FilterState, options: HeaderOptions): "withheld" | void {
114
- if (!hasSecrets(state)) return
115
- for (const name of Object.keys(headers)) {
116
- if (options.skipAuth && AUTH_CHANNEL.has(name.toLowerCase())) continue
117
- const value = headers[name]
118
- if (typeof value !== "string") {
119
- if (options.strict) return "withheld"
120
- delete headers[name]
121
- continue
122
- }
123
- const safeValue = redactString(value, state)
124
- const safeName = redactString(name, state)
125
- if (safeValue === null || safeName === null) {
126
- if (options.strict) return "withheld"
127
- delete headers[name]
128
- continue
129
- }
130
- if (safeValue !== value) headers[name] = safeValue
131
- if (safeName !== name) {
132
- delete headers[name]
133
- headers[safeName] = safeValue
134
- }
135
- }
136
- }
137
-
138
- /**
139
- * JSON-aware body rewrite for the exact published bytes: structured payloads
140
- * are sanitized per field and re-serialized only when something changed; the
141
- * final joined bytes are independently re-scanned before publication.
142
- */
143
- export function redactBody(text: string, state: FilterState): string | null {
144
- if (!hasSecrets(state)) return text
145
- const trimmed = text.trimStart()
146
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
147
- try {
148
- const parsed: unknown = JSON.parse(text)
149
- const result = redactNode(parsed, 0, state, { nodes: 0 })
150
- if (!result.changed) return text
151
- const serialized = JSON.stringify(result.value)
152
- if (serialized === undefined) return null
153
- if (encoder.encode(serialized).byteLength > state.maxInputBytes) return null
154
- if (containsSecret(serialized, state)) return null
155
- return serialized
156
- } catch {
157
- /* Not a JSON payload after all; fall through to raw text handling. */
158
- }
159
- }
160
- return redactString(text, state)
161
- }
162
-
163
- /** Redact JSON payloads inside SSE data frames without corrupting JSON quotes. */
164
- function redactEventStream(text: string, state: FilterState): string | null {
165
- const lines = text.split(/(\r?\n)/u)
166
- for (let index = 0; index < lines.length; index += 1) {
167
- const line = lines[index]!
168
- if (!line.startsWith("data:") || line.endsWith("\n") || line.endsWith("\r")) continue
169
- const payload = line.slice(5).trimStart()
170
- if (payload === "[DONE]" || payload.length === 0) continue
171
- try {
172
- const parsed: unknown = JSON.parse(payload)
173
- if (!redactTree(parsed, state)) continue
174
- const serialized = JSON.stringify(parsed)
175
- if (serialized === undefined || containsSecret(serialized, state)) return null
176
- lines[index] = `data: ${serialized}`
177
- } catch {
178
- return null
179
- }
180
- }
181
- return lines.join("")
182
- }
183
-
184
- /**
185
- * Bounded body read: accumulates at most `limit` payload bytes and returns the
186
- * decoded text with its actual byte size. `null` means the body exceeded the
187
- * limit or could not be read (both are withhold conditions for callers). On
188
- * overflow the reader is abandoned rather than cancelled: cancelling one branch
189
- * of Node's tee'd clone blocks forever when the sibling branch is never read,
190
- * and an abandoned reader leaves the sibling (original) branch intact for the
191
- * publish decision. Neither branch is ever forwarded after an overflow.
192
- */
193
- async function readBoundedBody(body: ReadableStream<Uint8Array> | null, limit: number): Promise<{ text: string; bytes: number } | null> {
194
- if (body === null) return { text: "", bytes: 0 }
195
- try {
196
- const reader = body.getReader()
197
- const chunks: Uint8Array[] = []
198
- let total = 0
199
- for (;;) {
200
- const { done, value } = await reader.read()
201
- if (done) break
202
- if (value === undefined || value.byteLength === 0) continue
203
- total += value.byteLength
204
- if (total > limit) return null
205
- chunks.push(value)
206
- }
207
- const merged = new Uint8Array(total)
208
- let offset = 0
209
- for (const chunk of chunks) {
210
- merged.set(chunk, offset)
211
- offset += chunk.byteLength
212
- }
213
- return { text: decoder.decode(merged), bytes: total }
214
- } catch {
215
- return null
216
- }
217
- }
218
-
219
- /**
220
- * One-shot, bounded replacement of a native provider `Request`. Bodies at or
221
- * below the rewrite ceiling (`maxInputBytes`) are rewritten and re-scanned as
222
- * before. Larger bodies up to the verify ceiling (8 MiB minus pattern bytes)
223
- * are containment-scanned instead: a verified-clean body is passed through
224
- * unchanged, anything that still intersects a registered representation — or
225
- * cannot be verified — withholds the whole request. Beyond the verify ceiling
226
- * (or a declared content-length above it) the request is withheld without
227
- * reading. The caller must stop with a fixed defect on `"withheld"` rather
228
- * than pass the original through.
229
- */
230
- export async function redactRequest(request: Request, state: FilterState): Promise<Request | "withheld"> {
231
- if (!hasSecrets(state)) return request
232
- const verifyCeiling = verifyCeilingBytes(state.patterns)
233
- const declared = Number(request.headers.get("content-length") ?? Number.NaN)
234
- if (Number.isFinite(declared) && declared > verifyCeiling) return "withheld"
235
- const record: Record<string, string> = {}
236
- request.headers.forEach((value, name) => {
237
- record[name] = value
238
- })
239
- if (redactHeaders(record, state, { skipAuth: true, strict: true }) === "withheld") return "withheld"
240
- delete record["content-length"]
241
- let body: string | undefined
242
- if (request.body !== null) {
243
- const read = await readBoundedBody(request.clone().body, verifyCeiling)
244
- if (read === null) return "withheld"
245
- if (read.bytes > state.maxInputBytes) {
246
- // Verify-only zone: rewriting is out of budget, so pass the original
247
- // only after proving it clean; otherwise withhold.
248
- if (scanForSecrets(read.text, state.patterns, state.budget) !== "clean") return "withheld"
249
- } else {
250
- const safe = redactBody(read.text, state)
251
- if (safe === null) return "withheld"
252
- if (safe !== read.text) body = safe
253
- }
254
- }
255
- try {
256
- return new Request(request, {
257
- headers: record,
258
- ...(body === undefined ? {} : { body }),
259
- signal: request.signal,
260
- })
261
- } catch {
262
- return "withheld"
263
- }
264
- }
265
-
266
- /**
267
- * Inbound response projection: every header pair is sanitized (dropped when
268
- * unsafe). Bodies at or below the rewrite ceiling are rewritten and re-scanned;
269
- * larger bodies up to the verify ceiling are containment-scanned and published
270
- * unchanged only when verified clean. Beyond the verify ceiling (or a declared
271
- * content-length above it) the body is withheld as an empty payload. The
272
- * original response is never returned unexamined once a registered value
273
- * exists.
274
- */
275
- export async function redactResponse(response: Response, state: FilterState): Promise<Response> {
276
- if (!hasSecrets(state)) return response
277
- const headers: Record<string, string> = {}
278
- response.headers.forEach((value, name) => {
279
- headers[name] = value
280
- })
281
- redactHeaders(headers, state, { skipAuth: false, strict: false })
282
- const status = response.status
283
- const nullBody = NULL_BODY_STATUS.has(status)
284
- if (nullBody) {
285
- try {
286
- return new Response(null, { status, headers })
287
- } catch {
288
- return new Response(null, { status: 502 })
289
- }
290
- }
291
- const verifyCeiling = verifyCeilingBytes(state.patterns)
292
- const declared = Number(response.headers.get("content-length") ?? Number.NaN)
293
- if (Number.isFinite(declared) && declared > verifyCeiling) {
294
- try {
295
- return new Response(null, { status, headers })
296
- } catch {
297
- return new Response(null, { status: 502 })
298
- }
299
- }
300
- const read = await readBoundedBody(response.clone().body, verifyCeiling)
301
- if (read === null) {
302
- try {
303
- return new Response(null, { status, headers })
304
- } catch {
305
- return new Response(null, { status: 502 })
306
- }
307
- }
308
- let body: string
309
- if (read.bytes > state.maxInputBytes) {
310
- body = scanForSecrets(read.text, state.patterns, state.budget) === "clean" ? read.text : ""
311
- } else if ((headers["content-type"] ?? "").toLowerCase().includes("text/event-stream")) {
312
- body = redactEventStream(read.text, state) ?? ""
313
- } else {
314
- const safe = redactBody(read.text, state)
315
- body = safe === null ? "" : safe
316
- }
317
- try {
318
- return new Response(body, { status, headers })
319
- } catch {
320
- return new Response(null, { status: 502 })
321
- }
322
- }
323
-
324
- /**
325
- * Drops positional `mention` offsets from a prompt after a rewrite; offsets
326
- * cannot be trusted once text lengths changed. Attachment identity (uri/id)
327
- * lives on the parent record and is preserved.
328
- */
329
- export function dropMentions(root: unknown): void {
330
- const walk = (node: unknown, depth: number, counters: WalkCounters): void => {
331
- counters.nodes++
332
- if (counters.nodes > 10_000 || depth > 32) return
333
- if (Array.isArray(node)) {
334
- for (const item of node) walk(item, depth + 1, counters)
335
- return
336
- }
337
- if (!node || typeof node !== "object") return
338
- const prototype = Object.getPrototypeOf(node)
339
- if (prototype !== Object.prototype && prototype !== null) return
340
- const record = node as Record<string, unknown>
341
- for (const key of Object.keys(record)) {
342
- if (key === "mention" && record[key] && typeof record[key] === "object") {
343
- delete record[key]
344
- continue
345
- }
346
- walk(record[key], depth + 1, counters)
347
- }
348
- }
349
- walk(root, 0, { nodes: 0 })
350
- }
@@ -1,86 +0,0 @@
1
- import { Plugin } from "@opencode/plugin/effect"
2
- import type { Context } from "@opencode/plugin/effect/plugin"
3
- import { Tool } from "@opencode/schema/tool"
4
- import { Effect } from "effect"
5
- import type { SessionHooks } from "@opencode/plugin/effect/session"
6
- import type { ToolHooks } from "@opencode/plugin/effect/tool"
7
- import { parseOptions, type PluginOptions } from "../../config/options.ts"
8
- import type { Scope } from "effect"
9
- import { realRuntimeFactory } from "./runtime.ts"
10
-
11
- /**
12
- * The core track owns policy, source loading, storage and sanitization. This
13
- * narrow boundary is intentional: no host event is converted to a guessed
14
- * core shape, and no secret-bearing value crosses the plugin boundary.
15
- */
16
- export interface CoreRuntime {
17
- readonly close: () => Effect.Effect<void>
18
- readonly session: {
19
- readonly prompt: (event: SessionHooks["prompt"]) => Effect.Effect<void>
20
- readonly context: (event: SessionHooks["context"]) => Effect.Effect<void>
21
- readonly compaction: (event: SessionHooks["compaction"]) => Effect.Effect<void>
22
- readonly generate: (event: SessionHooks["generate"]) => Effect.Effect<void>
23
- readonly title: (event: SessionHooks["title"]) => Effect.Effect<void>
24
- readonly modelRequest: (event: SessionHooks["model.request"]) => Effect.Effect<void>
25
- readonly httpRequest: (event: SessionHooks["http.request"]) => Effect.Effect<void>
26
- readonly httpResponse: (event: SessionHooks["http.response"]) => Effect.Effect<void>
27
- }
28
- readonly tool: {
29
- readonly before: (event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>
30
- readonly after: (event: ToolHooks["execute.after"]) => Effect.Effect<void>
31
- }
32
- }
33
-
34
- export type CoreRuntimeFactory = (options: PluginOptions, location: Context["location"]) => Effect.Effect<CoreRuntime, never, never>
35
-
36
- export const unavailableRuntime: CoreRuntime = {
37
- close: () => Effect.void,
38
- session: {
39
- prompt: () => Effect.void,
40
- context: () => Effect.void,
41
- compaction: () => Effect.void,
42
- generate: () => Effect.void,
43
- title: () => Effect.void,
44
- modelRequest: () => Effect.void,
45
- httpRequest: () => Effect.void,
46
- httpResponse: () => Effect.void,
47
- },
48
- tool: {
49
- before: () => Effect.void,
50
- after: () => Effect.void,
51
- },
52
- }
53
-
54
- export const unavailableRuntimeFactory: CoreRuntimeFactory = () => Effect.succeed(unavailableRuntime)
55
-
56
- /** Build the native V2 Effect entrypoint around an injected, qualified core. */
57
- export const makeEffect = (factory: CoreRuntimeFactory = unavailableRuntimeFactory) =>
58
- (ctx: Context): Effect.Effect<void, never, Scope.Scope> => Effect.gen(function* () {
59
- const runtime = yield* Effect.suspend((): Effect.Effect<CoreRuntime, never, never> => {
60
- try {
61
- return factory(parseOptions(ctx.options), ctx.location)
62
- } catch {
63
- return Effect.succeed(unavailableRuntime)
64
- }
65
- })
66
- yield* Effect.addFinalizer(() => runtime.close())
67
-
68
- yield* ctx.session.hook("prompt", (event) => runtime.session.prompt(event))
69
- yield* ctx.session.hook("context", (event) => runtime.session.context(event))
70
- yield* ctx.session.hook("compaction", (event) => runtime.session.compaction(event))
71
- yield* ctx.session.hook("generate", (event) => runtime.session.generate(event))
72
- yield* ctx.session.hook("title", (event) => runtime.session.title(event))
73
- yield* ctx.session.hook("model.request", (event) => runtime.session.modelRequest(event))
74
- yield* ctx.session.hook("http.request", (event) => runtime.session.httpRequest(event))
75
- yield* ctx.session.hook("http.response", (event) => runtime.session.httpResponse(event))
76
- yield* ctx.tool.hook("execute.before", (event) => runtime.tool.before(event))
77
- yield* ctx.tool.hook("execute.after", (event) => runtime.tool.after(event))
78
-
79
- })
80
-
81
- const plugin = Plugin.define({
82
- id: "opencode-secrets-obfuscation",
83
- effect: makeEffect(realRuntimeFactory),
84
- })
85
-
86
- export default plugin