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,289 +0,0 @@
1
- import { execFileSync } from "node:child_process"
2
- import { createHash, randomBytes } from "node:crypto"
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
4
- import { createServer } from "node:net"
5
- import { join } from "node:path"
6
- import { buildOpenCodeEnvironment } from "./opencode-environment.ts"
7
- import { runChild } from "./child-process.ts"
8
- import {
9
- startRecordingEndpoint,
10
- type RecordingEndpoint,
11
- } from "./recording-endpoint.ts"
12
-
13
- /** Transparent substitution smoke support; captures and canaries stay private. */
14
-
15
- const HOST_VERSION = "2.0.28"
16
- const SOURCE_ID = "smoke-env"
17
- const TOKEN_KEY = "SMOKE_ROUNDTRIP_TOKEN"
18
- const FINAL_TEXT = "ROUNDTRIP-DONE"
19
-
20
- export interface SmokeCaseContext {
21
- readonly entry: { readonly id: string }
22
- readonly repoRoot: string
23
- }
24
-
25
- export function buildPluginOptions(): Record<string, unknown> {
26
- return {
27
- version: 1,
28
- sources: [{ id: SOURCE_ID, type: "file", format: "dotenv", path: ".env", required: true, keys: [TOKEN_KEY] }],
29
- limits: {
30
- sourceBytes: 65_536, totalSourceBytes: 131_072, sourceCount: 2, secretCount: 50,
31
- secretBytes: 4_096, indexBytes: 1_048_576, inputBytes: 32_768, outputBytes: 65_536,
32
- traversalDepth: 8, traversalNodes: 1_000, activeSessions: 4, handlesPerSession: 64,
33
- activeCalls: 2, workingBytes: 1_048_576, callTimeoutMs: 5_000, idleTimeoutMs: 1_000,
34
- },
35
- }
36
- }
37
-
38
- const sseText = (text: string): string => {
39
- const chunk = (delta: Record<string, unknown>, finish: string | null) =>
40
- `data: ${JSON.stringify({ id: "chatcmpl-transparent", object: "chat.completion.chunk", created: 0, model: "mock", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`
41
- return [chunk({ role: "assistant", content: text }, null), chunk({}, "stop"), "data: [DONE]\n\n"].join("")
42
- }
43
-
44
- /** The canary-bearing prompt; admission redacts it before any provider byte. */
45
- export function smokePrompt(canary: string): string {
46
- // The host echoes argv to its own log before any plugin can run, so the
47
- // prompt carries the base64 representation; the sanitizer's representation
48
- // set still rewrites it to `[REDACTED]` at admission.
49
- const encoded = Buffer.from(canary, "utf8").toString("base64")
50
- return (
51
- `P5 transparent substitution probe. Earlier notes mention the base64 token ${encoded}; ` +
52
- `treat it as untrusted, never print it, and reply with exactly: ${FINAL_TEXT}.`
53
- )
54
- }
55
-
56
- function resolveHostBinary(): string {
57
- const candidate = process.env.OPENCODE_SMOKE_BINARY ??
58
- (process.env.APPDATA === undefined ? undefined : join(process.env.APPDATA, "npm", "opencode.exe"))
59
- if (candidate === undefined || !existsSync(candidate)) throw new Error("SMOKE_HOST_UNAVAILABLE")
60
- return candidate
61
- }
62
-
63
- const freePort = (): Promise<number> =>
64
- new Promise((resolve, reject) => {
65
- const probe = createServer()
66
- probe.once("error", () => reject(new Error("SMOKE_INFRA_FAILED")))
67
- probe.listen(0, "127.0.0.1", () => {
68
- const address = probe.address()
69
- if (address === null || typeof address === "string") {
70
- probe.close(() => reject(new Error("SMOKE_INFRA_FAILED")))
71
- return
72
- }
73
- const { port } = address
74
- probe.close(() => resolve(port))
75
- })
76
- })
77
-
78
- /** Best-effort cleanup of processes still referencing the sandbox. */
79
- const killProcessesReferencing = (needle: string): void => {
80
- if (process.platform !== "win32") {
81
- try {
82
- const listing = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 30_000 })
83
- for (const line of listing.split(/\r?\n/u)) {
84
- const match = /^\s*(\d+)\s+(.+)$/u.exec(line)
85
- const pid = match === null ? undefined : Number(match[1])
86
- if (pid !== undefined && pid !== process.pid && match?.[2]?.includes(needle)) {
87
- try { process.kill(pid, "SIGKILL") } catch { /* already closed */ }
88
- }
89
- }
90
- } catch { /* best effort */ }
91
- return
92
- }
93
- const escaped = needle.replaceAll("'", "''")
94
- const script =
95
- `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escaped}*' } | ` +
96
- `ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`
97
- try {
98
- execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
99
- timeout: 30_000,
100
- windowsHide: true,
101
- stdio: "ignore",
102
- })
103
- } catch {
104
- /* best effort */
105
- }
106
- }
107
-
108
- /** Best-effort cleanup of the sandbox's managed-service listener (its command line carries no sandbox path). */
109
- const killPortListener = (port: number): void => {
110
- if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) return
111
- if (process.platform !== "win32") {
112
- try { execFileSync("fuser", ["-k", `${port}/tcp`], { timeout: 30_000, stdio: "ignore" }) } catch { /* best effort */ }
113
- return
114
- }
115
- const script =
116
- `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ` +
117
- `ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`
118
- try {
119
- execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
120
- timeout: 30_000,
121
- windowsHide: true,
122
- stdio: "ignore",
123
- })
124
- } catch {
125
- /* best effort */
126
- }
127
- }
128
-
129
- const removeArtifacts = (directory: string): void => {
130
- const wait = (milliseconds: number): void => {
131
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
132
- }
133
- for (let attempt = 0; attempt < 12; attempt += 1) {
134
- try {
135
- rmSync(directory, { recursive: true, force: true })
136
- return
137
- } catch {
138
- wait(250)
139
- }
140
- }
141
- // A locked failed-run directory is retained as private evidence rather than
142
- // turning an otherwise passing case into a false negative.
143
- }
144
-
145
- /** True when the canary appears anywhere outside the intentional fixture. */
146
- const canaryOutsideFixture = (directory: string, fixture: string, canary: string): boolean => {
147
- const pending = [directory]
148
- while (pending.length > 0) {
149
- const current = pending.pop()
150
- if (current === undefined) break
151
- let entries
152
- try {
153
- entries = readdirSync(current, { withFileTypes: true })
154
- } catch {
155
- return true
156
- }
157
- for (const entry of entries) {
158
- const full = join(current, entry.name)
159
- if (full === fixture) continue
160
- if (entry.isDirectory()) {
161
- pending.push(full)
162
- continue
163
- }
164
- if (entry.isSymbolicLink() || !entry.isFile()) return true
165
- try {
166
- const data = readFileSync(full)
167
- if (data.byteLength <= 67_108_864 && data.includes(canary)) return true
168
- } catch {
169
- return true
170
- }
171
- }
172
- }
173
- return false
174
- }
175
-
176
- /** First corrected E2E: existing provider traffic, no plugin-owned tools or I/O. */
177
- export async function p5TransparentRoundtrip(context: SmokeCaseContext): Promise<string> {
178
- const startedAt = new Date().toISOString()
179
- const runId = `p5-transparent-${randomBytes(8).toString("hex")}`
180
- const artifacts = join(context.repoRoot, ".artifacts", runId)
181
- const root = join(artifacts, "run")
182
- const canary = `smk_${randomBytes(24).toString("hex")}`
183
- let recorder: RecordingEndpoint | undefined
184
- let succeeded = false
185
- let servicePort = -1
186
- mkdirSync(root, { recursive: true })
187
- writeFileSync(join(artifacts, "owner.json"), JSON.stringify({ pid: process.pid, startedAt }))
188
- try {
189
- const binary = resolveHostBinary()
190
- const sha256 = createHash("sha256").update(readFileSync(binary)).digest("hex")
191
- for (const directory of ["cwd", "plugin", "user", "home", "programdata", "temp"]) mkdirSync(join(root, directory), { recursive: true })
192
- const cwd = join(root, "cwd")
193
- const fixture = join(cwd, ".env")
194
- writeFileSync(fixture, `${TOKEN_KEY}=${canary}\n`)
195
- const dist = join(context.repoRoot, "dist", "plugin.mjs")
196
- if (!existsSync(dist)) throw new Error("SMOKE_ARTIFACT_MISSING")
197
- copyFileSync(dist, join(root, "plugin", "index.js"))
198
- try { execFileSync("git.exe", ["init"], { cwd, timeout: 30_000, stdio: "ignore" }) } catch { /* optional */ }
199
-
200
- const providerReply = context.entry.id === "P5-SINKS" ? canary : FINAL_TEXT
201
- const bodyCase = context.entry.id === "P5-BODIES"
202
- recorder = await startRecordingEndpoint({
203
- responses: [bodyCase
204
- ? { status: 400, contentType: "application/json", body: JSON.stringify({ error: { message: canary, detail: "synthetic provider failure" } }) }
205
- : { status: 200, contentType: "text/event-stream", body: sseText(providerReply) }],
206
- resolve: () => 0,
207
- maxRequests: 16,
208
- maxBodyBytes: 1_048_576,
209
- timeoutMs: 30_000,
210
- })
211
- const environment = buildOpenCodeEnvironment({
212
- root,
213
- password: randomBytes(32).toString("hex"),
214
- trustedOs: {
215
- userProfile: join(root, "user"), home: join(root, "home"), programData: join(root, "programdata"),
216
- temp: join(root, "temp"), tmp: join(root, "temp"), path: process.env.PATH ?? "",
217
- systemRoot: process.env.SystemRoot ?? "/", comspec: process.env.ComSpec ?? "/bin/sh", pathext: process.env.PATHEXT ?? ":",
218
- },
219
- sandboxConfig: {
220
- provider: {
221
- smoke: {
222
- npm: "@ai-sdk/openai-compatible", name: "Smoke Mock",
223
- options: { baseURL: `${recorder.url}/v1`, apiKey: "smoke-synthetic-key" }, models: { mock: { name: "Mock" } },
224
- },
225
- },
226
- model: "smoke/mock",
227
- plugins: [{ package: join(root, "plugin"), options: buildPluginOptions() }],
228
- },
229
- })
230
- const version = execFileSync(binary, ["--version"], { encoding: "utf8", env: environment.env, timeout: 30_000 }).trim()
231
- if (!version.includes(HOST_VERSION)) throw new Error("SMOKE_HOST_MISMATCH")
232
- const selectedPort = await freePort()
233
- servicePort = selectedPort
234
- const childBase = {
235
- executable: binary, expectedExecutableSha256: sha256, cwd,
236
- allowedEnv: environment.allowedEnv, startupTimeoutMs: 30_000,
237
- stdoutLimitBytes: 4_194_304, stderrLimitBytes: 4_194_304,
238
- }
239
- await runChild({ ...childBase, args: ["service", "set", "password", randomBytes(16).toString("hex")], env: environment.env, overallTimeoutMs: 60_000 })
240
- await runChild({ ...childBase, args: ["service", "set", "port", String(selectedPort)], env: environment.env, overallTimeoutMs: 60_000 })
241
- const result = await runChild({
242
- ...childBase,
243
- args: ["run", "--standalone", "--agent", "build", smokePrompt(canary)],
244
- env: environment.env,
245
- overallTimeoutMs: 240_000,
246
- })
247
- if (bodyCase ? result.exitCode === 0 : result.exitCode !== 0) throw new Error("SMOKE_HOST_RUN_FAILED")
248
- if (!bodyCase && context.entry.id !== "P5-SINKS" && !result.stdout.includes(FINAL_TEXT)) throw new Error("SMOKE_RUN_INCOMPLETE")
249
- const captures = recorder.captures().filter((capture) => capture.target.endsWith("/chat/completions"))
250
- const bodies = captures.map((capture) => capture.body.toString("utf8"))
251
- if (captures.length < 1) throw new Error("SMOKE_PROVIDER_CAPTURE_MISSING")
252
- for (const body of bodies) {
253
- try {
254
- const payload = JSON.parse(body) as { tools?: Array<{ function?: { name?: string }; name?: string }> }
255
- for (const tool of payload.tools ?? []) {
256
- const name = tool.function?.name ?? tool.name
257
- if (name === "credentials_read" || name === "credential_http") throw new Error("SMOKE_PLUGIN_TOOL_EXPOSED")
258
- }
259
- } catch (error) {
260
- if (error instanceof Error && error.message === "SMOKE_PLUGIN_TOOL_EXPOSED") throw error
261
- throw new Error("SMOKE_PROVIDER_PAYLOAD_INVALID")
262
- }
263
- }
264
- if (bodies.some((body) => body.includes(canary))) throw new Error("SMOKE_CANARY_IN_PROVIDER_CAPTURE")
265
- if (!bodies.some((body) => body.includes("secret-"))) throw new Error("SMOKE_HANDLE_SUBSTITUTION_MISSING")
266
- if (result.stdout.includes(canary) || result.stderr.includes(canary)) throw new Error("SMOKE_CANARY_IN_PUBLIC_OUTPUT")
267
- if (context.entry.id === "P5-SINKS" && !result.stdout.includes("secret-")) throw new Error("SMOKE_RESPONSE_SUBSTITUTION_MISSING")
268
- if (canaryOutsideFixture(root, fixture, canary)) throw new Error("SMOKE_CANARY_IN_HOST_DATA")
269
- writeFileSync(join(artifacts, "evidence.json"), JSON.stringify({
270
- case: context.entry.id, status: "passed", startedAt, finishedAt: new Date().toISOString(),
271
- host: { version, sha256 }, counts: { providerPosts: captures.length },
272
- assertions: {
273
- noPluginTools: true,
274
- requestSubstitution: true,
275
- responseSubstitution: context.entry.id === "P5-SINKS",
276
- errorBodyWithheld: bodyCase,
277
- publicOutputClean: true,
278
- hostDataClean: true,
279
- },
280
- }, null, 2))
281
- succeeded = true
282
- return `host ${version}; ${captures.length} provider post(s); raw canary replaced by opaque secret- handle; no plugin-owned request`
283
- } finally {
284
- if (recorder !== undefined) await recorder.close().catch(() => undefined)
285
- killProcessesReferencing(root)
286
- if (servicePort > 0) killPortListener(servicePort)
287
- if (succeeded) removeArtifacts(artifacts)
288
- }
289
- }
@@ -1,35 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { test } from "node:test"
3
- import { BudgetLedger } from "../../src/budgets/index.ts"
4
-
5
- test("budget reservations are atomic and released after disposal", () => {
6
- const ledger = new BudgetLedger({ inputBytes: 10, outputBytes: 10, workingBytes: 20, activeCalls: 1, activeSessions: 1, handlesPerSession: 2, sourceBytes: 20, sourceCount: 1, indexBytes: 20, traversalDepth: 2, traversalNodes: 4, secretCount: 2, secretBytes: 20, totalSourceBytes: 20, calls: 1, nodes: 4, depth: 2, sessions: 1, handles: 2 })
7
- const reservation = ledger.reserve({ inputBytes: 5, outputBytes: 4, workingBytes: 10, activeCalls: 1, activeSessions: 1, handles: 1, sourceBytes: 5, sourceCount: 1, indexBytes: 5, traversalDepth: 1, traversalNodes: 2, secretCount: 1, secretBytes: 5, totalSourceBytes: 5, calls: 1, nodes: 2, depth: 1, sessions: 1 })
8
- assert.equal(ledger.reserve({ inputBytes: 6, workingBytes: 11 }).ok, false)
9
- assert.equal(reservation.ok, true)
10
- if (reservation.ok) reservation.value.dispose()
11
- assert.equal(ledger.reserve({ inputBytes: 10, workingBytes: 20 }).ok, true)
12
- })
13
-
14
- test("unknown and negative budget dimensions are rejected without raw diagnostics", () => {
15
- assert.throws(() => new BudgetLedger({ inputBytes: -1 } as never), /invalid/)
16
- assert.throws(() => new BudgetLedger({ inputBytes: 1, mystery: 2 } as never), /invalid/)
17
- })
18
-
19
- test("budget rejects checked additions that exceed the safe integer range", () => {
20
- const ledger = new BudgetLedger({ inputBytes: Number.MAX_SAFE_INTEGER })
21
- assert.equal(ledger.reserve({ inputBytes: Number.MAX_SAFE_INTEGER }).ok, true)
22
- assert.equal(ledger.reserve({ inputBytes: 1 }).ok, false)
23
- })
24
-
25
- test("budget boundaries deny throwing proxies, accessors, symbols, and cyclic requests", () => {
26
- assert.throws(() => new BudgetLedger(new Proxy({ inputBytes: 1 }, { ownKeys: () => { throw new Error("foreign") } })), /invalid/)
27
- const accessor = Object.defineProperty({}, "inputBytes", { get: () => { throw new Error("foreign") }, enumerable: true })
28
- assert.throws(() => new BudgetLedger(accessor), /invalid/)
29
- const ledger = new BudgetLedger({ inputBytes: 2 })
30
- const cyclic = Object.create(null) as Record<string, unknown>
31
- cyclic.self = cyclic
32
- assert.equal(ledger.reserve(cyclic as never).ok, false)
33
- const symbol = { inputBytes: 1, [Symbol("unknown")]: 1 }
34
- assert.equal(ledger.reserve(symbol as never).ok, false)
35
- })
@@ -1,40 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { test } from "node:test"
3
- import { parseOptions, OptionsError } from "../../src/config/options.ts"
4
-
5
- const limits = {
6
- sourceBytes: 1_048_576, totalSourceBytes: 2_097_152, sourceCount: 4,
7
- secretCount: 1000, secretBytes: 16_384, indexBytes: 8_388_608,
8
- inputBytes: 1_048_576, outputBytes: 2_097_152, traversalDepth: 32,
9
- traversalNodes: 50_000, activeSessions: 8, handlesPerSession: 2048,
10
- activeCalls: 4, workingBytes: 33_554_432, callTimeoutMs: 30_000, idleTimeoutMs: 5_000,
11
- }
12
- const valid = () => ({
13
- version: 1,
14
- sources: [{ id: "project", type: "file", format: "dotenv", path: ".env", required: true, keys: ["SMOKE_TOKEN"] }],
15
- limits,
16
- })
17
-
18
- test("parses only source and bounded substitution configuration", () => {
19
- const options = parseOptions(valid())
20
- assert.equal(options.version, 1)
21
- assert.equal(options.sources[0]?.path, ".env")
22
- })
23
-
24
- test("rejects tool-broker, authorization and unknown configuration fields", () => {
25
- for (const input of [
26
- { ...valid(), authorization: {} },
27
- { ...valid(), capabilities: {} },
28
- { ...valid(), unexpected: true },
29
- ]) {
30
- assert.throws(() => parseOptions(input), (error: unknown) => error instanceof OptionsError && error.message === "CONFIG_INVALID")
31
- }
32
- })
33
-
34
- test("rejects unsafe source paths and raised budgets", () => {
35
- for (const input of [
36
- { ...valid(), sources: [{ ...valid().sources[0], path: "../secret.env" }] },
37
- { ...valid(), sources: [{ ...valid().sources[0], path: "${env:SECRET}" }] },
38
- { ...valid(), limits: { ...limits, sourceBytes: 1_048_577 } },
39
- ]) assert.throws(() => parseOptions(input), (error: unknown) => error instanceof OptionsError && error.message === "CONFIG_INVALID")
40
- })
@@ -1,72 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { test } from "node:test"
3
- import * as fs from "node:fs/promises"
4
- import * as os from "node:os"
5
- import * as path from "node:path"
6
- import { loadDotenvFile, parseDotenv, SourceParseError } from "../../src/sources/dotenv.ts"
7
-
8
- test("parses comments, export, quotes, CRLF and multiline without expansion", () => {
9
- const snapshot = parseDotenv("# ignored\r\nexport TOKEN=abc#not-comment\r\nNAME='hello world'\nMULTI=\"one\\ntwo\"\nRAW=$NO_EXPAND\n")
10
- assert.deepEqual(snapshot.entries.map(({ key, value }) => ({ key, value })), [
11
- { key: "TOKEN", value: "abc" }, { key: "NAME", value: "hello world" },
12
- { key: "MULTI", value: "one\ntwo" }, { key: "RAW", value: "$NO_EXPAND" },
13
- ])
14
- assert.equal(snapshot.entries[2]?.lexicalValue, '"one\\ntwo"')
15
- })
16
-
17
- test("supports quoted multiline values and preserves lexical spelling", () => {
18
- const result = parseDotenv("KEY=\"first\nsecond\"\nSINGLE='a#b'\n")
19
- assert.equal(result.entries[0]?.value, "first\nsecond")
20
- assert.equal(result.entries[0]?.lexicalValue, '"first\nsecond"')
21
- assert.equal(result.entries[1]?.value, "a#b")
22
- })
23
-
24
- test("uses stateful escaping for quoted values and preserves every accepted spelling", () => {
25
- const source = String.raw`DOUBLE="a\"b\\c\n\r\t"
26
- MULTI="first
27
- second"
28
- SINGLE='a\\b\'c'
29
- `
30
- const result = parseDotenv(source)
31
- assert.deepEqual(result.entries.map(({ key, value, lexicalValue }) => ({ key, value, lexicalValue })), [
32
- { key: "DOUBLE", value: 'a"b\\c\n\r\t', lexicalValue: String.raw`"a\"b\\c\n\r\t"` },
33
- { key: "MULTI", value: "first\nsecond", lexicalValue: String.raw`"first
34
- second"` },
35
- { key: "SINGLE", value: String.raw`a\\b\'c`, lexicalValue: String.raw`'a\\b\'c'` },
36
- ])
37
- })
38
-
39
- test("rejects unsupported and dangling quoted escapes", () => {
40
- for (const source of ["A=\"bad\\q\"", "A=\"bad" + "\\", "A='bad" + "\\"]) {
41
- assert.throws(() => parseDotenv(source), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
42
- }
43
- })
44
-
45
- test("rejects duplicates, malformed lines, oversized input and never partially returns", () => {
46
- for (const source of ["A=1\nA=2", "not an assignment", "A=\"unterminated"]) {
47
- assert.throws(() => parseDotenv(source), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
48
- }
49
- assert.throws(() => parseDotenv("A=12345", { maxBytes: 4 }), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
50
- })
51
-
52
- test("normalizes dotenv options as finite positive data-only limits", () => {
53
- for (const options of [
54
- { maxBytes: 0 }, { maxEntries: -1 }, { maxValueBytes: Infinity },
55
- { maxBytes: Number.MAX_SAFE_INTEGER + 1 }, { unknown: 1 },
56
- ]) {
57
- assert.throws(() => parseDotenv("A=1", options as never), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
58
- }
59
- const throwing = new Proxy({}, { ownKeys: () => { throw new Error("foreign") } })
60
- assert.throws(() => parseDotenv("A=1", throwing as never), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
61
- const accessor = Object.defineProperty({}, "maxBytes", { get: () => { throw new Error("foreign") } })
62
- assert.throws(() => parseDotenv("A=1", accessor as never), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
63
- })
64
-
65
- test("loads only a regular final source file and never reads a directory", async (t) => {
66
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), "dotenv-source-"))
67
- t.after(() => { void fs.rm(directory, { recursive: true, force: true }) })
68
- const file = path.join(directory, ".env")
69
- await fs.writeFile(file, "TOKEN=synthetic\n")
70
- assert.equal(loadDotenvFile(file).entries[0]?.value, "synthetic")
71
- assert.throws(() => loadDotenvFile(directory), (error: unknown) => error instanceof SourceParseError && error.message === "SOURCE_INVALID")
72
- })
@@ -1,132 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { test } from "node:test"
3
- import { admitPatterns } from "../../src/sanitize/index.ts"
4
- import { containsSecret, redactRequest, redactResponse, type FilterState } from "../../src/opencode/v2/filter.ts"
5
-
6
- const MIB = 1024 * 1024
7
- const REWRITE_CEILING = 1 * MIB
8
- const VERIFY_CEILING = 8 * MIB
9
-
10
- const canary = "CANARY_7f3a9c2e_synthetic"
11
- const patterns = [{ text: canary }]
12
-
13
- function makeState(): FilterState {
14
- assert.equal(admitPatterns(patterns).ok, true)
15
- return {
16
- patterns,
17
- budget: { maxOutputBytes: 2 * MIB, maxDepth: 32, maxNodes: 50_000, maxMatches: 50_000 },
18
- maxInputBytes: REWRITE_CEILING,
19
- }
20
- }
21
-
22
- const jsonBody = (padLength: number, leak?: string): string =>
23
- JSON.stringify({ pad: "a".repeat(padLength), ...(leak === undefined ? {} : { leak }) })
24
-
25
- const postRequest = (body: string): Request =>
26
- new Request("https://provider.invalid/v1/chat/completions", {
27
- method: "POST",
28
- headers: { "content-type": "application/json" },
29
- body,
30
- })
31
-
32
- test("response SSE redaction preserves valid JSON event framing", async () => {
33
- const value = "SSE_CANARY_7f3a9c2e"
34
- const handle = "secret-12345678-1234-4234-9234-123456789012"
35
- const state: FilterState = {
36
- patterns: [{ text: value, replacement: handle, entry: value }],
37
- budget: { maxOutputBytes: 65_536, maxDepth: 32, maxNodes: 1_000, maxMatches: 1_000 },
38
- maxInputBytes: 32_768,
39
- }
40
- const body = `data: ${JSON.stringify({ choices: [{ delta: { content: value } }] })}\n\ndata: [DONE]\n\n`
41
- const response = await redactResponse(new Response(body, { headers: { "content-type": "text/event-stream" } }), state)
42
- const output = await response.text()
43
- const frame = output.split("\n").find((line) => line.startsWith("data: {") )
44
- assert.ok(frame)
45
- const parsed = JSON.parse(frame.slice("data: ".length)) as { choices: Array<{ delta: { content: string } }> }
46
- assert.equal(parsed.choices[0]!.delta.content, handle)
47
- assert.ok(!output.includes(value))
48
- })
49
-
50
- test("clean request body between the rewrite and verify ceilings passes unchanged", async () => {
51
- const state = makeState()
52
- const body = jsonBody(1_600_000)
53
- assert.ok(body.length > REWRITE_CEILING && body.length < VERIFY_CEILING)
54
- const result = await redactRequest(postRequest(body), state)
55
- if (result === "withheld") assert.fail("clean oversize body must not be withheld")
56
- assert.equal(await result.text(), body)
57
- })
58
-
59
- test("request body between the ceilings containing a raw secret is withheld", async () => {
60
- const state = makeState()
61
- const body = jsonBody(1_600_000, canary)
62
- const result = await redactRequest(postRequest(body), state)
63
- assert.equal(result, "withheld")
64
- })
65
-
66
- test("request bodies beyond the verify ceiling are withheld even when clean", async () => {
67
- const state = makeState()
68
- const body = jsonBody(9_000_000)
69
- assert.ok(body.length > VERIFY_CEILING)
70
- assert.equal(await redactRequest(postRequest(body), state), "withheld")
71
- })
72
-
73
- test("a declared content-length above the verify ceiling withholds before reading", async () => {
74
- const state = makeState()
75
- const request = new Request("https://provider.invalid/v1/chat/completions", {
76
- method: "POST",
77
- headers: { "content-type": "application/json", "content-length": String(9 * MIB) },
78
- body: "{}",
79
- })
80
- assert.equal(request.headers.get("content-length"), String(9 * MIB), "test precondition: declared length is preserved")
81
- assert.equal(await redactRequest(request, state), "withheld")
82
- })
83
-
84
- test("a small declared content-length does not veto a body that verifies clean", async () => {
85
- const state = makeState()
86
- const body = jsonBody(1_600_000)
87
- const request = postRequest(body)
88
- const declared = request.headers.get("content-length")
89
- assert.ok(declared === null || Number(declared) <= REWRITE_CEILING, `test precondition: declared=${declared}`)
90
- const result = await redactRequest(request, state)
91
- if (result === "withheld") assert.fail("verified-clean body must not be withheld")
92
- assert.equal(await result.text(), body)
93
- })
94
-
95
- test("clean response body between the ceilings is published unchanged", async () => {
96
- const state = makeState()
97
- const text = jsonBody(1_600_000)
98
- assert.ok(text.length > REWRITE_CEILING && text.length < VERIFY_CEILING)
99
- const response = await redactResponse(new Response(text, { status: 200, headers: { "content-type": "application/json" } }), state)
100
- assert.equal(await response.text(), text)
101
- })
102
-
103
- test("response body between the ceilings containing a raw secret is withheld", async () => {
104
- const state = makeState()
105
- const response = await redactResponse(new Response(jsonBody(1_600_000, canary), { status: 200 }), state)
106
- assert.equal(await response.text(), "")
107
- })
108
-
109
- test("response bodies beyond the verify ceiling are withheld even when clean", async () => {
110
- const state = makeState()
111
- const response = await redactResponse(new Response("a".repeat(9_000_000), { status: 200 }), state)
112
- assert.equal(await response.text(), "")
113
- })
114
-
115
- test("body at the rewrite ceiling still rewrites secrets in place", async () => {
116
- const state = makeState()
117
- const body = jsonBody(200_000, canary)
118
- assert.ok(body.length <= REWRITE_CEILING)
119
- const result = await redactRequest(postRequest(body), state)
120
- if (result === "withheld") assert.fail("small rewriting body must not be withheld")
121
- const published = await result.text()
122
- assert.equal(published.includes(canary), false)
123
- assert.equal(published.includes("[REDACTED]"), true)
124
- })
125
-
126
- test("containsSecret verifies clean values beyond the output budget", async () => {
127
- const state = makeState()
128
- const clean = "b".repeat(3 * MIB)
129
- assert.equal(containsSecret(clean, state), false)
130
- assert.equal(containsSecret(`${clean}${canary}`, state), true)
131
- assert.equal(containsSecret("a".repeat(9 * MIB), state), true)
132
- })