opencode-secrets-obfuscation 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -31
  3. package/package.json +29 -23
  4. package/plugin.mjs +3841 -0
  5. package/.editorconfig +0 -12
  6. package/.github/workflows/ci.yml +0 -75
  7. package/.github/workflows/release.yml +0 -50
  8. package/.node-version +0 -1
  9. package/AGENTS.md +0 -23
  10. package/INSTALL.md +0 -104
  11. package/docs/checkpoint.md +0 -68
  12. package/docs/history/1-spec-review.md +0 -233
  13. package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
  14. package/docs/history/11-p0-job-containment-review.md +0 -188
  15. package/docs/history/12-p0-api-client.md +0 -15
  16. package/docs/history/13-p0-status-review.md +0 -122
  17. package/docs/history/14-p0-containment-final-review.md +0 -84
  18. package/docs/history/15-config-source-implementation.md +0 -27
  19. package/docs/history/16-sanitizer-implementation.md +0 -65
  20. package/docs/history/17-store-policy-implementation.md +0 -36
  21. package/docs/history/18-pure-mvp-review.md +0 -182
  22. package/docs/history/19-pure-mvp-re-review.md +0 -142
  23. package/docs/history/2-p0-runtime-prerequisites.md +0 -163
  24. package/docs/history/20-pure-mvp-final-review.md +0 -116
  25. package/docs/history/21-pure-mvp-review-final.md +0 -109
  26. package/docs/history/22-pure-mvp-signoff-review.md +0 -113
  27. package/docs/history/23-pure-mvp-signoff-review.md +0 -75
  28. package/docs/history/24-owned-tools-implementation.md +0 -27
  29. package/docs/history/25-v2-plugin-integration.md +0 -53
  30. package/docs/history/26-owned-tools-review.md +0 -138
  31. package/docs/history/27-owned-tools-final-review.md +0 -93
  32. package/docs/history/28-owned-tools-signoff.md +0 -98
  33. package/docs/history/3-p0-harness-foundation.md +0 -105
  34. package/docs/history/4-p0-source-opening.md +0 -78
  35. package/docs/history/5-qualification-gate-review.md +0 -133
  36. package/docs/history/6-p0-infrastructure-review.md +0 -275
  37. package/docs/history/7-p0-job-containment.md +0 -29
  38. package/docs/history/8-p0-evidence-support.md +0 -52
  39. package/docs/history/9-p0-launch-plan.md +0 -88
  40. package/docs/spec/design.md +0 -111
  41. package/docs/spec/intent.md +0 -81
  42. package/docs/spec/plan.md +0 -78
  43. package/docs/testing.md +0 -157
  44. package/scripts/build-plugin.mjs +0 -84
  45. package/scripts/install-opencode-ci.ps1 +0 -39
  46. package/scripts/install-opencode-ci.sh +0 -27
  47. package/scripts/smoke.mjs +0 -105
  48. package/scripts/verify-package.mjs +0 -18
  49. package/src/README.md +0 -22
  50. package/src/budgets/index.ts +0 -49
  51. package/src/config/README.md +0 -4
  52. package/src/config/options.ts +0 -76
  53. package/src/diagnostics/README.md +0 -4
  54. package/src/opencode/v2/filter.ts +0 -350
  55. package/src/opencode/v2/index.ts +0 -86
  56. package/src/opencode/v2/runtime.ts +0 -207
  57. package/src/opencode/v2/transports/README.md +0 -5
  58. package/src/runtime/http-transport.ts +0 -20
  59. package/src/sanitize/README.md +0 -4
  60. package/src/sanitize/index.ts +0 -454
  61. package/src/sources/README.md +0 -5
  62. package/src/sources/dotenv.ts +0 -133
  63. package/src/sources/native/README.md +0 -5
  64. package/src/store/index.ts +0 -64
  65. package/tests/README.md +0 -26
  66. package/tests/contract/plugin-v2.test.ts +0 -29
  67. package/tests/fixtures/README.md +0 -9
  68. package/tests/fixtures/job-child.mjs +0 -18
  69. package/tests/fixtures/plugins/README.md +0 -8
  70. package/tests/fixtures/process-child.mjs +0 -20
  71. package/tests/fixtures/services/README.md +0 -10
  72. package/tests/integration/components.test.ts +0 -5
  73. package/tests/integration/transparent-runtime.test.ts +0 -97
  74. package/tests/integration/windows-job.test.ts +0 -71
  75. package/tests/p0-host/README.md +0 -24
  76. package/tests/p0-host/linux-job.sh +0 -61
  77. package/tests/p0-host/windows-job.ps1 +0 -51
  78. package/tests/p0-native/README.md +0 -33
  79. package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
  80. package/tests/scaffold/repository.test.ts +0 -105
  81. package/tests/smoke/README.md +0 -37
  82. package/tests/smoke/cases.json +0 -17
  83. package/tests/smoke/qualification.json +0 -20
  84. package/tests/smoke/runtime-candidate.json +0 -111
  85. package/tests/support/README.md +0 -32
  86. package/tests/support/child-process.ts +0 -235
  87. package/tests/support/host-api.ts +0 -123
  88. package/tests/support/opencode-environment.ts +0 -239
  89. package/tests/support/qualification.ts +0 -154
  90. package/tests/support/recording-endpoint.ts +0 -242
  91. package/tests/support/run-directory.ts +0 -179
  92. package/tests/support/smoke-cases.ts +0 -289
  93. package/tests/unit/budgets.test.ts +0 -35
  94. package/tests/unit/config.test.ts +0 -40
  95. package/tests/unit/dotenv.test.ts +0 -72
  96. package/tests/unit/filter-size.test.ts +0 -132
  97. package/tests/unit/harness.test.ts +0 -228
  98. package/tests/unit/host-api.test.ts +0 -122
  99. package/tests/unit/opencode-environment.test.ts +0 -161
  100. package/tests/unit/qualification.test.ts +0 -128
  101. package/tests/unit/recording-endpoint.test.ts +0 -188
  102. package/tests/unit/sanitize.test.ts +0 -159
  103. package/tests/unit/smoke-cases.test.ts +0 -20
  104. package/tsconfig.json +0 -20
