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.
- package/LICENSE +21 -21
- package/README.md +11 -21
- package/package.json +29 -23
- package/plugin.mjs +3847 -0
- package/.editorconfig +0 -12
- package/.github/workflows/ci.yml +0 -75
- package/.github/workflows/release.yml +0 -50
- package/.node-version +0 -1
- package/AGENTS.md +0 -23
- package/INSTALL.md +0 -104
- package/docs/checkpoint.md +0 -68
- package/docs/history/1-spec-review.md +0 -233
- package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
- package/docs/history/11-p0-job-containment-review.md +0 -188
- package/docs/history/12-p0-api-client.md +0 -15
- package/docs/history/13-p0-status-review.md +0 -122
- package/docs/history/14-p0-containment-final-review.md +0 -84
- package/docs/history/15-config-source-implementation.md +0 -27
- package/docs/history/16-sanitizer-implementation.md +0 -65
- package/docs/history/17-store-policy-implementation.md +0 -36
- package/docs/history/18-pure-mvp-review.md +0 -182
- package/docs/history/19-pure-mvp-re-review.md +0 -142
- package/docs/history/2-p0-runtime-prerequisites.md +0 -163
- package/docs/history/20-pure-mvp-final-review.md +0 -116
- package/docs/history/21-pure-mvp-review-final.md +0 -109
- package/docs/history/22-pure-mvp-signoff-review.md +0 -113
- package/docs/history/23-pure-mvp-signoff-review.md +0 -75
- package/docs/history/24-owned-tools-implementation.md +0 -27
- package/docs/history/25-v2-plugin-integration.md +0 -53
- package/docs/history/26-owned-tools-review.md +0 -138
- package/docs/history/27-owned-tools-final-review.md +0 -93
- package/docs/history/28-owned-tools-signoff.md +0 -98
- package/docs/history/3-p0-harness-foundation.md +0 -105
- package/docs/history/4-p0-source-opening.md +0 -78
- package/docs/history/5-qualification-gate-review.md +0 -133
- package/docs/history/6-p0-infrastructure-review.md +0 -275
- package/docs/history/7-p0-job-containment.md +0 -29
- package/docs/history/8-p0-evidence-support.md +0 -52
- package/docs/history/9-p0-launch-plan.md +0 -88
- package/docs/spec/design.md +0 -111
- package/docs/spec/intent.md +0 -81
- package/docs/spec/plan.md +0 -78
- package/docs/testing.md +0 -157
- package/scripts/build-plugin.mjs +0 -84
- package/scripts/install-opencode-ci.ps1 +0 -39
- package/scripts/install-opencode-ci.sh +0 -27
- package/scripts/smoke.mjs +0 -105
- package/scripts/verify-package.mjs +0 -18
- package/src/README.md +0 -22
- package/src/budgets/index.ts +0 -49
- package/src/config/README.md +0 -4
- package/src/config/options.ts +0 -76
- package/src/diagnostics/README.md +0 -4
- package/src/opencode/v2/filter.ts +0 -350
- package/src/opencode/v2/index.ts +0 -86
- package/src/opencode/v2/runtime.ts +0 -207
- package/src/opencode/v2/transports/README.md +0 -5
- package/src/runtime/http-transport.ts +0 -20
- package/src/sanitize/README.md +0 -4
- package/src/sanitize/index.ts +0 -454
- package/src/sources/README.md +0 -5
- package/src/sources/dotenv.ts +0 -133
- package/src/sources/native/README.md +0 -5
- package/src/store/index.ts +0 -64
- package/tests/README.md +0 -26
- package/tests/contract/plugin-v2.test.ts +0 -29
- package/tests/fixtures/README.md +0 -9
- package/tests/fixtures/job-child.mjs +0 -18
- package/tests/fixtures/plugins/README.md +0 -8
- package/tests/fixtures/process-child.mjs +0 -20
- package/tests/fixtures/services/README.md +0 -10
- package/tests/integration/components.test.ts +0 -5
- package/tests/integration/transparent-runtime.test.ts +0 -97
- package/tests/integration/windows-job.test.ts +0 -71
- package/tests/p0-host/README.md +0 -24
- package/tests/p0-host/linux-job.sh +0 -61
- package/tests/p0-host/windows-job.ps1 +0 -51
- package/tests/p0-native/README.md +0 -33
- package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
- package/tests/scaffold/repository.test.ts +0 -105
- package/tests/smoke/README.md +0 -37
- package/tests/smoke/cases.json +0 -17
- package/tests/smoke/qualification.json +0 -20
- package/tests/smoke/runtime-candidate.json +0 -111
- package/tests/support/README.md +0 -32
- package/tests/support/child-process.ts +0 -235
- package/tests/support/host-api.ts +0 -123
- package/tests/support/opencode-environment.ts +0 -239
- package/tests/support/qualification.ts +0 -154
- package/tests/support/recording-endpoint.ts +0 -242
- package/tests/support/run-directory.ts +0 -179
- package/tests/support/smoke-cases.ts +0 -289
- package/tests/unit/budgets.test.ts +0 -35
- package/tests/unit/config.test.ts +0 -40
- package/tests/unit/dotenv.test.ts +0 -72
- package/tests/unit/filter-size.test.ts +0 -132
- package/tests/unit/harness.test.ts +0 -228
- package/tests/unit/host-api.test.ts +0 -122
- package/tests/unit/opencode-environment.test.ts +0 -161
- package/tests/unit/qualification.test.ts +0 -128
- package/tests/unit/recording-endpoint.test.ts +0 -188
- package/tests/unit/sanitize.test.ts +0 -159
- package/tests/unit/smoke-cases.test.ts +0 -20
- 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
|
-
})
|