From e7aad6e741a97d399b7a3084f8e19b4f34228195 Mon Sep 17 00:00:00 2001 From: KevinTen <22876610+kevinten10@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:01:34 +0800 Subject: [PATCH 1/2] fix: redact provider failure data and verify public commit identities --- CHANGELOG.md | 8 +- CONTRIBUTING.md | 5 + DOCS-INDEX.md | 6 +- SECURITY.md | 19 ++- docs/public-safety.md | 62 ++++++++ docs/testing.md | 10 +- package.json | 1 + scripts/check-identity.mjs | 34 ++++ spec/protocol.md | 9 +- src/conformance.ts | 6 +- src/runtime.ts | 43 +++-- tests/commit-identity.test.ts | 95 +++++++++++ tests/provider-data-safety.test.ts | 248 +++++++++++++++++++++++++++++ tests/runtime.test.ts | 2 +- 14 files changed, 525 insertions(+), 23 deletions(-) create mode 100644 docs/public-safety.md create mode 100644 scripts/check-identity.mjs create mode 100644 tests/commit-identity.test.ts create mode 100644 tests/provider-data-safety.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf10c3a..4107ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## Unreleased -No changes queued. +- replace Provider exception details and stored failure messages with fixed public messages, preserve + legal portable error codes, and normalize arbitrary failure codes; +- suppress Provider exception details in conformance reports without losing runner deadline evidence; +- reject embedded credentials in Provider endpoint URLs; +- add synthetic HTTP/SDK/resource-read regressions and a value-withholding commit-identity gate; +- document the difference between content scans, commit-identity checks, explicit legacy baselines, + and authorized history remediation. The existing `v0.1.1` archive remains unchanged. ## 0.1.1 - 2026-10-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ebfcae..144cd9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,3 +31,8 @@ an independent design rationale. Do not include proprietary code, private infras credentials, user data, or unpublished behavior. Contributions are accepted under the Apache License 2.0. + +Before pushing and after merging, review [public-safety guidance](docs/public-safety.md) and run the +commit-identity gate against the declared candidate range. Configure an approved public name and +GitHub noreply email. For squash merges, pass an explicit approved author email and verify the actual +merged commit; local Git configuration alone does not control GitHub merge authorship. diff --git a/DOCS-INDEX.md b/DOCS-INDEX.md index c8374cf..46e22da 100644 --- a/DOCS-INDEX.md +++ b/DOCS-INDEX.md @@ -4,7 +4,7 @@ authority: canonical status: canonical title: Sandbox Runtime API documentation index genre: spec -last_verified: 2026-10-09 +last_verified: 2026-10-13 --- # Documentation Index @@ -15,6 +15,9 @@ The `v0.1.1` delivery implements the `0.1` contract. It owns portable lifecycle, command and file operations, event replay, the TypeScript SDK, the loopback-only reference server, the unsafe Local Provider, and provider conformance. +Post-`v0.1.1` security fixes are tracked under [Unreleased](CHANGELOG.md). The development source's +Provider data-minimization behavior is not proof that an older release archive contains the fix. + Real cloud providers, production multi-tenancy, strong isolation, durable storage, authentication, network policy enforcement, snapshots, PTY, and port forwarding remain outside the completed MVP. @@ -37,6 +40,7 @@ network policy enforcement, snapshots, PTY, and port forwarding remain outside t - [Testing and conformance](docs/testing.md) - [Release archive and acceptance map](docs/delivery.md) - [Clean-room policy](docs/clean-room-policy.md) +- [Public safety and commit identity](docs/public-safety.md) - [Visual source and validation register](docs/visuals/README.md) ## Process and release evidence diff --git a/SECURITY.md b/SECURITY.md index 986e941..3863eb7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ authority: canonical status: canonical title: Security policy and deployment boundary genre: spec -last_verified: 2026-10-09 +last_verified: 2026-10-13 --- # Security Policy @@ -37,6 +37,23 @@ still use the server. Do not expose it through a public reverse proxy. The ratio on simple content types and complete-origin comparison, plus independent authority validation for a directly accessed development server. +## Provider error and metadata boundary + +The development source after `v0.1.1` replaces Provider exception details with fixed public messages. +Legal portable error codes remain available to callers; arbitrary Provider failure codes normalize to +`provider_unavailable`. Failure messages stored on resources are also fixed, so later list/get and +SDK calls do not reveal the original exception. Conformance reports suppress Provider exception +details while retaining runner-generated validation and deadline messages. This fix is not present +in the original `v0.1.1` archive; see the [changelog](CHANGELOG.md) for release status. + +Provider endpoints must not contain URL userinfo credentials. Provider extensions, manifest fields, +endpoint paths/query parameters, command output, and file bytes are intentionally returned data, not +automatic secret-redaction surfaces. Providers must publish only approved metadata; do not put secrets +in extensions or endpoint URLs. Namespacing does not provide redaction. Diagnostics belong in a +separately authorized, redacted Provider-side channel; the reference runtime does not log raw errors. + +See [public-safety guidance](docs/public-safety.md) for content and commit-identity checks. + ## Deployment boundary The reference runtime and mock provider are development implementations. They do not provide: diff --git a/docs/public-safety.md b/docs/public-safety.md new file mode 100644 index 0000000..25dcf22 --- /dev/null +++ b/docs/public-safety.md @@ -0,0 +1,62 @@ +--- +id: public-safety +authority: reference +status: active +title: Public safety and commit identity +genre: how-to +last_verified: 2026-10-13 +--- + +# Public Safety and Commit Identity + +## Separate checks + +`pnpm sanitize` scans source, available Git patch history and image trace metadata for credential +shapes, private paths and dangerous files. It is not a full semantic, personal-identity or forensic +history audit. Synthetic tests and approved public identities still need human review. + +`pnpm identity:check` checks author and committer email formats in all commits reachable from `HEAD`. +It accepts GitHub noreply formats, prints commit IDs and field names only, and fails closed when Git +or the requested range is unavailable. It checks format, not authenticated identity or approved names. +Known organization fingerprints and person names require a separate private, value-withholding audit. + +## Candidate range gate + +Existing historical identity findings require an explicit disposition. Do not silently exempt or +rewrite them. When the owner approves retaining a historical baseline, verify every new commit: + +```bash +pnpm identity:check -- --since +pnpm sanitize +``` + +The baseline must exist and be an ancestor of `HEAD`; invalid refs fail closed. A green range check +proves only that range's author/committer email formats, not that older history has been scrubbed. +Shallow CI clones may not have the baseline. Run against a complete owning clone before push and +again on the actual merged commit; synthetic CI merge identities are not a substitute. + +Use an approved public name and GitHub noreply email in local configuration. For GitHub squash merges, +the author email is independent of local configuration. Pass an explicit approved address: + +```bash +gh pr merge --squash --match-head-commit \ + --author-email +``` + +Inspect the merged commit with the range gate after merge. Never paste personal email values into a +public finding, report or fixture; use dynamic synthetic values and reserved example identities. + +## Historical remediation + +Commit history, released tags, open/closed PR refs, forks, caches and release metadata are distinct +surfaces. Ordinary new commits and `.mailmap` do not remove the original objects. Prepare exact ref +and tag mappings, backup/recovery, clone migration, release/source links and cache limitations before +requesting approval for rewriting. Force-pushes, tag moves, repository deletion and support removal +are not authorized by a generic code-fix request. Never claim all public copies have disappeared. + +## Runtime data boundary + +Provider exceptions and failure observations use fixed messages in the post-`v0.1.1` development +source; see [SECURITY](../SECURITY.md) for release status and trusted metadata responsibilities. +Intentional command output/file content is not rewritten. Local processes still run with host-user +permissions. Do not supply real secrets or untrusted code to development fixtures. diff --git a/docs/testing.md b/docs/testing.md index fa72624..511cf9e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ authority: reference status: active title: Testing and provider conformance genre: how-to -last_verified: 2026-10-09 +last_verified: 2026-10-13 --- # Testing and Provider Conformance @@ -37,6 +37,14 @@ ripgrep from `PATH` and verify that detection still blocks publication without p ## Test dimensions +Provider data-safety regressions cover ordinary/typed/non-Error exceptions at every runtime entry, +stored create/recreate/terminate failures, get/list/events and SDK reads, returned observation codes, +conformance output, endpoint userinfo, and unchanged intentional command output. Commit-identity +fixtures separately verify author and committer emails, complete candidate ranges, explicit legacy +baselines, and fail-closed behavior without printing matched values. Before pushing and after merging, +run the [identity gate](public-safety.md) against the exact candidate range; ordinary content scanning +is not an identity audit. + | Dimension | Coverage | | --- | --- | | Unit and boundary | validation, lifecycle, errors, event cursors | diff --git a/package.json b/package.json index 5366fb1..3ec25e5 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "build": "tsc -p tsconfig.build.json", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm docs:check && pnpm build && pnpm test:coverage && pnpm pack:check && pnpm audit:dependencies", "docs:check": "node scripts/check-docs.mjs", + "identity:check": "node scripts/check-identity.mjs", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint .", diff --git a/scripts/check-identity.mjs b/scripts/check-identity.mjs new file mode 100644 index 0000000..0d82e92 --- /dev/null +++ b/scripts/check-identity.mjs @@ -0,0 +1,34 @@ +import { execFileSync } from 'node:child_process' + +const arguments_ = process.argv.slice(2) +const git = (args) => + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +const isPublicEmail = (value) => + /^[A-Za-z0-9_.+\-[\]]+@users\.noreply\.github\.com$|^noreply@github\.com$/.test(value) +try { + if (arguments_.length !== 0 && (arguments_.length !== 2 || arguments_[0] !== '--since')) + throw new Error('invalid arguments') + const head = git(['rev-parse', '--verify', 'HEAD^{commit}']) + let range = head + if (arguments_.length) { + const base = git(['rev-parse', '--verify', '--end-of-options', `${arguments_[1]}^{commit}`]) + git(['merge-base', '--is-ancestor', base, head]) + range = `${base}..${head}` + } + const records = git(['log', '--format=%H%x00%ae%x00%ce', range]).split('\n').filter(Boolean) + const hits = [] + for (const record of records) { + const [commit, author, committer] = record.split('\0') + if (!isPublicEmail(author)) hits.push({ commit, field: 'author email' }) + if (!isPublicEmail(committer)) hits.push({ commit, field: 'committer email' }) + } + if (hits.length) { + process.stderr.write( + `non-public commit identity format detected; values withheld\n${hits.map(({ commit, field }) => `${commit}: ${field}`).join('\n')}\n`, + ) + process.exitCode = 1 + } else process.stdout.write(`commit identity check passed: ${records.length} commits\n`) +} catch { + process.stderr.write('commit identity check could not complete; refusing a clean result\n') + process.exitCode = 2 +} diff --git a/spec/protocol.md b/spec/protocol.md index b75a8e9..de1380c 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -4,7 +4,7 @@ authority: canonical status: canonical title: Portable protocol semantics genre: spec -last_verified: 2026-09-02 +last_verified: 2026-10-13 --- # Portable Protocol Semantics @@ -25,6 +25,13 @@ The normative HTTP projection uses JSON under `/v1`. Errors use: The stable machine field is `error.code`. Human-readable messages may improve without a protocol version change. +The reference runtime uses fixed public messages for Provider exceptions and stored failure +observations. Only portable error codes from the published vocabulary are retained; other Provider +failure codes map to `provider_unavailable`. Clients must branch on codes, not diagnostic text. +Raw Provider diagnostics and credentials must not be placed in returned metadata or failure fields. +Endpoint URLs containing userinfo credentials are rejected. Intentionally returned command output +and file bytes are not rewritten by this policy. + ## Lifecycle Create is idempotent by `clientRequestId`. Concurrent identical requests converge to one logical diff --git a/src/conformance.ts b/src/conformance.ts index ccbe778..77634bb 100644 --- a/src/conformance.ts +++ b/src/conformance.ts @@ -87,7 +87,11 @@ const callBefore = async ( let timer: ReturnType | undefined try { return await Promise.race([ - Promise.resolve().then(() => operation({ requestId, signal: controller.signal })), + Promise.resolve() + .then(() => operation({ requestId, signal: controller.signal })) + .catch(() => { + throw new Error('provider operation failed') + }), new Promise((_resolve, reject) => { timer = setTimeout(() => { reject(new Error(timeoutMessage)) diff --git a/src/runtime.ts b/src/runtime.ts index f55c0f5..590c6fa 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -12,6 +12,8 @@ import { capabilityNames, type ProviderManifest, RuntimeError, + runtimeErrorCodes, + type RuntimeErrorCode, type RuntimeInfo, type SandboxEvent, type SandboxEventType, @@ -50,6 +52,10 @@ const canonicalize = (value: unknown): unknown => { } const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value)) +const publicProviderErrorCode = (value: unknown): RuntimeErrorCode => + runtimeErrorCodes.includes(value as RuntimeErrorCode) + ? (value as RuntimeErrorCode) + : 'provider_unavailable' const extensionKeyPattern = /^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9_.-]*$/ const isJsonValue = (value: unknown, seen = new WeakSet()): value is JsonValue => { @@ -245,7 +251,7 @@ export class InMemorySandboxRuntime { } catch (error) { this.#writeObservation(id, { state: 'failed', - failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, + failure: { code: 'provider_unavailable', message: 'provider operation failed' }, }) throw this.#providerError('terminate', error) } @@ -359,10 +365,10 @@ export class InMemorySandboxRuntime { { sandboxId: id, generation: next.generation, spec }, context, ) - } catch (error) { + } catch { return this.#writeObservation(id, { state: 'failed', - failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, + failure: { code: 'provider_unavailable', message: 'provider operation failed' }, }) } return this.#writeObservation(id, observation) @@ -396,7 +402,6 @@ export class InMemorySandboxRuntime { }) return result } catch (error) { - if (error instanceof RuntimeError) throw error throw this.#providerError('execute', error) } })() @@ -528,10 +533,10 @@ export class InMemorySandboxRuntime { { sandboxId: id, generation: requested.generation, spec }, context, ) - } catch (error) { + } catch { return this.#writeObservation(id, { state: 'failed', - failure: { code: 'provider_unavailable', message: this.#errorMessage(error) }, + failure: { code: 'provider_unavailable', message: 'provider operation failed' }, }) } return this.#writeObservation(id, observation) @@ -563,7 +568,11 @@ export class InMemorySandboxRuntime { if (observation.endpoint) { try { const endpoint = new URL(observation.endpoint.baseUrl) - if (!['http:', 'https:'].includes(endpoint.protocol)) + if ( + !['http:', 'https:'].includes(endpoint.protocol) || + endpoint.username || + endpoint.password + ) throw new Error('unsupported protocol') } catch { throw new RuntimeError('provider_protocol_error', 'provider endpoint must be an HTTP URL') @@ -572,7 +581,7 @@ export class InMemorySandboxRuntime { if (!transitions[current.state].includes(observation.state)) { throw new RuntimeError( 'provider_protocol_error', - `provider attempted invalid transition ${current.state} -> ${observation.state}`, + 'provider attempted an invalid state transition', ) } const endpoint = observation.endpoint ?? current.endpoint @@ -587,7 +596,14 @@ export class InMemorySandboxRuntime { updatedAt: this.#clock().toISOString(), ...(observation.state !== 'terminated' && endpoint ? { endpoint } : {}), ...(providerExtensions ? { providerExtensions } : {}), - ...(observation.failure ? { failure: observation.failure } : {}), + ...(observation.failure + ? { + failure: { + code: publicProviderErrorCode(observation.failure.code), + message: 'provider operation failed', + }, + } + : {}), } this.#resources.set(id, updated) if (current.state !== updated.state) { @@ -868,15 +884,10 @@ export class InMemorySandboxRuntime { } } - #errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'provider failed without an error' - } - #providerError(operation: string, error: unknown): RuntimeError { - if (error instanceof RuntimeError) return error return new RuntimeError( - 'provider_unavailable', - `provider ${operation} failed: ${this.#errorMessage(error)}`, + error instanceof RuntimeError ? publicProviderErrorCode(error.code) : 'provider_unavailable', + `provider ${operation} failed`, ) } diff --git a/tests/commit-identity.test.ts b/tests/commit-identity.test.ts new file mode 100644 index 0000000..4abd990 --- /dev/null +++ b/tests/commit-identity.test.ts @@ -0,0 +1,95 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const script = resolve('scripts/check-identity.mjs') +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) +const fixture = async () => { + const root = await mkdtemp(join(tmpdir(), 'sandbox-identity-test-')) + roots.push(root) + execFileSync('git', ['init', '--quiet'], { cwd: root }) + return root +} +const commit = (root: string, author: string, committer = author) => { + execFileSync( + 'git', + [ + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + 'commit', + '--allow-empty', + '--quiet', + '-m', + 'synthetic identity fixture', + ], + { + cwd: root, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: author, + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: committer, + }, + }, + ) + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() +} +const scan = (root: string, args: string[] = []) => + spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8', timeout: 5_000 }) +const publicEmail = '12345+fixture@users.noreply.github.com' + +describe('public commit identity gate', () => { + it('accepts public noreply authors and GitHub merge committers', async () => { + const root = await fixture() + commit(root, publicEmail, 'noreply@github.com') + expect(scan(root).status).toBe(0) + }) + it.each(['author', 'committer'])( + 'rejects a non-noreply %s without printing values', + async (field) => { + const root = await fixture() + const synthetic = 'private-fixture@example.test' + commit( + root, + field === 'author' ? synthetic : publicEmail, + field === 'committer' ? synthetic : publicEmail, + ) + const result = scan(root) + expect(result.status).toBe(1) + expect(result.stderr).toContain(`${field} email`) + expect(result.stderr).not.toContain(synthetic) + }, + ) + it('checks every commit in an explicit range, not just its head', async () => { + const root = await fixture() + const base = commit(root, publicEmail) + commit(root, 'private-fixture@example.test') + commit(root, publicEmail) + expect(scan(root, ['--since', base]).status).toBe(1) + expect(scan(root).status).toBe(1) + }) + it('keeps a declared legacy baseline explicit without claiming history is scrubbed', async () => { + const root = await fixture() + const base = commit(root, 'legacy-fixture@example.test') + commit(root, publicEmail) + const result = scan(root, ['--since', base]) + expect(result.status).toBe(0) + expect(result.stdout).toContain('1 commits') + expect(scan(root).status).toBe(1) + }) + it('fails closed for a missing repository or invalid baseline', async () => { + const root = await fixture() + expect(scan(root).status).toBe(2) + commit(root, publicEmail) + expect(scan(root, ['--since', 'missing-ref']).status).toBe(2) + expect(scan(root, ['--unknown']).status).toBe(2) + }) +}) diff --git a/tests/provider-data-safety.test.ts b/tests/provider-data-safety.test.ts new file mode 100644 index 0000000..20321bd --- /dev/null +++ b/tests/provider-data-safety.test.ts @@ -0,0 +1,248 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { + InMemorySandboxRuntime, + MockSandboxProvider, + RuntimeError, + runProviderConformance, + SandboxRuntimeClient, + startSandboxRuntimeServer, + type SandboxRuntimeServerHandle, +} from '../src/index.js' + +const handles: SandboxRuntimeServerHandle[] = [] +afterEach(async () => { + await Promise.all(handles.splice(0).map((handle) => handle.close())) +}) + +const start = async () => { + let fault: { operation: string; value: unknown; observation?: boolean } | undefined + const provider = new Proxy(new MockSandboxProvider(), { + get(target, property) { + if (fault?.operation === property) { + const current = fault + return async () => { + if (current.observation) { + return { state: 'failed', failure: { code: current.value, message: current.value } } + } + throw current.value + } + } + const value = Reflect.get(target, property) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'data-probe' }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + const client = new SandboxRuntimeClient(handle.baseUrl) + return { runtime, provider, client, handle, setFault: (value: typeof fault) => (fault = value) } +} + +const operations = [ + 'describe', + 'observe', + 'terminate', + 'pause', + 'resume', + 'execute', + 'readFile', + 'writeFile', + 'listFiles', +] as const +const invoke = (client: SandboxRuntimeClient, operation: (typeof operations)[number]) => { + switch (operation) { + case 'describe': + return client.getRuntimeInfo() + case 'observe': + return client.reconcile('data-probe') + case 'terminate': + return client.terminate('data-probe', 1) + case 'pause': + return client.pause('data-probe', 1) + case 'resume': + return client.resume('data-probe', 1) + case 'execute': + return client.execute('data-probe', { expectedGeneration: 1, argv: ['synthetic-noop'] }) + case 'readFile': + return client.readFile('data-probe', 1, 'probe.txt') + case 'writeFile': + return client.writeFile('data-probe', 1, 'probe.txt', Buffer.from('safe').toString('base64')) + case 'listFiles': + return client.listFiles('data-probe', 1) + } +} + +describe('Provider data minimization at HTTP and SDK boundaries', () => { + it.each( + operations.flatMap((operation) => + ['error', 'typed', 'non-error'].map((kind) => ({ operation, kind })), + ), + )('withholds $kind exception details from $operation', async ({ operation, kind }) => { + const { client, runtime, setFault } = await start() + await client.create({ clientRequestId: 'data-create' }) + if (operation === 'resume') await client.pause('data-probe', 1) + const marker = `synthetic-${randomUUID()}` + const error = + kind === 'typed' + ? new RuntimeError('not_found', marker) + : kind === 'error' + ? new Error(marker) + : marker + setFault({ operation, value: error }) + const result = await invoke(client, operation).catch((failure: unknown) => failure) + expect(result).toBeInstanceOf(RuntimeError) + expect(result).toMatchObject({ + code: kind === 'typed' ? 'not_found' : 'provider_unavailable', + message: `provider ${operation} failed`, + }) + expect(JSON.stringify(result)).not.toContain(marker) + expect((result as Error).message).not.toContain(marker) + expect(JSON.stringify(await client.get('data-probe'))).not.toContain(marker) + expect(JSON.stringify(await client.list())).not.toContain(marker) + expect(JSON.stringify(await client.listEvents())).not.toContain(marker) + expect(JSON.stringify(runtime.get('data-probe'))).not.toContain(marker) + }) + + it.each(['create', 'recreate'] as const)( + 'withholds provision exception details from %s and later resource reads', + async (action) => { + const { client, runtime, setFault } = await start() + if (action === 'recreate') { + await client.create({ clientRequestId: 'data-create' }) + await client.terminate('data-probe', 1) + } + const marker = `synthetic-${randomUUID()}` + setFault({ operation: 'provision', value: new Error(marker) }) + const resource = + action === 'create' + ? await client.create({ clientRequestId: 'data-create' }) + : await client.recreate('data-probe', 1, { clientRequestId: 'data-recreate' }) + expect(resource).toMatchObject({ + state: 'failed', + failure: { code: 'provider_unavailable', message: 'provider operation failed' }, + }) + for (const value of [ + resource, + runtime.get('data-probe'), + await client.get('data-probe'), + await client.list(), + await client.listEvents(), + ]) { + expect(JSON.stringify(value)).not.toContain(marker) + } + }, + ) + + it.each(['provision', 'observe', 'terminate', 'pause', 'resume'])( + 'normalizes returned failure code and message from %s', + async (operation) => { + const { client, runtime, setFault } = await start() + if (operation !== 'provision') await client.create({ clientRequestId: 'data-create' }) + if (operation === 'resume') await client.pause('data-probe', 1) + const marker = `synthetic-${randomUUID()}` + setFault({ operation, value: marker, observation: true }) + const resource = + operation === 'provision' + ? await client.create({ clientRequestId: 'data-create' }) + : await invoke(client, operation as (typeof operations)[number]) + expect(resource).toMatchObject({ + state: 'failed', + failure: { code: 'provider_unavailable', message: 'provider operation failed' }, + }) + expect( + JSON.stringify([ + resource, + runtime.get('data-probe'), + await client.get('data-probe'), + await client.listEvents(), + ]), + ).not.toContain(marker) + }, + ) + + it('normalizes an invalid RuntimeError code instead of disclosing its value', async () => { + const { client, setFault } = await start() + await client.create({ clientRequestId: 'data-create' }) + const marker = `synthetic-${randomUUID()}` + const error = new RuntimeError('invalid_request', marker) + Object.defineProperty(error, 'code', { value: marker }) + setFault({ operation: 'execute', value: error }) + await expect(invoke(client, 'execute')).rejects.toMatchObject({ + code: 'provider_unavailable', + message: 'provider execute failed', + }) + }) + + it('keeps intentionally returned command output intact', async () => { + const { client } = await start() + await client.create({ clientRequestId: 'data-create' }) + const marker = `synthetic-${randomUUID()}` + const result = await client.execute('data-probe', { expectedGeneration: 1, argv: [marker] }) + expect(result.stdout).toContain(marker) + }) + + it('preserves a portable failure code but not the Provider message', async () => { + const { client, setFault } = await start() + setFault({ operation: 'provision', value: 'not_found', observation: true }) + expect(await client.create({ clientRequestId: 'data-create' })).toMatchObject({ + state: 'failed', + failure: { code: 'not_found', message: 'provider operation failed' }, + }) + }) + + it('does not reflect an invalid Provider state in an HTTP validation error', async () => { + const marker = `synthetic-${randomUUID()}` + class StateProbe extends MockSandboxProvider { + override async provision() { + return { state: marker as 'ready' } + } + } + const runtime = new InMemorySandboxRuntime(new StateProbe(), { idFactory: () => 'state-probe' }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + const client = new SandboxRuntimeClient(handle.baseUrl) + await expect(client.create({ clientRequestId: 'state-create' })).rejects.toMatchObject({ + code: 'provider_protocol_error', + message: 'provider attempted an invalid state transition', + }) + expect(JSON.stringify(runtime.get('state-probe'))).not.toContain(marker) + }) + + it('rejects credentials embedded in Provider endpoint metadata before storage', async () => { + class EndpointProbe extends MockSandboxProvider { + override async provision() { + return { state: 'ready' as const, endpoint: { baseUrl: 'https://example.test:443/path' } } + } + } + const provider = new EndpointProbe() + const marker = `synthetic-${randomUUID()}` + provider.provision = async () => ({ + state: 'ready', + endpoint: { baseUrl: `https://fixture:${marker}@example.test/path` }, + }) + const runtime = new InMemorySandboxRuntime(provider, { idFactory: () => 'endpoint-probe' }) + await expect( + runtime.create({ clientRequestId: 'endpoint-create' }, { requestId: 'endpoint' }), + ).rejects.toMatchObject({ code: 'provider_protocol_error' }) + expect(JSON.stringify(runtime.get('endpoint-probe'))).not.toContain(marker) + }) + + it.each([...operations, 'provision'])( + 'withholds %s exception details from conformance reports', + async (operation) => { + const { provider, setFault } = await start() + const marker = `synthetic-${randomUUID()}` + setFault({ operation, value: new Error(marker) }) + const results = await runProviderConformance(provider, { + commandRequest: { argv: ['synthetic-noop'] }, + operationTimeoutMs: 100, + cleanupTimeoutMs: 100, + readinessTimeoutMs: 100, + pollIntervalMs: 1, + }) + expect(results.some((result) => !result.passed)).toBe(true) + expect(JSON.stringify(results)).not.toContain(marker) + }, + ) +}) diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 52b8b76..217e5f9 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -684,7 +684,7 @@ describe('provider conformance', () => { await expect(runProviderConformance(unreadable)).resolves.toContainEqual({ name: 'provider manifest is readable', passed: false, - detail: 'offline', + detail: 'provider operation failed', }) const incomplete: SandboxProvider = { From a62fe6558bde8b348efbb2ecb85d07094e66a884 Mon Sep 17 00:00:00 2001 From: KevinTen <22876610+kevinten10@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:02:04 +0800 Subject: [PATCH 2/2] fix: accept pnpm argument separator in identity gate --- scripts/check-identity.mjs | 3 ++- tests/commit-identity.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-identity.mjs b/scripts/check-identity.mjs index 0d82e92..e53dfb7 100644 --- a/scripts/check-identity.mjs +++ b/scripts/check-identity.mjs @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process' -const arguments_ = process.argv.slice(2) +const suppliedArguments = process.argv.slice(2) +const arguments_ = suppliedArguments[0] === '--' ? suppliedArguments.slice(1) : suppliedArguments const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() const isPublicEmail = (value) => diff --git a/tests/commit-identity.test.ts b/tests/commit-identity.test.ts index 4abd990..ca6e2fd 100644 --- a/tests/commit-identity.test.ts +++ b/tests/commit-identity.test.ts @@ -83,6 +83,7 @@ describe('public commit identity gate', () => { const result = scan(root, ['--since', base]) expect(result.status).toBe(0) expect(result.stdout).toContain('1 commits') + expect(scan(root, ['--', '--since', base]).status).toBe(0) expect(scan(root).status).toBe(1) }) it('fails closed for a missing repository or invalid baseline', async () => {