@@ -1,154 +0,0 @@
1
- /**
2
- * Test-harness bookkeeping only. This assesses a trusted runner's selected-case
3
- * evidence; it does not verify artifact hashes, capture contents, assertions, or
4
- * completeness of the release's required-case selection. It cannot qualify a
5
- * runtime by itself. Inputs are validated as bounded plain-data records here.
6
- */
7
- export type Profile = "active-only" | "enforced"
8
- export type Expectation = "behavior" | "protected" | "denied" | "expected-gap"
9
- export type EvidenceStatus = "passed" | "observed-gap" | "planned" | "todo" | "skipped" | "failed" | "unavailable"
10
-
11
- export interface RequiredCase {
12
- id: string
13
- profile: Profile
14
- platform: string
15
- expectation: Expectation
16
- }
17
-
18
- export interface CaseEvidence extends RequiredCase {
19
- runID: string
20
- buildID: string
21
- status: EvidenceStatus
22
- assertions: number
23
- captureIDs: string[]
24
- }
25
-
26
- interface RunIdentity {
27
- runID: string
28
- buildID: string
29
- profile: Profile
30
- platform: string
31
- }
32
-
33
- type FailureCode = "INVALID_SELECTION" | "INVALID_RUN" | "INVALID_REQUIREMENT" |
34
- "INVALID_EVIDENCE" | "EVIDENCE_COUNT" | "EVIDENCE_MISMATCH" | "CASE_NOT_PASSED" |
35
- "MISSING_ASSERTIONS_OR_CAPTURES" | "GAP_NOT_ALLOWED" | "INVALID_INPUT"
36
-
37
- export interface Assessment {
38
- satisfied: boolean
39
- failures: { code: FailureCode }[]
40
- }
41
-
42
- const safeID = (value: string) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
43
- const caseID = (value: string) => /^P[0-9]-[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(value) && value.length <= 128
44
- const profile = (value: string) => value === "active-only" || value === "enforced"
45
- const expectation = (value: string) => ["behavior", "protected", "denied", "expected-gap"].includes(value)
46
-
47
- function plain(value: unknown, fields: readonly string[]): Record<string, unknown> | undefined {
48
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
49
- const prototype = Object.getPrototypeOf(value)
50
- if (prototype !== Object.prototype && prototype !== null) return undefined
51
- if (Reflect.ownKeys(value).length !== fields.length) return undefined
52
- const result: Record<string, unknown> = Object.create(null)
53
- for (const key of fields) {
54
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
55
- if (!descriptor || !("value" in descriptor)) return undefined
56
- result[key] = descriptor.value
57
- }
58
- return result
59
- }
60
-
61
- function items(value: unknown, limit: number): unknown[] | undefined {
62
- if (!Array.isArray(value) || value.length > limit) return undefined
63
- const result: unknown[] = []
64
- for (let index = 0; index < value.length; index++) {
65
- const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
66
- if (!descriptor || !("value" in descriptor)) return undefined
67
- result.push(descriptor.value)
68
- }
69
- return result
70
- }
71
-
72
- /** Throws neither malformed input nor its contents into the caller's diagnostics. */
73
- export function assessCases(requiredInput: unknown, evidenceInput: unknown, runInput: unknown): Assessment {
74
- const invalidInput: Assessment = { satisfied: false, failures: [{ code: "INVALID_INPUT" }] }
75
- try {
76
- const requirements = items(requiredInput, 4096)
77
- const records = items(evidenceInput, 4096)
78
- const run = plain(runInput, ["runID", "buildID", "profile", "platform"])
79
- if (!requirements || !records || !run || Object.values(run).some((value) => typeof value !== "string")) return invalidInput
80
- const required: RequiredCase[] = []
81
- for (const value of requirements) {
82
- const item = plain(value, ["id", "profile", "platform", "expectation"])
83
- if (!item || Object.values(item).some((field) => typeof field !== "string")) return invalidInput
84
- required.push(item as unknown as RequiredCase)
85
- }
86
- const evidence: CaseEvidence[] = []
87
- for (const value of records) {
88
- const record = plain(value, ["id", "profile", "platform", "expectation", "runID", "buildID", "status", "assertions", "captureIDs"])
89
- if (!record || typeof record.assertions !== "number") return invalidInput
90
- const captures = items(record.captureIDs, 1024)
91
- if (!captures || captures.some((id) => typeof id !== "string")) return invalidInput
92
- for (const key of ["id", "profile", "platform", "expectation", "runID", "buildID", "status"]) {
93
- if (typeof record[key] !== "string") return invalidInput
94
- }
95
- evidence.push({ ...record, captureIDs: captures } as unknown as CaseEvidence)
96
- }
97
- return assessValidatedCases(required, evidence, run as unknown as RunIdentity)
98
- } catch {
99
- // Includes proxy reflection traps; never invoke getters or format their causes.
100
- return invalidInput
101
- }
102
- }
103
-
104
- function assessValidatedCases(required: readonly RequiredCase[], evidence: readonly CaseEvidence[], run: RunIdentity): Assessment {
105
- const codes = new Set<FailureCode>()
106
- const fail = (code: FailureCode) => { codes.add(code) }
107
- const finish = (): Assessment => ({ satisfied: codes.size === 0, failures: [...codes].map((code) => ({ code })) })
108
-
109
- if (required.length === 0 || required.length > 4096 || evidence.length > 4096) {
110
- fail("INVALID_SELECTION")
111
- return finish()
112
- }
113
- if (!safeID(run.runID) || !safeID(run.buildID) || !safeID(run.platform) || !profile(run.profile)) {
114
- fail("INVALID_RUN")
115
- return finish()
116
- }
117
-
118
- const selected = new Set<string>()
119
- for (const item of required) {
120
- if (!caseID(item.id) || selected.has(item.id) || item.profile !== run.profile ||
121
- item.platform !== run.platform || !expectation(item.expectation)) fail("INVALID_REQUIREMENT")
122
- selected.add(item.id)
123
- if (item.expectation === "expected-gap" && run.profile !== "active-only") fail("GAP_NOT_ALLOWED")
124
- }
125
- if (codes.size > 0) return finish()
126
-
127
- const grouped = new Map<string, CaseEvidence[]>()
128
- for (const record of evidence) {
129
- if (!caseID(record.id) || !selected.has(record.id)) {
130
- fail("INVALID_EVIDENCE")
131
- continue
132
- }
133
- const records = grouped.get(record.id) ?? []
134
- records.push(record)
135
- grouped.set(record.id, records)
136
- }
137
-
138
- for (const item of required) {
139
- const records = grouped.get(item.id) ?? []
140
- if (records.length !== 1) {
141
- fail("EVIDENCE_COUNT")
142
- continue
143
- }
144
- const record = records[0]!
145
- if (record.runID !== run.runID || record.buildID !== run.buildID || record.profile !== run.profile ||
146
- record.platform !== run.platform || record.expectation !== item.expectation) fail("EVIDENCE_MISMATCH")
147
- const needed = item.expectation === "expected-gap" ? "observed-gap" : "passed"
148
- if (record.status !== needed) fail("CASE_NOT_PASSED")
149
- if (!Number.isSafeInteger(record.assertions) || record.assertions < 1 || record.assertions > 1_000_000 ||
150
- record.captureIDs.length === 0 || record.captureIDs.length > 1024 ||
151
- record.captureIDs.some((id) => !safeID(id))) fail("MISSING_ASSERTIONS_OR_CAPTURES")
152
- }
153
- return finish()
154
- }
@@ -1,242 +0,0 @@
1
- import { createServer } from "node:http"
2
- import type { Socket } from "node:net"
3
-
4
- export interface ScriptedResponse {
5
- status: number
6
- body: string
7
- contentType?: "application/json" | "text/event-stream" | "text/plain"
8
- }
9
-
10
- /** Selects a scripted response index from the captured request; body must not be mutated. */
11
- export interface ResponseResolver {
12
- (request: { readonly sequence: number; readonly method: string; readonly target: string; readonly headers: Record<string, string>; readonly body: Buffer }): number
13
- }
14
-
15
- interface Options {
16
- responses: readonly ScriptedResponse[]
17
- resolve?: ResponseResolver
18
- maxRequests?: number
19
- maxBodyBytes?: number
20
- timeoutMs?: number
21
- }
22
-
23
- interface Capture {
24
- method: string
25
- target: string
26
- /** Lowercase header names with joined values; private capture data like bodies. */
27
- headers: Record<string, string>
28
- body: Buffer
29
- }
30
-
31
- export interface RecordingEndpoint {
32
- readonly url: string
33
- /** Synthetic private captures only: never include these in assertions/log output. */
34
- captures(): Capture[]
35
- /** parsed HTTP requests; malformed connections are counted separately. */
36
- counts(): { attempted: number; rejected: number; malformed: number; connections: number; openConnections: number; pendingRequests: number }
37
- close(): Promise<void>
38
- }
39
-
40
- const bounded = (value: number, maximum: number) => Number.isSafeInteger(value) && value > 0 && value <= maximum
41
-
42
- function dataRecord(value: unknown, keys: readonly string[]): Record<string, unknown> {
43
- if (typeof value !== "object" || value === null || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) throw 0
44
- const ownKeys = Reflect.ownKeys(value)
45
- if (ownKeys.length > keys.length) throw 0
46
- const result: Record<string, unknown> = Object.create(null)
47
- for (const key of ownKeys) {
48
- if (typeof key !== "string" || !keys.includes(key)) throw 0
49
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
50
- if (!descriptor || !("value" in descriptor)) throw 0
51
- result[key] = descriptor.value
52
- }
53
- return result
54
- }
55
-
56
- function parseOptions(input: unknown): Options {
57
- try {
58
- const options = dataRecord(input, ["responses", "resolve", "maxRequests", "maxBodyBytes", "timeoutMs"])
59
- if (!Array.isArray(options.responses) || options.responses.length > 64) throw 0
60
- if (options.resolve !== undefined && typeof options.resolve !== "function") throw 0
61
- for (const key of ["maxRequests", "maxBodyBytes", "timeoutMs"]) {
62
- if (options[key] !== undefined && typeof options[key] !== "number") throw 0
63
- }
64
- const responses: ScriptedResponse[] = []
65
- for (let index = 0; index < options.responses.length; index++) {
66
- const descriptor = Object.getOwnPropertyDescriptor(options.responses, String(index))
67
- if (!descriptor || !("value" in descriptor)) throw 0
68
- const response = dataRecord(descriptor.value, ["status", "body", "contentType"])
69
- if (typeof response.status !== "number" || typeof response.body !== "string" ||
70
- (response.contentType !== undefined && typeof response.contentType !== "string")) throw 0
71
- responses.push(response as unknown as ScriptedResponse)
72
- }
73
- return { ...options, responses } as unknown as Options
74
- } catch { throw new Error("FIXTURE_INVALID_OPTIONS") }
75
- }
76
-
77
- /** Test-only loopback HTTP recorder, not a model implementation or TLS connector. */
78
- export async function startRecordingEndpoint(input: unknown): Promise<RecordingEndpoint> {
79
- const options = parseOptions(input)
80
- const maxRequests = options.maxRequests ?? 16
81
- const maxBodyBytes = options.maxBodyBytes ?? 65536
82
- const timeoutMs = options.timeoutMs ?? 5000
83
- if (!bounded(maxRequests, 64) || !bounded(maxBodyBytes, 1_048_576) || !bounded(timeoutMs, 30_000) ||
84
- options.responses.length > maxRequests || options.responses.some((response) =>
85
- !Number.isInteger(response.status) || response.status < 200 || response.status > 599 ||
86
- Buffer.byteLength(response.body) > 1_048_576 ||
87
- (response.contentType !== undefined && !["application/json", "text/event-stream", "text/plain"].includes(response.contentType))) ||
88
- options.responses.reduce((total, response) => total + Buffer.byteLength(response.body), 0) > 2_097_152) {
89
- throw new Error("FIXTURE_INVALID_OPTIONS")
90
- }
91
- const responses = options.responses.map((response) => ({ ...response }))
92
- const captures: Capture[] = []
93
- const pending = new Set<Buffer[]>()
94
- const sockets = new Set<Socket>()
95
- const deadlines = new Set<ReturnType<typeof setTimeout>>()
96
- let attempted = 0
97
- let rejected = 0
98
- let malformed = 0
99
- let connections = 0
100
- let closing = false
101
- let closePromise: Promise<void> | undefined
102
- let checkClosed = () => {}
103
- const server = createServer({ maxHeaderSize: 8192, requestTimeout: timeoutMs, headersTimeout: timeoutMs }, (request, response) => {
104
- const sequence = attempted++
105
- const resolver = options.resolve
106
- if (closing || sequence >= maxRequests || (resolver === undefined && !responses[sequence])) {
107
- rejected++
108
- response.writeHead(503, { connection: "close" })
109
- response.end("FIXTURE_UNSCRIPTED_REQUEST")
110
- return
111
- }
112
-
113
- const chunks: Buffer[] = []
114
- pending.add(chunks)
115
- let bytes = 0
116
- let settled = false
117
- const discard = () => {
118
- for (const chunk of chunks) chunk.fill(0)
119
- chunks.length = 0
120
- pending.delete(chunks)
121
- checkClosed()
122
- }
123
- const fail = (status: number) => {
124
- if (settled) return
125
- settled = true
126
- rejected++
127
- clearTimeout(deadline)
128
- deadlines.delete(deadline)
129
- discard()
130
- response.writeHead(status, { connection: "close" })
131
- response.end("FIXTURE_REQUEST_REJECTED")
132
- }
133
- const deadline = setTimeout(() => fail(408), timeoutMs)
134
- deadlines.add(deadline)
135
- deadline.unref()
136
- request.on("data", (chunk: Buffer) => {
137
- if (settled || closing) return
138
- bytes += chunk.length
139
- if (bytes > maxBodyBytes) {
140
- fail(413)
141
- return
142
- }
143
- chunks.push(Buffer.from(chunk))
144
- })
145
- request.on("end", () => {
146
- if (settled || closing) return
147
- settled = true
148
- clearTimeout(deadline)
149
- deadlines.delete(deadline)
150
- const body = Buffer.concat(chunks, bytes)
151
- discard()
152
- const headers: Record<string, string> = {}
153
- for (const [name, value] of Object.entries(request.headers)) {
154
- if (value === undefined) continue
155
- headers[name] = Array.isArray(value) ? value.join(", ") : value
156
- }
157
- captures.push({ method: request.method ?? "", target: request.url ?? "", headers, body })
158
- let chosen = sequence
159
- if (resolver !== undefined) {
160
- try {
161
- chosen = resolver({ sequence, method: request.method ?? "", target: request.url ?? "", headers, body })
162
- } catch {
163
- chosen = -1
164
- }
165
- if (!Number.isSafeInteger(chosen)) chosen = -1
166
- }
167
- const scripted = responses[chosen]
168
- if (!scripted) {
169
- rejected++
170
- response.writeHead(503, { connection: "close" })
171
- response.end("FIXTURE_UNSCRIPTED_REQUEST")
172
- return
173
- }
174
- response.writeHead(scripted.status, { "content-type": scripted.contentType ?? "text/plain", connection: "close" })
175
- response.end(scripted.body)
176
- })
177
- request.on("error", () => fail(400))
178
- request.on("close", () => {
179
- clearTimeout(deadline)
180
- deadlines.delete(deadline)
181
- if (!settled) { settled = true; rejected++ }
182
- discard()
183
- })
184
- })
185
- server.maxConnections = maxRequests
186
- server.setTimeout(timeoutMs, (socket) => socket.destroy())
187
- server.on("clientError", (_error, socket) => { malformed++; socket.destroy() })
188
- server.on("connection", (socket) => {
189
- connections++
190
- sockets.add(socket)
191
- socket.once("close", () => { sockets.delete(socket); checkClosed() })
192
- if (closing) socket.destroy()
193
- })
194
-
195
- await new Promise<void>((resolve, reject) => {
196
- server.once("error", () => reject(new Error("FIXTURE_LISTEN_FAILED")))
197
- server.listen(0, "127.0.0.1", () => resolve())
198
- })
199
- const address = server.address()
200
- if (!address || typeof address === "string") {
201
- server.closeAllConnections()
202
- server.close()
203
- throw new Error("FIXTURE_LISTEN_FAILED")
204
- }
205
- return {
206
- url: `http://127.0.0.1:${address.port}`,
207
- captures: () => captures.map((capture) => ({ ...capture, headers: { ...capture.headers }, body: Buffer.from(capture.body) })),
208
- counts: () => ({ attempted, rejected, malformed, connections, openConnections: sockets.size, pendingRequests: pending.size }),
209
- close: () => {
210
- if (closePromise) return closePromise
211
- closing = true
212
- closePromise = new Promise<void>((resolve, reject) => {
213
- let serverClosed = false
214
- const deadline = setTimeout(() => reject(new Error("FIXTURE_CLOSE_FAILED")), 5000)
215
- const discard = () => {
216
- for (const timer of deadlines) clearTimeout(timer)
217
- deadlines.clear()
218
- for (const capture of captures) capture.body.fill(0)
219
- captures.length = 0
220
- for (const chunks of pending) {
221
- for (const chunk of chunks) chunk.fill(0)
222
- chunks.length = 0
223
- }
224
- pending.clear()
225
- }
226
- checkClosed = () => {
227
- if (!serverClosed || sockets.size > 0) return
228
- discard()
229
- clearTimeout(deadline)
230
- resolve()
231
- }
232
- discard()
233
- server.close(() => {
234
- serverClosed = true
235
- checkClosed()
236
- })
237
- for (const socket of sockets) socket.destroy()
238
- })
239
- return closePromise
240
- },
241
- }
242
- }
@@ -1,179 +0,0 @@
1
- import { execFile } from "node:child_process"
2
- import { constants } from "node:fs"
3
- import { chmod, lstat, mkdir, open, readFile, readdir, rm, rmdir } from "node:fs/promises"
4
- import { promisify } from "node:util"
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
6
-
7
- const execFileAsync = promisify(execFile)
8
- const MARKER_NAME = ".opencode-secrets-obfuscation-owned"
9
- const MARKER = "opencode-secrets-obfuscation run-directory v1\n"
10
-
11
- export interface RunDirectory {
12
- readonly path: string
13
- readonly marker: string
14
- cleanup(): Promise<void>
15
- verifyPrivateChild(path: string): Promise<void>
16
- }
17
-
18
- function fail(code: string): Error { return new Error(code) }
19
-
20
- async function validateAncestors(path: string): Promise<string[]> {
21
- let current = resolve(path)
22
- const missing: string[] = []
23
- for (;;) {
24
- const stat = await lstat(current).catch(() => undefined)
25
- if (stat) {
26
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw fail("RUN_DIRECTORY_UNSAFE_ROOT")
27
- if (process.platform !== "win32") {
28
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined
29
- const mode = stat.mode & 0o7777
30
- // Root-owned ancestors are normal on Linux. Ownership of the newly
31
- // created run directory is checked separately and must be current UID.
32
- if (uid !== undefined && stat.uid !== uid && stat.uid !== 0) throw fail("RUN_DIRECTORY_UNSAFE_ROOT")
33
- if ((mode & 0o022) !== 0 && (mode & 0o1000) === 0) throw fail("RUN_DIRECTORY_UNSAFE_ROOT")
34
- }
35
- } else {
36
- missing.push(current)
37
- }
38
- const parent = dirname(current)
39
- if (parent === current) return missing.reverse()
40
- current = parent
41
- }
42
- }
43
-
44
- function windowsTool(name: string): string {
45
- const systemRoot = process.env.SystemRoot
46
- if (typeof systemRoot !== "string" || !isAbsolute(systemRoot)) throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
47
- return join(systemRoot, "System32", name)
48
- }
49
-
50
- // Windows PowerShell 5.1 must not inherit PowerShell 7's PSModulePath: it can
51
- // contain a WindowsApps path with a PS7 Microsoft.PowerShell.Security copy that
52
- // shadows the system module, making Get-Acl/Set-Acl autoload fail
53
- // (CouldNotAutoloadMatchingModule). Strip it so powershell.exe computes its own
54
- // canonical module path.
55
- function windowsPowerShellEnv(): NodeJS.ProcessEnv {
56
- const env: NodeJS.ProcessEnv = { ...process.env }
57
- delete env.PSModulePath
58
- delete env.psmodulepath
59
- return env
60
- }
61
-
62
- async function windowsSid(): Promise<string> {
63
- try {
64
- const result = await execFileAsync(windowsTool("whoami.exe"), ["/user"], { windowsHide: true, maxBuffer: 4096 })
65
- const sid = result.stdout.match(/S-\d-(?:\d+-){1,14}\d+/i)?.[0]
66
- if (!sid) throw new Error("missing sid")
67
- return sid
68
- } catch {
69
- throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
70
- }
71
- }
72
-
73
- async function windowsRules(path: string): Promise<Array<{ Id: string; Type: string; Rights: string; Inherited: boolean }>> {
74
- try {
75
- const encodedPath = Buffer.from(path, "utf8").toString("base64")
76
- const script = `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}')); $a=Get-Acl -LiteralPath $p; $r=@($a.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]) | ForEach-Object { [pscustomobject]@{Id=$_.IdentityReference.Value;Type=$_.AccessControlType.ToString();Rights=$_.FileSystemRights.ToString();Inherited=$_.IsInherited} }); [pscustomobject]@{Rules=$r}|ConvertTo-Json -Compress`
77
- const command = Buffer.from(script, "utf16le").toString("base64")
78
- const result = await execFileAsync(windowsTool("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoProfile", "-NonInteractive", "-EncodedCommand", command], { windowsHide: true, maxBuffer: 16384, env: windowsPowerShellEnv() })
79
- const parsed = JSON.parse(result.stdout) as { Rules?: Array<{ Id: string; Type: string; Rights: string; Inherited: boolean }> | { Id: string; Type: string; Rights: string; Inherited: boolean } }
80
- if (Array.isArray(parsed.Rules)) return parsed.Rules
81
- return parsed.Rules ? [parsed.Rules] : []
82
- } catch {
83
- throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
84
- }
85
- }
86
-
87
- async function verifyPrivate(path: string, sid?: string, root = false): Promise<void> {
88
- const stat = await lstat(path).catch(() => undefined)
89
- if (!stat?.isDirectory() || stat.isSymbolicLink()) throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
90
- if (process.platform !== "win32") {
91
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined
92
- if ((stat.mode & 0o777) !== 0o700 || (uid !== undefined && !root && stat.uid !== uid)) throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
93
- return
94
- }
95
- const rules = await windowsRules(path)
96
- if (!sid || rules.length !== 1 || rules[0]?.Id.toUpperCase() !== sid.toUpperCase() || rules[0]?.Type !== "Allow" || rules[0]?.Rights !== "FullControl" || (root && rules[0]?.Inherited)) {
97
- throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
98
- }
99
- }
100
-
101
- async function makePrivateNew(path: string, sid?: string): Promise<void> {
102
- if (process.platform !== "win32") {
103
- try {
104
- await chmod(path, 0o700)
105
- await verifyPrivate(path)
106
- } catch { throw fail("RUN_DIRECTORY_PERMISSION_FAILED") }
107
- return
108
- }
109
- const currentSid = sid ?? await windowsSid()
110
- try {
111
- const encodedPath = Buffer.from(path, "utf8").toString("base64")
112
- const encodedSid = Buffer.from(currentSid, "utf8").toString("base64")
113
- const script = `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}')); $s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedSid}')); $a=Get-Acl -LiteralPath $p; $a.SetAccessRuleProtection($true,$false); foreach($x in @($a.Access)){ [void]$a.RemoveAccessRule($x) }; $id=New-Object System.Security.Principal.SecurityIdentifier($s); $f=[System.Security.AccessControl.FileSystemAccessRule]::new($id,[System.Security.AccessControl.FileSystemRights]::FullControl,([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow); [void]$a.AddAccessRule($f); Set-Acl -LiteralPath $p -AclObject $a`
114
- const command = Buffer.from(script, "utf16le").toString("base64")
115
- await execFileAsync(windowsTool("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoProfile", "-NonInteractive", "-EncodedCommand", command], { windowsHide: true, maxBuffer: 16384, env: windowsPowerShellEnv() })
116
- await verifyPrivate(path, currentSid)
117
- } catch { throw fail("RUN_DIRECTORY_PERMISSION_FAILED") }
118
- }
119
-
120
- async function assertOwned(path: string, marker: string): Promise<void> {
121
- const stat = await lstat(path).catch(() => undefined)
122
- if (!stat?.isDirectory() || stat.isSymbolicLink()) throw fail("RUN_DIRECTORY_NOT_OWNED")
123
- const markerStat = await lstat(marker).catch(() => undefined)
124
- if (!markerStat?.isFile() || markerStat.isSymbolicLink() || markerStat.nlink !== 1) throw fail("RUN_DIRECTORY_NOT_OWNED")
125
- if ((await readFile(marker, "utf8")) !== MARKER) throw fail("RUN_DIRECTORY_NOT_OWNED")
126
- }
127
-
128
- async function cleanupEmptyOwned(path: string, marker: string): Promise<void> {
129
- await assertOwned(path, marker)
130
- const entries = await readdir(path)
131
- if (entries.length !== 1 || entries[0] !== MARKER_NAME) throw fail("RUN_DIRECTORY_CONTENT_UNSAFE")
132
- await rm(marker)
133
- await rmdir(path)
134
- }
135
-
136
- export async function createRunDirectory(rootInput: string): Promise<RunDirectory> {
137
- if (!isAbsolute(rootInput)) throw fail("RUN_DIRECTORY_ROOT_NOT_ABSOLUTE")
138
- const root = resolve(rootInput)
139
- const missing = await validateAncestors(root)
140
- const existing = await lstat(root).catch(() => undefined)
141
- const sid = process.platform === "win32" ? await windowsSid() : undefined
142
- for (const directory of missing) {
143
- await mkdir(directory, { mode: 0o700 }).catch(() => { throw fail("RUN_DIRECTORY_CREATE_FAILED") })
144
- await makePrivateNew(directory, sid)
145
- }
146
- if (existing) {
147
- await verifyPrivate(root, sid, true)
148
- }
149
- let path: string | undefined
150
- for (let attempt = 0; attempt < 10 && !path; attempt += 1) {
151
- const candidate = join(root, `run-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`)
152
- try { await mkdir(candidate, { mode: 0o700 }); path = candidate } catch (error) {
153
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw fail("RUN_DIRECTORY_CREATE_FAILED")
154
- }
155
- }
156
- if (!path) throw fail("RUN_DIRECTORY_CREATE_FAILED")
157
- const marker = join(path, MARKER_NAME)
158
- try {
159
- await makePrivateNew(path, sid)
160
- const handle = await open(marker, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
161
- await handle.writeFile(MARKER, "utf8")
162
- await handle.close()
163
- await assertOwned(path, marker)
164
- } catch {
165
- await rmdir(path).catch(() => undefined)
166
- throw fail("RUN_DIRECTORY_PERMISSION_FAILED")
167
- }
168
- return {
169
- path,
170
- marker,
171
- cleanup: () => cleanupEmptyOwned(path!, marker),
172
- async verifyPrivateChild(childPath: string) {
173
- const child = resolve(childPath)
174
- const childRelative = relative(path!, child)
175
- if (isAbsolute(childRelative) || childRelative === ".." || childRelative.startsWith(`..${sep}`)) throw fail("RUN_DIRECTORY_UNSAFE_ROOT")
176
- await verifyPrivate(child, sid)
177
- },
178
- }
179
- }