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,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
|
-
}
|