From 0160ed9e6e8de28d43a34b347afa69755f623cb5 Mon Sep 17 00:00:00 2001 From: KevinTen <22876610+kevinten10@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:46:49 +0800 Subject: [PATCH 1/3] feat: finalize v0.1.1 public delivery and security gates --- CHANGELOG.md | 12 +++ DOCS-INDEX.md | 5 +- README.md | 5 +- README.zh-CN.md | 5 +- SECURITY.md | 21 ++++- docs/00-v0.1-summary.md | 4 +- docs/delivery.md | 90 ++++++++++++++++++ docs/providers/local.md | 7 +- docs/quickstart.md | 8 +- docs/testing.md | 7 +- examples/embedded-runtime.mts | 19 ++++ package.json | 7 +- pnpm-lock.yaml | 62 ++++++------- scripts/check-package.mjs | 43 ++++++++- scripts/package-smoke.mjs | 137 +++++++++++++++++++++++++++ scripts/scan-public.sh | 9 +- spec/openapi.yaml | 8 +- src/cli.ts | 7 +- src/providers/local.ts | 18 +++- src/providers/mock.ts | 2 +- src/server.ts | 97 +++++++++++++++---- tests/http-sdk.test.ts | 13 +-- tests/local-boundaries.test.ts | 128 +++++++++++++++++++++++++ tests/local-provider.test.ts | 12 +-- tests/public-scan.test.ts | 80 ++++++++++++++++ tests/server-security.test.ts | 165 +++++++++++++++++++++++++++++++++ tests/spec.test.ts | 42 ++++++++- 27 files changed, 923 insertions(+), 90 deletions(-) create mode 100644 docs/delivery.md create mode 100644 examples/embedded-runtime.mts create mode 100644 scripts/package-smoke.mjs create mode 100644 tests/local-boundaries.test.ts create mode 100644 tests/public-scan.test.ts create mode 100644 tests/server-security.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 377fd5d..dea9c29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +No changes queued. + +## 0.1.1 - 2026-10-09 + +- protect the reference HTTP server with authority/origin/fetch-metadata checks, JSON media-type + enforcement, strict action/file routes, and portable malformed-path errors; +- reject special filesystem write/list targets and cancel descendants after their group leader exits; +- validate an independently installed tarball, compile the packaged example, and smoke the actual + SDK/CLI through files, commands, SSE, recreation, fencing, and graceful resource cleanup; +- expand credential-shaped detection without printing matching values, including historical hits; +- isolate all test-created filesystem fixtures in unique temporary directories; +- align reference Provider and OpenAPI versions with the package and include a release acceptance map; - allow generation-fenced termination during ambiguous pause/resume transitions; - bound every conformance Provider call, validate timeout options, and verify cleanup state; - use unique conformance create-intent keys and reject unsuccessful recovery before data-plane probes; diff --git a/DOCS-INDEX.md b/DOCS-INDEX.md index 7a5df65..c8374cf 100644 --- a/DOCS-INDEX.md +++ b/DOCS-INDEX.md @@ -4,14 +4,14 @@ authority: canonical status: canonical title: Sandbox Runtime API documentation index genre: spec -last_verified: 2026-09-03 +last_verified: 2026-10-09 --- # Documentation Index ## Active context -The `v0.1` MVP is the current public baseline. It owns portable lifecycle, capability negotiation, +The `v0.1.1` delivery implements the `0.1` contract. It owns portable lifecycle, capability negotiation, command and file operations, event replay, the TypeScript SDK, the loopback-only reference server, the unsafe Local Provider, and provider conformance. @@ -35,6 +35,7 @@ network policy enforcement, snapshots, PTY, and port forwarding remain outside t - [API and SDK guide](docs/api-and-sdk.md) - [Local Provider](docs/providers/local.md) - [Testing and conformance](docs/testing.md) +- [Release archive and acceptance map](docs/delivery.md) - [Clean-room policy](docs/clean-room-policy.md) - [Visual source and validation register](docs/visuals/README.md) diff --git a/README.md b/README.md index 54e63fc..6b74a91 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ portable sandbox semantics. ## v0.1 Features -Version `0.1.0` contains: +Version `0.1.1` builds on the original `0.1.0` baseline and contains: - a normative runtime model, protocol, capability vocabulary, and OpenAPI document; - capability preflight; @@ -79,6 +79,9 @@ pnpm build Continue with the [Quickstart](docs/quickstart.md) or open the [documentation index](DOCS-INDEX.md). +For a prebuilt, independently installable SDK/CLI archive, use the +[GitHub release guide](docs/delivery.md). npm registry publication is not enabled. + ## Repository Map | Path | Purpose | diff --git a/README.zh-CN.md b/README.zh-CN.md index 71a6dc8..e9f0fa5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,7 @@ Harness 执行;Sandbox Runtime API 规范 Harness 或工具运行所需的隔 ## v0.1 能力 -`0.1.0` 包含: +`0.1.1` 在原始 `0.1.0` 基础上包含: - 规范化 Runtime 模型、协议、能力词表与 OpenAPI; - capability preflight; @@ -65,6 +65,9 @@ pnpm build 继续阅读 [快速开始](docs/quickstart.md) 或 [文档索引](DOCS-INDEX.md)。 +预构建 SDK/CLI 安装包和最终验收标准见 [GitHub 交付指南](docs/delivery.md)。 +当前不向 npm 注册表发布。 + ## Clean-room 边界 本仓库只基于公开规范、公开仓库和通用分布式系统原则独立设计。禁止提交私有代码、私有 API、 diff --git a/SECURITY.md b/SECURITY.md index 564e4dd..986e941 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-09-02 +last_verified: 2026-10-09 --- # Security Policy @@ -18,6 +18,25 @@ The project is pre-1.0. Security fixes are applied to the latest development rel Use GitHub private vulnerability reporting when enabled. Do not open a public issue containing exploit details, credentials, private infrastructure, prompts, or user data. +## Reference HTTP defenses + +The default server requires a loopback request authority and the actual listening port. It rejects +mismatched/opaque `Origin`, cross-origin fallback `Referer`, and cross-site/same-site Fetch Metadata +when present. JSON body +endpoints require `Content-Type: application/json`. Malformed paths return portable validation errors; +extra route components do not invoke actions; responses disable MIME sniffing. These controls apply +before Provider operations, including event and resource reads. + +The explicit `allowUnsafeNetwork` override permits non-loopback bind/authority names, but does not +remove origin, Fetch Metadata, JSON, or port checks. The server does not trust proxy forwarding +headers. There is no CORS allowlist or proxy-authentication feature. + +SDK/CLI clients may omit browser metadata. These checks are not authentication: local processes can +still use the server. Do not expose it through a public reverse proxy. The rationale follows public +[OWASP CSRF guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) +on simple content types and complete-origin comparison, plus independent authority validation for a +directly accessed development server. + ## Deployment boundary The reference runtime and mock provider are development implementations. They do not provide: diff --git a/docs/00-v0.1-summary.md b/docs/00-v0.1-summary.md index 7749a98..d88c45b 100644 --- a/docs/00-v0.1-summary.md +++ b/docs/00-v0.1-summary.md @@ -51,8 +51,8 @@ As of 2026-10-09, the original MVP has shipped as `0b513eae4d88ac1c7b60f46b00ad7bd5b587e652`, with [passing main CI](https://github.com/capa-cloud/sandbox-runtime-api/actions/runs/33732582026). -Maintenance after the tag is tracked under [Unreleased](../CHANGELOG.md). It does not change the -contents of the existing v0.1.0 tag. Use [Testing and conformance](testing.md) for current verification +Maintenance after the original tag is tracked in the [Changelog](../CHANGELOG.md), including the +`0.1.1` delivery. It does not change the existing v0.1.0 tag. Use [Testing and conformance](testing.md) for current verification commands; the main-branch CI badge in the README shows current repository health. ## Architecture decision diff --git a/docs/delivery.md b/docs/delivery.md new file mode 100644 index 0000000..3912b62 --- /dev/null +++ b/docs/delivery.md @@ -0,0 +1,90 @@ +--- +id: release-delivery +authority: reference +status: active +title: Release delivery and acceptance +genre: primer +last_verified: 2026-10-09 +--- + +# Release Delivery and Acceptance + +## Scope + +Version `0.1.1` delivers the public `0.1` contract, TypeScript SDK, reference runtime/server/CLI, +Mock and unsafe Local Providers, conformance runner, documentation, diagrams, and a prebuilt archive. +This is development infrastructure, not a hosted or production-isolated sandbox service. + +The [roadmap](../ROADMAP.md) distinguishes completed work from candidate extensions. A real cloud, +container, or Kubernetes Provider requires a separate public-source RFC, capability map, isolation +boundary, and opt-in integration evidence. Those candidates are not unfinished parts of this release. + +## Install a release archive + +Download `sandbox-runtime-api-0.1.1.tgz` and `SHA256SUMS.txt` from the +[v0.1.1 GitHub release](https://github.com/capa-cloud/sandbox-runtime-api/releases/tag/v0.1.1). +Verify the archive before installing: + +```bash +shasum -a 256 -c SHA256SUMS.txt +npm install --ignore-scripts ./sandbox-runtime-api-0.1.1.tgz +npx sandbox-runtime serve --host 127.0.0.1 --port 4311 +``` + +Node.js 22 or later runs the package; source development uses Node.js 22.12+ (22.x) or 24.x and +pnpm 10. The package has no production dependencies. `private: true` intentionally prevents accidental +npm registry publication; it does not prevent local installation of the archive. + +The CLI uses the **unsafe Local Provider**. Only run trusted, synthetic development commands, never +untrusted or AI-generated code. Ctrl-C cleans active resource directories. User-supplied `--root` +directories are retained; only this Provider's resource directories are removed. + +For embedding without host execution, start with the packaged +[Mock example](../examples/embedded-runtime.mts). For HTTP operations, use the +[API/SDK guide](api-and-sdk.md) and [quickstart](quickstart.md). + +## Requirement-to-evidence map + +| Requirement | Verifier | What it establishes | +| --- | --- | --- | +| Contract/version alignment | spec tests, `pnpm docs:check` | HTTP routes, vocabulary, errors, OpenAPI and Provider versions | +| Lifecycle/capabilities | runtime tests | preflight, idempotency, concurrency, reconciliation, pause/resume, termination/recreation | +| Generation fencing | runtime and HTTP/SDK tests | reject stale-generation mutations and file reads; resource discovery remains available | +| Commands/files | Local and local-boundary tests | argv, limits, cancellation, regular-file boundaries, traversal and cleanup | +| SDK/HTTP/SSE | HTTP/SDK tests | mapping, error normalization, replay, stream closure and disconnect cancellation | +| Browser boundary | server-security tests | authority/origin rejection, JSON types, exact routing, no mutation on denied requests | +| Provider compatibility | conformance/deadline tests | manifest/method alignment, bounded calls, ambiguous failure cleanup and result validation | +| Independent consumer | `pnpm pack:check` | tarball links/assets; clean install; compiled example/types; real CLI and SDK scenarios | +| Public safety | `pnpm sanitize`, scanner tests and semantic review | credentials/history, machine paths and media metadata; scanner output does not print matched values | +| Supply chain/CI | full dependency audit, Node.js 22/24 CI | frozen lockfile and complete verification on both development lines | + +`pnpm check` is the full local gate; CI additionally runs `pnpm sanitize` and a production dependency +audit. Exact commit, CI runs, scan triage, artifact checksums, and publication read-back belong in +release notes and merged PR evidence. Counts in the [original v0.1 summary](00-v0.1-summary.md) remain +dated evidence, not current health assertions. + +## Security and provenance + +Review covers HTTP inputs, lifecycle fencing, command/process cleanup, filesystem operations, +distributed package contents, and publication safety. Fixtures use task-owned temporary directories. +Default tests need no cloud credentials, real accounts, production responses, private repositories, +or customer data. + +HTTP request defenses do not authenticate a local process. Commands retain host-user permissions. +POSIX process-group cancellation covers ordinary descendants, not adversarial children that detach +into another session. Windows does not provide the same POSIX semantics; Unix FIFO/descendant +regressions are not asserted there. CI verifies Linux; local validation also verifies macOS. + +Passing these gates is not isolation, production availability, penetration-testing, or compliance +certification. See [Security policy](../SECURITY.md), [Local Provider](providers/local.md), and +[provenance](../ORIGIN_AND_PROVENANCE.md) before extending the project. + +## Maintenance and next development + +Start at [DOCS-INDEX](../DOCS-INDEX.md). Canonical `spec/` sources own the contract; implementation and +fresh tests own behavior; dated summaries and releases own historical evidence. Begin an approved +follow-up from a clean branch in the owning public repository. A candidate item, branch, or empty +directory alone does not establish an active phase. + +Coordinate Vitest/coverage upgrades, rerun all gates after substantive changes, and preserve existing +release tags. Do not lower capability or security assertions just to make tests pass. diff --git a/docs/providers/local.md b/docs/providers/local.md index fa5d61b..ff98635 100644 --- a/docs/providers/local.md +++ b/docs/providers/local.md @@ -4,7 +4,7 @@ authority: reference status: active title: Local Provider boundary genre: primer -last_verified: 2026-09-03 +last_verified: 2026-10-09 --- # Local Provider @@ -49,7 +49,12 @@ File operations reject: - lexical `..` escape; - existing paths whose real path escapes the sandbox directory; - symbolic-link entries and symbolic-link write targets; +- FIFOs, sockets, devices, and other non-regular write/list targets; - files above the configured byte limit; - malformed base64 input. These controls protect the file API contract. They do not constrain a spawned command. + +Writes verify the opened descriptor is a regular file before truncating. Process-group cancellation +also applies when the group leader has exited but ordinary descendants still hold output streams. +These are development reliability controls, not kernel isolation or an adversarial process supervisor. diff --git a/docs/quickstart.md b/docs/quickstart.md index c32d54e..90a9cfa 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -4,7 +4,7 @@ authority: reference status: active title: Local quickstart genre: how-to -last_verified: 2026-09-02 +last_verified: 2026-10-09 --- # Local Quickstart @@ -14,6 +14,9 @@ last_verified: 2026-09-02 ## Install and verify +This section builds from a Git checkout. For an independently installable prebuilt archive, see +[Release delivery](delivery.md). + ```bash pnpm install pnpm check @@ -28,6 +31,9 @@ node dist/cli.js serve --host 127.0.0.1 --port 4311 The server prints its base URL and binds to loopback by default. It has no authentication and refuses a non-loopback address unless the embedding application explicitly enables the unsafe override. +Requests must use a loopback authority with the listening port. Browser metadata must identify the +same origin; JSON body endpoints reject simple form/text media types. These checks do not authenticate +local processes or make the Local Provider safe for untrusted code. ## Create and use a sandbox diff --git a/docs/testing.md b/docs/testing.md index 51f876c..a35a50c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,8 +23,11 @@ pnpm audit:dependencies `pnpm check` runs formatting, lint, type checking, the complete test suite, documentation validation, build, coverage thresholds, and actual tarball validation. The package gate checks exports, CLI, -documentation, and every packaged Markdown local link, including image assets. CI runs the same gates -on Node.js 22 and 24. The final check gate audits all dependencies, including development tooling, +documentation, and every packaged Markdown local link, including image assets. +The package gate also performs an isolated install, compiles the packaged Mock example, and runs the +actual SDK/CLI through command, files, SSE, recreation, fencing, and shutdown cleanup. +CI runs the same gates on Node.js 22 and 24. The final check gate audits all dependencies, including +development tooling, against high-severity advisories and requires registry access. CI then runs the public-content scan and a separate production dependency audit. diff --git a/examples/embedded-runtime.mts b/examples/embedded-runtime.mts new file mode 100644 index 0000000..d2f9ca5 --- /dev/null +++ b/examples/embedded-runtime.mts @@ -0,0 +1,19 @@ +import { InMemorySandboxRuntime, MockSandboxProvider } from 'sandbox-runtime-api' + +// The Mock Provider executes no host code. This example needs no credentials. +const runtime = new InMemorySandboxRuntime(new MockSandboxProvider()) +const context = { requestId: 'embedded-example' } +const resource = await runtime.create({ clientRequestId: 'example-create' }, context) +const result = await runtime.execute( + resource.id, + { + expectedGeneration: resource.generation, + argv: ['synthetic-probe'], + }, + context, +) +await runtime.terminate(resource.id, resource.generation, context) +if (result.exitCode !== 0 || runtime.get(resource.id).state !== 'terminated') { + throw new Error('embedded example failed') +} +console.log('embedded package example passed') diff --git a/package.json b/package.json index a8655db..5366fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sandbox-runtime-api", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "A provider-neutral runtime contract for isolated AI agent execution environments.", "license": "Apache-2.0", @@ -33,6 +33,7 @@ "dist", "docs", "spec", + "examples", "DOCS-INDEX.md", "README.md", "README.zh-CN.md", @@ -65,9 +66,9 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/node": "22.20.4", - "@vitest/coverage-v8": "5.0.2", + "@vitest/coverage-v8": "5.0.3", "typescript": "7.0.2", - "vitest": "5.0.2", + "vitest": "5.0.3", "yaml": "2.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bca666c..919fe27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: 22.20.4 version: 22.20.4 '@vitest/coverage-v8': - specifier: 5.0.2 - version: 5.0.2(vitest@5.0.2) + specifier: 5.0.3 + version: 5.0.3(vitest@5.0.3) typescript: specifier: 7.0.2 version: 7.0.2 vitest: - specifier: 5.0.2 - version: 5.0.2(@types/node@22.20.4)(@vitest/coverage-v8@5.0.2)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) + specifier: 5.0.3 + version: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) yaml: specifier: 2.9.1 version: 2.9.1 @@ -351,11 +351,11 @@ packages: cpu: [x64] os: [win32] - '@vitest/coverage-v8@5.0.2': - resolution: {integrity: sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==} + '@vitest/coverage-v8@5.0.3': + resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} peerDependencies: - '@vitest/browser': 5.0.2 - vitest: 5.0.2 + '@vitest/browser': 5.0.3 + vitest: 5.0.3 peerDependenciesMeta: '@vitest/browser': optional: true @@ -368,8 +368,8 @@ packages: resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} engines: {node: '>=22'} - '@vitest/mocker@5.0.2': - resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -379,8 +379,8 @@ packages: vite: optional: true - '@vitest/spy@5.0.2': - resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -603,20 +603,20 @@ packages: yaml: optional: true - vitest@5.0.2: - resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.2 - '@vitest/browser-preview': 5.0.2 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.2 - '@vitest/coverage-v8': 5.0.2 - '@vitest/ui': 5.0.2 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -644,8 +644,8 @@ packages: jsdom: optional: true - why-is-node-running@3.2.2: - resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} engines: {node: '>=20.11'} hasBin: true @@ -837,7 +837,7 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/coverage-v8@5.0.2(vitest@5.0.2)': + '@vitest/coverage-v8@5.0.3(vitest@5.0.3)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.2 @@ -847,7 +847,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.2(@types/node@22.20.4)(@vitest/coverage-v8@5.0.2)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -855,16 +855,16 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.2(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1))': + '@vitest/mocker@5.0.3(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.2 + '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: vite: 8.2.2(@types/node@22.20.4)(yaml@2.9.1) - '@vitest/spy@5.0.2': {} + '@vitest/spy@5.0.3': {} assertion-error@2.0.1: {} @@ -1041,10 +1041,10 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.1 - vitest@5.0.2(@types/node@22.20.4)(@vitest/coverage-v8@5.0.2)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)): + vitest@5.0.3(@types/node@22.20.4)(@vitest/coverage-v8@5.0.3)(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.2(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) + '@vitest/mocker': 5.0.3(vite@8.2.2(@types/node@22.20.4)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1056,13 +1056,13 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 vite: 8.2.2(@types/node@22.20.4)(yaml@2.9.1) - why-is-node-running: 3.2.2 + why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 22.20.4 - '@vitest/coverage-v8': 5.0.2(vitest@5.0.2) + '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) transitivePeerDependencies: - msw - why-is-node-running@3.2.2: {} + why-is-node-running@3.2.1: {} yaml@2.9.1: {} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 9c58d72..2e5011f 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, posix, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -43,8 +43,47 @@ try { } } if (missing.length) throw new Error(`package validation failed:\n${missing.join('\n')}`) + const consumer = join(temporary, 'consumer') + await mkdir(consumer) + execFileSync( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', archive], + { + cwd: consumer, + stdio: 'pipe', + timeout: 60_000, + }, + ) + const example = join(consumer, 'embedded-runtime.mts') + await writeFile(example, read('package/examples/embedded-runtime.mts')) + execFileSync( + process.execPath, + [ + join(root, 'node_modules/typescript/bin/tsc'), + '--module', + 'NodeNext', + '--target', + 'ES2022', + '--strict', + '--outDir', + consumer, + example, + ], + { cwd: consumer, stdio: 'pipe', timeout: 30_000 }, + ) + execFileSync(process.execPath, [join(consumer, 'embedded-runtime.mjs')], { + cwd: consumer, + stdio: 'pipe', + timeout: 10_000, + }) + const smoke = await readFile(join(root, 'scripts/package-smoke.mjs'), 'utf8') + execFileSync(process.execPath, ['--input-type=module', '--eval', smoke], { + cwd: consumer, + stdio: 'pipe', + timeout: 20_000, + }) process.stdout.write( - `package validation passed: ${files.size} entries, exports, CLI, docs, and local links\n`, + `package validation passed: ${files.size} entries; local links, installed types/example, SDK, CLI, SSE, and shutdown\n`, ) } finally { await rm(temporary, { recursive: true, force: true }) diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs new file mode 100644 index 0000000..dbd7c91 --- /dev/null +++ b/scripts/package-smoke.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { mkdir, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { SandboxRuntimeClient } from 'sandbox-runtime-api' + +const root = join(process.cwd(), 'cli-resources') +await mkdir(root) +const startupRoot = join(process.cwd(), 'cli-startup') +await mkdir(startupRoot) +const denied = spawnSync( + process.execPath, + ['node_modules/sandbox-runtime-api/dist/cli.js', 'serve', '--host', '0.0.0.0', '--port', '0'], + { + env: { ...process.env, TMPDIR: startupRoot }, + encoding: 'utf8', + timeout: 5_000, + }, +) +assert.equal(denied.status, 1) +assert.deepEqual(await readdir(startupRoot), []) +const child = spawn( + process.platform === 'win32' + ? process.execPath + : join(process.cwd(), 'node_modules/.bin/sandbox-runtime'), + [ + ...(process.platform === 'win32' ? ['node_modules/sandbox-runtime-api/dist/cli.js'] : []), + 'serve', + '--port', + '0', + '--root', + root, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, +) +const exited = once(child, 'exit') +// Attach a handler immediately, including when startup fails before readiness. +void exited.catch(() => undefined) +const lines = createInterface({ input: child.stdout }) +const bounded = async (operation, timeoutMs) => { + let timer + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('package smoke timed out')), timeoutMs) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +let failure +try { + const [baseUrl] = await bounded( + Promise.race([ + once(lines, 'line'), + exited.then(() => { + throw new Error('packaged CLI exited before readiness') + }), + ]), + 5_000, + ) + const client = new SandboxRuntimeClient(baseUrl) + const signal = AbortSignal.timeout(8_000) + assert.equal((await fetch(`${baseUrl}/healthz`, { signal })).status, 200) + assert.equal((await client.getRuntimeInfo(signal)).provider.runtimeClass, 'local-process-unsafe') + const resource = await client.create({ clientRequestId: 'package-create' }, signal) + const bytes = Buffer.from('synthetic-package-probe').toString('base64') + await client.writeFile(resource.id, resource.generation, 'probe.txt', bytes, signal) + assert.equal( + (await client.readFile(resource.id, resource.generation, 'probe.txt', signal)).contentBase64, + bytes, + ) + assert.equal( + (await client.listFiles(resource.id, resource.generation, '.', signal)).some( + (entry) => entry.path === 'probe.txt', + ), + true, + ) + assert.equal( + ( + await client.execute( + resource.id, + { + expectedGeneration: resource.generation, + argv: [process.execPath, '-e', 'process.stdout.write("package-probe")'], + }, + signal, + ) + ).stdout, + 'package-probe', + ) + const stream = client.streamEvents({ sandboxId: resource.id, signal }) + try { + assert.equal((await bounded(stream.next(), 3_000)).value.sandboxId, resource.id) + } finally { + await stream.return() + } + assert.equal( + (await client.terminate(resource.id, resource.generation, signal)).state, + 'terminated', + ) + const recreated = await client.recreate( + resource.id, + resource.generation, + { + clientRequestId: 'package-recreate', + }, + signal, + ) + assert.equal(recreated.generation, resource.generation + 1) + await assert.rejects(client.terminate(resource.id, resource.generation, signal), { + code: 'generation_conflict', + }) + // Leave the recreated resource active: graceful CLI shutdown must clean it. +} catch (error) { + failure = error +} finally { + lines.close() + child.kill('SIGTERM') + try { + assert.equal((await bounded(exited, 5_000))[0], 0) + } catch (error) { + child.kill('SIGKILL') + await exited.catch(() => undefined) + failure ??= error + } +} +if (failure) throw failure +assert.deepEqual(await readdir(root), []) +console.log( + 'installed package SDK, CLI, lifecycle, files, command, SSE, fencing, and shutdown passed', +) diff --git a/scripts/scan-public.sh b/scripts/scan-public.sh index 8a76530..1211868 100644 --- a/scripts/scan-public.sh +++ b/scripts/scan-public.sh @@ -3,6 +3,7 @@ set -euo pipefail root="${1:-.}" status=0 +credential_pattern='(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|authorization:[[:space:]]*bearer[[:space:]]+[A-Za-z0-9._-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-(proj-)?[A-Za-z0-9_-]{20,}|api[_-]?key[^[:alnum:]]{0,4}[:=][[:space:]]*[\x22\x27][^\x22\x27]{12,}[\x22\x27])' dangerous_files=$(find "$root" \ -path '*/.git' -prune -o \ @@ -22,7 +23,7 @@ scan() { local label="$1" local pattern="$2" local output - output=$(rg -n -i --hidden \ + output=$(rg -l -i --hidden \ --glob '!.git/**' --glob '!node_modules/**' --glob '!coverage/**' --glob '!pnpm-lock.yaml' \ --glob '!scripts/scan-public.sh' \ "$pattern" "$root" || true) @@ -33,7 +34,7 @@ scan() { } scan 'credential-shaped content' \ - '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|authorization:[[:space:]]*bearer[[:space:]]+[A-Za-z0-9._-]{16,}|api[_-]?key[[:space:]]*[:=][[:space:]]*["'"'][^"'"']{12,}["'"'])' + "$credential_pattern" scan 'local absolute path or private-network literal' \ '(/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$)|(^|[^0-9])(10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3})([^0-9]|$))' @@ -50,9 +51,9 @@ fi if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then history=$(git -C "$root" log --all -p -- . ':!pnpm-lock.yaml' ':!scripts/scan-public.sh' | \ - rg -n -i '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|authorization:[[:space:]]*bearer[[:space:]]+[A-Za-z0-9._-]{16,}|/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$))' || true) + rg --count-matches -i "$credential_pattern|/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$)" || true) if [[ -n "$history" ]]; then - printf 'sensitive-shaped git history content:\n%s\n' "$history" + printf 'sensitive-shaped git history content detected: %s matching lines; values withheld\n' "$history" status=1 fi fi diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 0bee1c1..e67c9a6 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Sandbox Runtime API - version: 0.1.0 + version: 0.1.1 description: Provider-neutral lifecycle and data-plane contract for isolated execution environments. servers: - url: http://127.0.0.1:4311 @@ -11,6 +11,7 @@ paths: get: operationId: getHealth responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Process is accepting requests content: @@ -24,6 +25,7 @@ paths: get: operationId: getRuntimeInfo responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Protocol and Provider manifest content: @@ -35,6 +37,7 @@ paths: get: operationId: listSandboxes responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Current in-memory resources content: @@ -70,6 +73,7 @@ paths: get: operationId: getSandbox responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Sandbox resource content: @@ -277,6 +281,7 @@ paths: - $ref: "#/components/parameters/AfterCursor" - $ref: "#/components/parameters/OptionalSandboxId" responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Events after the cursor content: @@ -296,6 +301,7 @@ paths: - $ref: "#/components/parameters/AfterCursor" - $ref: "#/components/parameters/OptionalSandboxId" responses: + "400": { $ref: "#/components/responses/InvalidRequest" } "200": description: Server-Sent Events stream content: diff --git a/src/cli.ts b/src/cli.ts index 7e840a6..fe3acd2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,12 @@ const main = async (): Promise => { const rootDirectory = argument('--root') const provider = new LocalSandboxProvider(rootDirectory ? { rootDirectory } : {}) const runtime = new InMemorySandboxRuntime(provider) - const handle = await startSandboxRuntimeServer(runtime, { host, port }) + const handle = await startSandboxRuntimeServer(runtime, { host, port }).catch( + async (error: unknown) => { + await provider.dispose().catch(() => undefined) + throw error + }, + ) process.stdout.write(`${handle.baseUrl}\n`) const shutdown = async () => { diff --git a/src/providers/local.ts b/src/providers/local.ts index 986e4a1..0f9eb67 100644 --- a/src/providers/local.ts +++ b/src/providers/local.ts @@ -81,7 +81,7 @@ export class LocalSandboxProvider implements SandboxProvider { async describe(_context: ProviderContext): Promise { return { name: 'local', - version: '0.1.0', + version: '0.1.1', runtimeClass: 'local-process-unsafe', capabilities: { commandExecution: true, @@ -275,12 +275,16 @@ export class LocalSandboxProvider implements SandboxProvider { } const handle = await open( target, - constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, + constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600, ).catch(() => { throw new RuntimeError('invalid_request', 'write target must be a regular non-symbolic file') }) try { + if (!(await handle.stat()).isFile()) { + throw new RuntimeError('invalid_request', 'write target must be a regular file') + } + await handle.truncate(0) await handle.writeFile(content) } finally { await handle.close() @@ -309,6 +313,9 @@ export class LocalSandboxProvider implements SandboxProvider { throw new RuntimeError('invalid_request', 'symbolic links are not portable file entries') } const entryPath = join(directory, entry.name) + if (!entry.isFile() && !entry.isDirectory()) { + throw new RuntimeError('invalid_request', 'special files are not portable file entries') + } const metadata = await stat(entryPath) result.push({ path: path === '.' || path === '' ? entry.name : `${path.replace(/\/$/, '')}/${entry.name}`, @@ -346,7 +353,7 @@ export class LocalSandboxProvider implements SandboxProvider { } #killProcess(child: ChildProcess): void { - if (!child.pid || child.exitCode !== null || child.signalCode !== null) return + if (!child.pid) return if (process.platform !== 'win32') { try { process.kill(-child.pid, 'SIGKILL') @@ -355,7 +362,7 @@ export class LocalSandboxProvider implements SandboxProvider { // Fall back to the direct child when the process group has already exited. } } - child.kill('SIGKILL') + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') } #resource(key: ProviderSandboxKey): LocalResource { @@ -401,6 +408,9 @@ export class LocalSandboxProvider implements SandboxProvider { if (existing?.isSymbolicLink()) { throw new RuntimeError('invalid_request', 'symbolic-link writes are not allowed') } + if (existing && !existing.isFile()) { + throw new RuntimeError('invalid_request', 'write target must be a regular file') + } return candidate } } diff --git a/src/providers/mock.ts b/src/providers/mock.ts index 4094cd0..b7989c2 100644 --- a/src/providers/mock.ts +++ b/src/providers/mock.ts @@ -39,7 +39,7 @@ export class MockSandboxProvider implements SandboxProvider { constructor(capabilities: Partial = {}) { this.#manifest = { name: 'mock', - version: '0.1.0-dev', + version: '0.1.1', runtimeClass: 'mock-process', capabilities: allCapabilities(capabilities), } diff --git a/src/server.ts b/src/server.ts index c8cc4d1..696af5c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,42 @@ const statusByCode: Readonly> = { const isLoopback = (host: string): boolean => host === '127.0.0.1' || host === '::1' || host === 'localhost' +const assertRequestBoundary = (request: IncomingMessage, allowUnsafeNetwork: boolean): void => { + let authority: URL + try { + if (!request.headers.host) throw new Error('missing authority') + authority = new URL(`http://${request.headers.host}`) + } catch { + throw new RuntimeError('invalid_request', 'request authority is invalid') + } + if ( + authority.username || + authority.password || + authority.pathname !== '/' || + authority.search || + authority.hash || + Number(authority.port || 80) !== request.socket.localPort || + (!allowUnsafeNetwork && !['localhost', '127.0.0.1', '[::1]'].includes(authority.hostname)) + ) { + throw new RuntimeError('invalid_request', 'request authority is not allowed') + } + let origin = request.headers.origin + if (origin === undefined && request.headers.referer !== undefined) { + try { + origin = new URL(request.headers.referer).origin + } catch { + throw new RuntimeError('invalid_request', 'request referrer is invalid') + } + } + const fetchSite = request.headers['sec-fetch-site'] + if ( + (origin !== undefined && origin !== authority.origin) || + (fetchSite !== undefined && !['same-origin', 'none'].includes(String(fetchSite))) + ) { + throw new RuntimeError('invalid_request', 'cross-origin browser requests are not allowed') + } +} + const json = (response: ServerResponse, status: number, value: unknown): void => { const body = JSON.stringify(value) response.writeHead(status, { @@ -39,6 +75,9 @@ const json = (response: ServerResponse, status: number, value: unknown): void => } const readJson = async (request: IncomingMessage, bodyLimitBytes: number): Promise => { + if (request.headers['content-type']?.split(';')[0]?.trim().toLowerCase() !== 'application/json') { + throw new RuntimeError('invalid_request', 'request body requires application/json') + } const chunks: Buffer[] = [] let size = 0 for await (const chunk of request) { @@ -89,19 +128,25 @@ export const startSandboxRuntimeServer = async ( } const server = createServer((request, response) => { - void handleRequest(runtime, request, response, maxBodyBytes).catch((error: unknown) => { - if (response.headersSent) { - response.end() - return - } - if (error instanceof RuntimeError) { - json(response, statusByCode[error.code], { - error: { code: error.code, message: error.message }, - }) - return - } - json(response, 500, { error: { code: 'internal_error', message: 'internal server error' } }) - }) + response.setHeader('x-content-type-options', 'nosniff') + void Promise.resolve() + .then(() => { + assertRequestBoundary(request, options.allowUnsafeNetwork ?? false) + return handleRequest(runtime, request, response, maxBodyBytes) + }) + .catch((error: unknown) => { + if (response.headersSent) { + response.end() + return + } + if (error instanceof RuntimeError) { + json(response, statusByCode[error.code], { + error: { code: error.code, message: error.message }, + }) + return + } + json(response, 500, { error: { code: 'internal_error', message: 'internal server error' } }) + }) }) await new Promise((resolveListen, reject) => { @@ -128,9 +173,20 @@ const handleRequest = async ( response: ServerResponse, maxBodyBytes: number, ): Promise => { - const url = new URL(request.url ?? '/', 'http://runtime.invalid') + let url: URL + let segments: string[] + try { + if (!request.url?.startsWith('/') || request.url.startsWith('//')) + throw new Error('invalid target') + url = new URL(request.url, 'http://runtime.invalid') + segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent) + } catch { + throw new RuntimeError('invalid_request', 'request path is invalid') + } + if (url.pathname.includes('//') || (url.pathname.length > 1 && url.pathname.endsWith('/'))) { + throw new RuntimeError('not_found', 'route does not exist') + } const method = request.method ?? 'GET' - const segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent) const requestIdHeader = request.headers['x-request-id'] const abortController = new AbortController() response.once('close', () => { @@ -198,7 +254,7 @@ const handleRequest = async ( json(response, 200, runtime.get(sandboxId)) return } - if (segments[3] === 'actions' && segments[4] && method === 'POST') { + if (segments[3] === 'actions' && segments[4] && segments.length === 5 && method === 'POST') { const body = await readJson(request, maxBodyBytes) if (segments[4] === 'reconcile') { json(response, 200, await runtime.reconcile(sandboxId, context)) @@ -249,7 +305,7 @@ const handleRequest = async ( ) return } - if (segments[3] === 'files' && segments[4] === 'content') { + if (segments[3] === 'files' && segments[4] === 'content' && segments.length === 5) { if (method === 'GET') { const expectedGeneration = parseExpectedGeneration({ expectedGeneration: Number(url.searchParams.get('expectedGeneration') ?? ''), @@ -279,7 +335,12 @@ const handleRequest = async ( return } } - if (segments[3] === 'files' && segments[4] === 'entries' && method === 'GET') { + if ( + segments[3] === 'files' && + segments[4] === 'entries' && + segments.length === 5 && + method === 'GET' + ) { const expectedGeneration = parseExpectedGeneration({ expectedGeneration: Number(url.searchParams.get('expectedGeneration') ?? ''), }) diff --git a/tests/http-sdk.test.ts b/tests/http-sdk.test.ts index 0d4fb30..5b8fa14 100644 --- a/tests/http-sdk.test.ts +++ b/tests/http-sdk.test.ts @@ -1,4 +1,5 @@ -import { access, rm } from 'node:fs/promises' +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { @@ -177,9 +178,9 @@ describe('HTTP server and TypeScript SDK', () => { it('propagates an HTTP disconnect to the running local process group', async () => { const { client } = await start() const sandbox = await client.create({ clientRequestId: 'abort-create' }) - const marker = join(process.cwd(), '.test-http-abort-marker') - cleanupPaths.push(marker) - await rm(marker, { force: true }) + const markerRoot = await mkdtemp(join(tmpdir(), 'sandbox-http-abort-')) + cleanupPaths.push(markerRoot) + const marker = join(markerRoot, 'probe.txt') const script = [ 'const {spawn}=require("node:child_process")', `spawn(process.execPath,["-e",${JSON.stringify(`setTimeout(()=>require("node:fs").writeFileSync(${JSON.stringify(marker)},"bad"),300)`)}],{stdio:"ignore"})`, @@ -208,10 +209,10 @@ describe('HTTP server and TypeScript SDK', () => { ).resolves.toMatchObject({ stdout: 'alive', exitCode: 0 }) }) - it('does not expose unknown error details', async () => { + it('does not expose parser details for malformed paths', async () => { const { handle } = await start() const response = await fetch(`${handle.baseUrl}/v1/sandboxes/%E0%A4%A`) - expect(response.status).toBe(500) + expect(response.status).toBe(400) expect(await response.text()).not.toContain('URIError') }) diff --git a/tests/local-boundaries.test.ts b/tests/local-boundaries.test.ts new file mode 100644 index 0000000..041004c --- /dev/null +++ b/tests/local-boundaries.test.ts @@ -0,0 +1,128 @@ +import { execFile } from 'node:child_process' +import { constants } from 'node:fs' +import { mkdtemp, open, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { LocalSandboxProvider } from '../src/index.js' + +const providers: LocalSandboxProvider[] = [] +const roots: string[] = [] +const context = { requestId: 'local-boundary' } +const key = { sandboxId: 'synthetic-boundary', generation: 1 } +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const bounded = async (operation: Promise, ms: number): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('operation did not settle')), ms) + }), + ]) + } finally { + clearTimeout(timer) + } +} +afterEach(async () => { + await Promise.all(providers.splice(0).map((provider) => provider.dispose())) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('Unix Local Provider boundaries', () => { + it('rejects FIFO file writes and listings without hanging', async () => { + const root = await mkdtemp(join(tmpdir(), 'sandbox-fifo-test-')) + roots.push(root) + const provider = new LocalSandboxProvider({ rootDirectory: root }) + providers.push(provider) + await provider.provision({ ...key, spec: { clientRequestId: 'fifo-fixture' } }, context) + const [directory] = await readdir(root) + if (!directory) throw new Error('missing synthetic resource') + const fifo = join(root, directory, 'probe.fifo') + await promisify(execFile)('mkfifo', [fifo]) + const write = provider.writeFile( + key, + { expectedGeneration: 1, path: 'probe.fifo', contentBase64: 'cHJvYmU=' }, + context, + ) + try { + await expect(bounded(write, 250)).rejects.toMatchObject({ code: 'invalid_request' }) + await expect(provider.listFiles(key, '.', context)).rejects.toMatchObject({ + code: 'invalid_request', + }) + } finally { + // Release any old blocking FIFO writer so a failing regression leaves no open I/O. + const reader = await open(fifo, constants.O_RDONLY | constants.O_NONBLOCK) + await write.catch(() => undefined) + await reader.close() + } + }) + + it('cancels descendants holding output after their process-group leader exits', async () => { + const provider = new LocalSandboxProvider() + providers.push(provider) + await provider.provision({ ...key, spec: { clientRequestId: 'descendant-fixture' } }, context) + const controller = new AbortController() + const script = [ + 'const {spawn}=require("node:child_process")', + 'const child=spawn(process.execPath,["-e","setTimeout(()=>{},2000)"],{stdio:"inherit"})', + 'require("node:fs").writeFileSync("owned-pids.json",JSON.stringify({leader:process.pid,child:child.pid}))', + 'process.exit(0)', + ].join(';') + const execution = provider.execute( + key, + { expectedGeneration: 1, argv: [process.execPath, '-e', script], timeoutSeconds: 5 }, + { ...context, signal: controller.signal }, + ) + let ownedChild: number | undefined + let completed = false + try { + const deadline = Date.now() + 1_500 + let leader: number | undefined + while (Date.now() < deadline) { + const content = await provider + .readFile(key, 'owned-pids.json', context) + .catch(() => undefined) + if (content) { + const pids = JSON.parse(Buffer.from(content.contentBase64, 'base64').toString('utf8')) + if ( + !Number.isInteger(pids.leader) || + pids.leader < 2 || + !Number.isInteger(pids.child) || + pids.child < 2 + ) + throw new Error('invalid synthetic ownership') + leader = pids.leader + ownedChild = pids.child + try { + process.kill(pids.leader, 0) + } catch { + break + } + } + await delay(10) + } + expect(leader).toBeDefined() + expect(ownedChild).toBeDefined() + expect(() => process.kill(leader as number, 0)).toThrow() + await delay(20) + controller.abort() + await expect(bounded(execution, 500)).resolves.toMatchObject({ + cancelled: true, + timedOut: false, + }) + completed = true + } finally { + // Only this fixture's known, still-live child is eligible for cleanup. + if (!completed && ownedChild) { + try { + process.kill(ownedChild, 'SIGKILL') + } catch { + /* Already exited. */ + } + } + await execution.catch(() => undefined) + } + }) +}) diff --git a/tests/local-provider.test.ts b/tests/local-provider.test.ts index 3cd82af..e3e1e23 100644 --- a/tests/local-provider.test.ts +++ b/tests/local-provider.test.ts @@ -1,4 +1,5 @@ -import { access, mkdir, readdir, rm, symlink } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readdir, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { @@ -90,9 +91,9 @@ describe('LocalSandboxProvider', () => { it('allows lifecycle termination to cancel a running command and its descendants', async () => { const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) await runtime.create({ clientRequestId: 'create-1' }, context) - const marker = join(process.cwd(), '.test-descendant-marker') - await rm(marker, { force: true }) - roots.push(marker) + const markerRoot = await mkdtemp(join(tmpdir(), 'sandbox-descendant-test-')) + roots.push(markerRoot) + const marker = join(markerRoot, 'probe.txt') const script = [ 'const {spawn}=require("node:child_process")', `spawn(process.execPath,["-e",${JSON.stringify(`setTimeout(()=>require("node:fs").writeFileSync(${JSON.stringify(marker)},"bad"),300)`)}],{stdio:"ignore"})`, @@ -149,9 +150,8 @@ describe('LocalSandboxProvider', () => { }) it('rejects symlink escape and invalid base64', async () => { - const root = join(process.cwd(), '.test-local-provider') + const root = await mkdtemp(join(tmpdir(), 'sandbox-file-test-')) roots.push(root) - await rm(root, { recursive: true, force: true }) const local = new LocalSandboxProvider({ rootDirectory: root }) providers.push(local) const runtime = new InMemorySandboxRuntime(local, { idFactory: () => 'sandbox-1' }) diff --git a/tests/public-scan.test.ts b/tests/public-scan.test.ts new file mode 100644 index 0000000..ec5e2e9 --- /dev/null +++ b/tests/public-scan.test.ts @@ -0,0 +1,80 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const roots: string[] = [] +const scanner = resolve('scripts/scan-public.sh') +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-scan-fixture-')) + roots.push(root) + return root +} +const scan = (root: string) => + spawnSync('bash', [scanner, '.'], { cwd: root, encoding: 'utf8', timeout: 5_000 }) + +describe('public-content scanner', () => { + it('reports credential locations without printing matching values', async () => { + const root = await fixture() + const synthetic = `synthetic-${'x'.repeat(30)}` + await writeFile(join(root, 'probe.txt'), JSON.stringify({ api_key: synthetic })) + const result = scan(root) + expect(result.status).toBe(1) + expect(result.stdout).toContain('probe.txt') + expect(result.stdout).not.toContain(synthetic) + }) + + it.each(['ghp', 'github_pat', 'xoxb', 'AKIA'])( + 'detects a synthetic %s-shaped credential', + async (prefix) => { + const root = await fixture() + const synthetic = + prefix === 'AKIA' + ? prefix + 'X'.repeat(16) + : prefix + (prefix === 'xoxb' ? '-' : '_') + 'x'.repeat(40) + await writeFile(join(root, 'probe.txt'), synthetic) + const result = scan(root) + expect(result.status).toBe(1) + expect(result.stdout).not.toContain(synthetic) + }, + ) + + it('keeps historical credential detection without leaking commit content', async () => { + const root = await fixture() + const synthetic = ['ghp', '_', 'x'.repeat(40)].join('') + execFileSync('git', ['init', '--quiet'], { cwd: root }) + await writeFile(join(root, 'probe.txt'), synthetic) + execFileSync('git', ['add', 'probe.txt'], { cwd: root }) + execFileSync( + 'git', + [ + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.test', + 'commit', + '--quiet', + '-m', + 'synthetic history', + ], + { cwd: root }, + ) + await writeFile(join(root, 'probe.txt'), 'safe current content') + const result = scan(root) + expect(result.status).toBe(1) + expect(result.stdout).toContain('history') + expect(result.stdout).not.toContain(synthetic) + }) + + it('passes clean synthetic content and rejects dangerous files', async () => { + const root = await fixture() + await writeFile(join(root, 'probe.txt'), 'safe example') + expect(scan(root).status).toBe(0) + await writeFile(join(root, '.env'), 'synthetic fixture') + expect(scan(root).status).toBe(1) + }) +}) diff --git a/tests/server-security.test.ts b/tests/server-security.test.ts new file mode 100644 index 0000000..237fc9b --- /dev/null +++ b/tests/server-security.test.ts @@ -0,0 +1,165 @@ +import { request } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { + InMemorySandboxRuntime, + MockSandboxProvider, + 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 () => { + const runtime = new InMemorySandboxRuntime(new MockSandboxProvider(), { + idFactory: () => 'boundary-probe', + }) + const handle = await startSandboxRuntimeServer(runtime, { port: 0 }) + handles.push(handle) + return { runtime, handle } +} + +const rawRequest = ( + url: string, + headers: Record, + body?: string, +): Promise<{ status: number; text: string }> => + new Promise((resolve, reject) => { + const pending = request( + url, + { headers, method: body === undefined ? 'GET' : 'POST' }, + (response) => { + let text = '' + response.setEncoding('utf8') + response.on('data', (chunk: string) => { + text += chunk + }) + response.on('end', () => resolve({ status: response.statusCode ?? 0, text })) + response.on('error', reject) + }, + ) + pending.setTimeout(2_000, () => pending.destroy(new Error('synthetic request timed out'))) + pending.on('error', reject) + pending.end(body) + }) + +describe('reference server browser and route boundaries', () => { + it.each([ + { origin: 'http://example.test' }, + { origin: 'null' }, + { referer: 'http://example.test/page' }, + { referer: 'not-a-url' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { host: 'example.test:4311' }, + { host: '127.0.0.1:1' }, + ])('rejects unsafe browser/authority headers %# before mutations', async (headers) => { + const { handle, runtime } = await start() + const response = await rawRequest( + `${handle.baseUrl}/v1/sandboxes`, + { 'content-type': 'application/json', ...headers }, + JSON.stringify({ clientRequestId: 'blocked' }), + ) + expect(response.status).toBe(400) + expect(runtime.list()).toEqual([]) + expect(runtime.events()).toEqual([]) + }) + + it.each(['text/plain', 'application/x-www-form-urlencoded', 'multipart/form-data'])( + 'rejects JSON sent as %s', + async (contentType) => { + const { handle, runtime } = await start() + const response = await fetch(`${handle.baseUrl}/v1/sandboxes`, { + method: 'POST', + headers: { 'content-type': contentType }, + body: JSON.stringify({ clientRequestId: 'simple-request' }), + }) + expect(response.status).toBe(400) + expect(runtime.list()).toEqual([]) + }, + ) + + it('accepts same-origin JSON requests and non-browser clients', async () => { + const { handle } = await start() + const response = await fetch(`${handle.baseUrl}/v1/sandboxes`, { + method: 'POST', + headers: { + 'content-type': 'application/json; charset=utf-8', + origin: handle.baseUrl, + 'sec-fetch-site': 'same-origin', + }, + body: JSON.stringify({ clientRequestId: 'same-origin' }), + }) + expect(response.status).toBe(201) + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect((await fetch(`${handle.baseUrl}/healthz`)).status).toBe(200) + }) + + it('rejects hostile read requests without disclosing resources', async () => { + const { handle, runtime } = await start() + await runtime.create({ clientRequestId: 'private-probe' }, { requestId: 'synthetic' }) + for (const path of ['/v1/runtime', '/v1/sandboxes', '/v1/events', '/v1/events/stream']) { + const response = await rawRequest(`${handle.baseUrl}${path}`, { host: 'example.test:4311' }) + expect(response.status).toBe(400) + expect(response.text).not.toContain('private-probe') + } + }) + + it('allows a loopback alias with the actual port and matching referrer', async () => { + const { handle } = await start() + const authority = `localhost:${new URL(handle.baseUrl).port}` + const response = await rawRequest(`${handle.baseUrl}/healthz`, { + host: authority, + referer: `http://${authority}/synthetic-page`, + }) + expect(response.status).toBe(200) + }) + + it('sanitizes unexpected implementation errors', async () => { + const { handle, runtime } = await start() + runtime.list = () => { + throw new Error('synthetic implementation detail') + } + const response = await fetch(`${handle.baseUrl}/v1/sandboxes`) + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('synthetic implementation detail') + }) + + it.each([ + 'actions/terminate/extra', + 'commands/extra', + 'files/content/extra', + 'files/entries/extra', + 'actions/terminate/', + 'actions/terminate//extra', + 'actions//terminate', + 'files//entries', + ])('does not route extra path components for %s', async (suffix) => { + const { handle, runtime } = await start() + await runtime.create({ clientRequestId: 'routes' }, { requestId: 'synthetic' }) + const method = suffix.startsWith('files/') ? 'GET' : 'POST' + const response = await fetch( + `${handle.baseUrl}/v1/sandboxes/boundary-probe/${suffix}?expectedGeneration=1&path=probe.txt`, + { + method, + ...(method === 'POST' + ? { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ expectedGeneration: 1 }), + } + : {}), + }, + ) + expect(response.status).toBe(404) + expect(runtime.get('boundary-probe').state).toBe('ready') + }) + + it('returns a portable validation error for malformed path encoding', async () => { + const { handle } = await start() + const response = await fetch(`${handle.baseUrl}/v1/sandboxes/%E0%A4%A`) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: { code: 'invalid_request' } }) + }) +}) diff --git a/tests/spec.test.ts b/tests/spec.test.ts index 4f0d9e2..8020b7a 100644 --- a/tests/spec.test.ts +++ b/tests/spec.test.ts @@ -1,13 +1,20 @@ import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { capabilityNames, protocolVersion, sandboxEventTypes, sandboxStates } from '../src/index.js' +import { + capabilityNames, + LocalSandboxProvider, + MockSandboxProvider, + protocolVersion, + sandboxEventTypes, + sandboxStates, +} from '../src/index.js' describe('OpenAPI projection', () => { it('matches the executable protocol vocabularies', async () => { const document = parse(await readFile('spec/openapi.yaml', 'utf8')) expect(document.openapi).toBe('3.1.0') - expect(document.info.version).toBe('0.1.0') + expect(document.info.version).toBe(JSON.parse(await readFile('package.json', 'utf8')).version) expect(document.components.schemas.RuntimeInfo.properties.protocolVersion.const).toBe( protocolVersion, ) @@ -52,4 +59,35 @@ describe('OpenAPI projection', () => { ].sort(), ) }) + + it('documents boundary validation on every HTTP operation', async () => { + const document = parse(await readFile('spec/openapi.yaml', 'utf8')) + for (const path of Object.values(document.paths) as Record< + string, + { responses?: Record } + >[]) { + for (const [method, operation] of Object.entries(path)) { + if (['get', 'post', 'put'].includes(method)) { + expect(operation.responses?.['400']).toEqual({ + $ref: '#/components/responses/InvalidRequest', + }) + } + } + } + }) + + it('aligns package and reference Provider versions while retaining the publication guard', async () => { + const pkg = JSON.parse(await readFile('package.json', 'utf8')) + const provider = new LocalSandboxProvider() + try { + for (const implementation of [provider, new MockSandboxProvider()]) { + expect((await implementation.describe({ requestId: 'version-probe' })).version).toBe( + pkg.version, + ) + } + expect(pkg.private).toBe(true) + } finally { + await provider.dispose() + } + }) }) From 3530cad5992b3327bbd3e70df856029ee659684a Mon Sep 17 00:00:00 2001 From: KevinTen <22876610+kevinten10@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:26:26 +0800 Subject: [PATCH 2/3] fix: make public scanner portable and fail closed --- CHANGELOG.md | 2 + docs/testing.md | 4 ++ scripts/scan-public.mjs | 109 ++++++++++++++++++++++++++++++++++++++ scripts/scan-public.sh | 61 ++------------------- tests/public-scan.test.ts | 41 +++++++++++++- 5 files changed, 158 insertions(+), 59 deletions(-) create mode 100644 scripts/scan-public.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index dea9c29..cf10c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ No changes queued. - validate an independently installed tarball, compile the packaged example, and smoke the actual SDK/CLI through files, commands, SSE, recreation, fencing, and graceful resource cleanup; - expand credential-shaped detection without printing matching values, including historical hits; +- make public-content scanning self-contained in Node.js and fail closed on scan/history errors, + including CI environments without ripgrep; - isolate all test-created filesystem fixtures in unique temporary directories; - align reference Provider and OpenAPI versions with the package and include a release acceptance map; - allow generation-fenced termination during ambiguous pause/resume transitions; diff --git a/docs/testing.md b/docs/testing.md index a35a50c..fa72624 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,6 +31,10 @@ development tooling, against high-severity advisories and requires registry access. CI then runs the public-content scan and a separate production dependency audit. +The public-content scanner uses Node.js built-ins, not an optional `rg`/`strings` installation. +Unreadable roots or failed Git history scans fail closed. Regression fixtures explicitly remove +ripgrep from `PATH` and verify that detection still blocks publication without printing values. + ## Test dimensions | Dimension | Coverage | diff --git a/scripts/scan-public.mjs b/scripts/scan-public.mjs new file mode 100644 index 0000000..0046b22 --- /dev/null +++ b/scripts/scan-public.mjs @@ -0,0 +1,109 @@ +import { execFileSync } from 'node:child_process' +import { readFile, readdir, stat } from 'node:fs/promises' +import { join, relative, resolve } from 'node:path' + +const credentials = + /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY|authorization:\s*bearer\s+[A-Za-z0-9._-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|api[_-]?key[^a-z0-9]{0,4}[:=]\s*["'][^"']{12,}["']/i +const locations = + /\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+|https?:\/\/[^/\s]+\.(?:internal|local)(?:\/|\s|$)|(?:^|[^0-9])(?:10\.(?:[0-9]{1,3}\.){2}[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3})(?:[^0-9]|$)/i +const imageMetadata = + /trc_[a-z0-9]{16,}|atomic_[a-z0-9-]{16,}|\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+/i +const ignored = new Set(['.git', 'node_modules', 'coverage']) +const excluded = new Set(['pnpm-lock.yaml', 'scripts/scan-public.sh', 'scripts/scan-public.mjs']) +const root = resolve(process.argv[2] ?? '.') +let failed = false +const report = (label, paths) => { + if (!paths.length) return + failed = true + process.stdout.write(`${label}:\n${paths.join('\n')}\n`) +} +const walk = async (directory) => { + const result = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (ignored.has(entry.name)) continue + const path = join(directory, entry.name) + if (entry.isDirectory()) { + result.push(...(await walk(path))) + } else if (entry.isSymbolicLink()) report('unsupported symbolic file', [relative(root, path)]) + else if (entry.isFile()) result.push(path) + } + return result +} +try { + if (!(await stat(root)).isDirectory()) throw new Error('invalid scan root') + const credentialFiles = [] + const locationFiles = [] + const imageFiles = [] + const dangerous = [] + for (const path of await walk(root)) { + const name = relative(root, path).split('\\').join('/') + const basename = name.split('/').at(-1) + if ( + !/\.(?:example|sample)$/.test(basename) && + /^(?:\.env(?:\..*)?|id_rsa(?:\..*)?|id_ed25519(?:\..*)?|\.DS_Store)$|\.(?:pem|key|p12|keystore)$/i.test( + basename, + ) + ) + dangerous.push(name) + if (excluded.has(name)) continue + const bytes = await readFile(path) + if (/\.(?:png|jpe?g|webp)$/i.test(name)) { + if (imageMetadata.test(bytes.toString('latin1'))) imageFiles.push(name) + continue + } + const content = bytes.toString('utf8') + if (credentials.test(content)) credentialFiles.push(name) + if (locations.test(content)) locationFiles.push(name) + } + report('dangerous files found', dangerous) + report('credential-shaped content', credentialFiles) + report('local absolute path or private-network literal', locationFiles) + report('sensitive-shaped image metadata', imageFiles) + let directory = root + let repository = false + for (;;) { + const entries = await readdir(directory) + if (entries.includes('.git')) { + repository = true + break + } + const parent = resolve(directory, '..') + if (parent === directory) break + directory = parent + } + if (repository) { + const history = execFileSync( + 'git', + [ + '-C', + root, + 'log', + '--all', + '-p', + '--', + '.', + ':!pnpm-lock.yaml', + ':!scripts/scan-public.sh', + ':!scripts/scan-public.mjs', + ], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 32 * 1024 * 1024, + }, + ) + const matches = history + .split('\n') + .filter( + (line) => credentials.test(line) || locations.test(line) || imageMetadata.test(line), + ).length + if (matches) + report('sensitive-shaped git history content detected; values withheld', [ + `${matches} matching lines`, + ]) + } + process.exitCode = failed ? 1 : 0 +} catch { + process.stderr.write('public-content scan could not complete; refusing a clean result\n') + process.exitCode = 2 +} diff --git a/scripts/scan-public.sh b/scripts/scan-public.sh index 1211868..d833269 100644 --- a/scripts/scan-public.sh +++ b/scripts/scan-public.sh @@ -1,61 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -root="${1:-.}" -status=0 -credential_pattern='(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|authorization:[[:space:]]*bearer[[:space:]]+[A-Za-z0-9._-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|sk-(proj-)?[A-Za-z0-9_-]{20,}|api[_-]?key[^[:alnum:]]{0,4}[:=][[:space:]]*[\x22\x27][^\x22\x27]{12,}[\x22\x27])' - -dangerous_files=$(find "$root" \ - -path '*/.git' -prune -o \ - -path '*/node_modules' -prune -o \ - -path '*/dist' -prune -o \ - -type f \( \ - -name '.env' -o -name '.env.*' -o -name '*.pem' -o -name '*.key' -o \ - -name 'id_rsa' -o -name '.DS_Store' \ - \) ! -name '.env.example' -print) - -if [[ -n "$dangerous_files" ]]; then - printf 'dangerous files found:\n%s\n' "$dangerous_files" - status=1 -fi - -scan() { - local label="$1" - local pattern="$2" - local output - output=$(rg -l -i --hidden \ - --glob '!.git/**' --glob '!node_modules/**' --glob '!coverage/**' --glob '!pnpm-lock.yaml' \ - --glob '!scripts/scan-public.sh' \ - "$pattern" "$root" || true) - if [[ -n "$output" ]]; then - printf '%s:\n%s\n' "$label" "$output" - status=1 - fi -} - -scan 'credential-shaped content' \ - "$credential_pattern" -scan 'local absolute path or private-network literal' \ - '(/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$)|(^|[^0-9])(10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3})([^0-9]|$))' - -if [[ -d "$root/docs/assets" ]]; then - image_metadata=$(find "$root/docs/assets" -type f \( \ - -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.webp' \ - \) -print0 | xargs -0 strings 2>/dev/null | \ - rg -n -i '(trc_[a-z0-9]{16,}|atomic_[a-z0-9-]{16,}|/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+)' || true) - if [[ -n "$image_metadata" ]]; then - printf 'sensitive-shaped image metadata found\n' - status=1 - fi -fi - -if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - history=$(git -C "$root" log --all -p -- . ':!pnpm-lock.yaml' ':!scripts/scan-public.sh' | \ - rg --count-matches -i "$credential_pattern|/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|https?://[^/[:space:]]+\.(internal|local)(/|[[:space:]]|$)" || true) - if [[ -n "$history" ]]; then - printf 'sensitive-shaped git history content detected: %s matching lines; values withheld\n' "$history" - status=1 - fi -fi - -exit "$status" +# A missing optional search binary must never produce a clean result. +script_directory=$(cd -- "${BASH_SOURCE[0]%/*}" && pwd) +exec node "$script_directory/scan-public.mjs" "${1:-.}" diff --git a/tests/public-scan.test.ts b/tests/public-scan.test.ts index ec5e2e9..6dea289 100644 --- a/tests/public-scan.test.ts +++ b/tests/public-scan.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -18,6 +18,31 @@ const scan = (root: string) => spawnSync('bash', [scanner, '.'], { cwd: root, encoding: 'utf8', timeout: 5_000 }) describe('public-content scanner', () => { + it('detects credentials without relying on ripgrep being installed', async () => { + const root = await fixture() + const tools = join(root, 'tools') + const target = join(root, 'target') + await mkdir(tools) + await mkdir(target) + for (const [name, executable] of Object.entries({ + node: process.execPath, + find: '/usr/bin/find', + git: '/usr/bin/git', + })) { + await symlink(executable, join(tools, name)) + } + await writeFile(join(target, 'probe.txt'), ['ghp', '_', 'x'.repeat(40)].join('')) + const result = spawnSync('/bin/bash', [scanner, '.'], { + cwd: target, + env: { ...process.env, PATH: tools }, + encoding: 'utf8', + timeout: 5_000, + }) + expect(result.status, result.stderr).toBe(1) + expect(result.stdout).toContain('probe.txt') + expect(result.stderr).not.toContain('command not found') + }) + it('reports credential locations without printing matching values', async () => { const root = await fixture() const synthetic = `synthetic-${'x'.repeat(30)}` @@ -77,4 +102,18 @@ describe('public-content scanner', () => { await writeFile(join(root, '.env'), 'synthetic fixture') expect(scan(root).status).toBe(1) }) + + it('fails closed for an invalid root and unreadable repository history', async () => { + const root = await fixture() + const missing = spawnSync('bash', [scanner, 'missing-directory'], { + cwd: root, + encoding: 'utf8', + }) + expect(missing.status).toBe(2) + expect(missing.stderr).toContain('refusing a clean result') + await mkdir(join(root, '.git')) + const brokenHistory = scan(root) + expect(brokenHistory.status).toBe(2) + expect(brokenHistory.stderr).toContain('refusing a clean result') + }) }) From 88ebf8e0b458eb8a7788cefcc8f30b88ba257e19 Mon Sep 17 00:00:00 2001 From: KevinTen <22876610+kevinten10@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:28:19 +0800 Subject: [PATCH 3/3] test: separate command startup headroom from deadline assertions --- tests/local-provider.test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/local-provider.test.ts b/tests/local-provider.test.ts index e3e1e23..c2c3794 100644 --- a/tests/local-provider.test.ts +++ b/tests/local-provider.test.ts @@ -12,8 +12,9 @@ const providers: LocalSandboxProvider[] = [] const roots: string[] = [] const context = { requestId: 'local-test' } -const makeProvider = (): LocalSandboxProvider => { - const result = new LocalSandboxProvider({ defaultTimeoutSeconds: 1 }) +const makeProvider = (defaultTimeoutSeconds = 5): LocalSandboxProvider => { + // Successful commands need startup headroom under coverage; deadline tests set their own limit. + const result = new LocalSandboxProvider({ defaultTimeoutSeconds }) providers.push(result) return result } @@ -36,11 +37,24 @@ describe('LocalSandboxProvider', () => { }, context, ) - expect(result.exitCode).toBe(0) + expect(result).toMatchObject({ exitCode: 0, timedOut: false, cancelled: false }) expect(result.stdout.endsWith('|ok')).toBe(true) expect(result.timedOut).toBe(false) }) + it('applies the configured default command deadline without a request override', async () => { + const runtime = new InMemorySandboxRuntime(makeProvider(0.02), { + idFactory: () => 'sandbox-1', + }) + await runtime.create({ clientRequestId: 'create-1' }, context) + const result = await runtime.execute( + 'sandbox-1', + { expectedGeneration: 1, argv: [process.execPath, '-e', 'setTimeout(()=>{}, 10000)'] }, + context, + ) + expect(result).toMatchObject({ exitCode: null, timedOut: true, cancelled: false }) + }) + it('captures stderr, bounds output, and terminates timed-out commands', async () => { const runtime = new InMemorySandboxRuntime(makeProvider(), { idFactory: () => 'sandbox-1' }) await runtime.create({ clientRequestId: 'create-1' }, context)