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