machine-bridge-mcp 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ machine-mcp service status
14
14
 
15
15
  | Result | Interpretation |
16
16
  |---|---|
17
+ | `server_info` reports full and all relay tools, but the current session UI exposes fewer tools | Host/connector post-relay filtering; Machine Bridge cannot enumerate or override that subset |
17
18
  | No structured result because the host rejects the call | Host/connector approval or safety layer, or transport before daemon delivery |
18
19
  | `mcp-host-to-daemon` passes but `local-filesystem` fails | Local state/runtime permissions, disk policy, sandbox, or endpoint security |
19
20
  | Filesystem passes but `local-process-spawn` fails | Local executable policy, endpoint security, OS permissions, or damaged Node runtime |
@@ -21,19 +22,19 @@ machine-mcp service status
21
22
  | `managed-job-storage` fails | Owner-only profile/job directory cannot be used |
22
23
  | Registered resource is unavailable | File moved, permissions changed, size exceeded, or local access denied |
23
24
 
24
- A successful diagnostic result applies only to that probe. An MCP host can still deny a later call based on its own request context.
25
+ A successful diagnostic result applies only to that probe. An MCP host can still deny a later call based on its own request context. This is expected layering, not a defect in the `full` profile: `full` removes Machine Bridge's own denials, while host delivery remains independent.
25
26
 
26
27
  ## Logs
27
28
 
28
- Remote autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup.
29
+ Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup.
29
30
 
30
31
  Logging is level-based:
31
32
 
32
33
  ```text
33
- error unrecoverable local/transport failures
34
- warn failed calls, disconnects, malformed relay events
35
- info startup/deploy/connect transitions and calls slower than 30 seconds
36
- debug routine successful calls, shortened correlation IDs, cancellation/reconnect details
34
+ error unrecoverable local/transport/service failures
35
+ warn relay disconnect/send failures, malformed relay events, supersession and service problems
36
+ info startup/deploy/connect transitions
37
+ debug all per-tool starts/successes/failures/cancellations/timing, correlation and reconnect details
37
38
  ```
38
39
 
39
40
  Foreground mode defaults to `info`; autostart uses `warn`. Use `--verbose` or `--log-level debug` only for diagnosis. `--quiet` is an alias for `--log-level error`.
@@ -42,13 +43,32 @@ Normal logs intentionally omit tool arguments, file/patch/image content, command
42
43
 
43
44
  See [LOGGING.md](LOGGING.md) for the event contract and MCP-host boundary. Cloudflare observability is sampled and is not a complete audit log.
44
45
 
46
+ ## Full capability acceptance
47
+
48
+ Run:
49
+
50
+ ```sh
51
+ machine-mcp full-test --workspace /path/to/project
52
+ ```
53
+
54
+ The command uses disposable local directories and performs actual read/write, process, shell, environment, SSH-key, sandbox authorized-key, SSH-client, managed-job and finally-cleanup operations. It also checks whether the Google Cloud OS Login command exists and whether `sudo -n true` is currently permitted, without changing either system. `ok` covers core Machine Bridge functionality; `operator_workflow_ready` additionally reports the local SSH/Google CLI prerequisites. No external cloud, account, or server change is made.
55
+
56
+ Generate and register an operator key locally:
57
+
58
+ ```sh
59
+ machine-mcp resource generate-ssh-key NAME [PRIVATE_KEY_PATH]
60
+ ```
61
+
62
+ An authorized canonical-full MCP client can use `generate_ssh_key_resource`. Both paths validate the key pair and return only metadata and the bare public fingerprint. Local paths are omitted unless `--show-paths` or `expose_paths=true` is explicitly requested; public-key comments are not included in the returned fingerprint. They do not install the public key in Google, modify `authorized_keys`, or grant remote `sudo`; those remain explicit managed-job/local-operator operations.
63
+
45
64
  ## Managed jobs and local recovery
46
65
 
47
66
  Register local-only resources from the terminal:
48
67
 
49
68
  ```sh
50
69
  machine-mcp resource add NAME FILE_PATH
51
- machine-mcp resource list
70
+ machine-mcp resource list # paths omitted by default
71
+ machine-mcp resource list --show-paths # explicit local-only disclosure
52
72
  machine-mcp resource check NAME
53
73
  machine-mcp resource remove NAME
54
74
  ```
@@ -84,8 +104,9 @@ Pending calls are bound to the socket that received them. Results from another s
84
104
  Defense-in-depth limits include:
85
105
 
86
106
  - Worker MCP body: 8 MiB by default, hard cap 16 MiB;
107
+ - stdio JSON-RPC line: 8 MiB, enforced incrementally while reading;
87
108
  - OAuth body: 64 KiB;
88
- - daemon WebSocket message: 8 MiB;
109
+ - daemon WebSocket message: 8 MiB, enforced by the local WebSocket parser before string conversion;
89
110
  - text writes and patch envelopes: 5 MiB;
90
111
  - images: 4 MiB before base64 encoding;
91
112
  - shell/argv envelope: 64 KiB;
@@ -108,9 +129,9 @@ Defense-in-depth limits include:
108
129
 
109
130
  ## Upgrade behavior
110
131
 
111
- Version 0.5 records policy origin and revision. A state entry matching the exact legacy implicit-default shape—write enabled, shell enabled, workspace-confined paths, isolated environment, and relative output—is migrated once to the current `full` default. Explicit named profiles and identified custom policies are preserved.
132
+ Policy revision 3 makes named profiles canonical. A state entry labelled `full` is repaired to writes, direct processes, process sessions, shell execution, unrestricted direct filesystem paths, absolute path output, the complete parent environment, and the complete tool catalog. CLI capability overrides are stored as `custom`. The exact pre-0.4 implicit-default shape is still migrated to the current `full` default; explicit restrictive and identified custom profiles remain preserved.
112
133
 
113
- `full` enables writes, direct processes, process sessions, shell execution, unrestricted direct filesystem paths, absolute path output, and the complete parent environment. It does not override operating-system access controls or independent MCP-host/platform policy.
134
+ `full` removes Machine Bridge's own profile/path/environment/shell denials and makes the complete catalog available to the relay. It does not force a connector host to expose every relayed tool, and the server cannot see the host's final subset. It also does not override operating-system access controls, endpoint security, remote authentication, cloud IAM, `sudo`, or independent MCP-host/platform policy.
114
135
 
115
136
  Inspect effective policy with:
116
137
 
@@ -0,0 +1,37 @@
1
+ # Repository privacy hygiene
2
+
3
+ Source code, tests, examples, release notes, and documentation are publication surfaces. Examples must use synthetic identifiers such as `maintenance-key`, `admin@server.example`, reserved example domains, and generic filesystem paths. Do not copy a real server alias, username, hostname, account name, workspace path, key filename, customer name, or internal codename into a fixture merely because the value is not itself a credential.
4
+
5
+ ## Automated check
6
+
7
+ Run:
8
+
9
+ ```sh
10
+ npm run privacy:check
11
+ ```
12
+
13
+ The check scans tracked and unignored new UTF-8 files and their relative names for private-key material, common live-token forms, user-home paths, non-example `user@host` identifiers, and locally configured private identifiers. Publication-surface symbolic links are rejected rather than followed. Binary, invalid UTF-8, and files above the bounded scanner limit fail closed and require explicit manual review instead of being silently skipped. It reports only the file, line, and rule; it does not print the matched value.
14
+
15
+ Maintain machine-specific names in an ignored owner-only file:
16
+
17
+ ```sh
18
+ cp .privacy-denylist.example .privacy-denylist
19
+ chmod 600 .privacy-denylist
20
+ ```
21
+
22
+ Add one identifier per line. The denylist is deliberately local and must never be committed. CI still runs the built-in generic checks; a developer's local check adds their private vocabulary.
23
+
24
+ ## Review rules
25
+
26
+ Before committing or publishing:
27
+
28
+ - inspect the complete staged diff, including tests, snapshots, examples, release notes, and generated metadata;
29
+ - use reserved example domains and neutral aliases;
30
+ - run `npm run privacy:check`, `npm run check`, and `npm pack --dry-run`;
31
+ - treat paths, host aliases, usernames, and codenames as private metadata even when they are not authentication secrets.
32
+
33
+ The scanner is heuristic. It cannot identify every personal or organizational name, transformed value, image, archive, binary fixture, or data already present in Git history.
34
+
35
+ ## Incident response
36
+
37
+ For an accidental publication, remove the value from the current tree and release artifacts, determine whether it is merely identifying metadata or an active credential, and rotate/revoke any credential immediately. Public Git and npm history are immutable in ordinary workflows: replacing the current file does not erase old commits or a published package. A coordinated history rewrite, cache invalidation request, or replacement release may be appropriate, but those actions are disruptive and require an explicit repository-owner decision.
package/docs/RELEASING.md CHANGED
@@ -7,6 +7,8 @@ The release invariant is:
7
7
  - A final GitHub Release exists for the tag.
8
8
  - The GitHub Release contains the npm tarball generated from that commit.
9
9
  - `package.json`, `package-lock.json`, and the Worker-reported version agree.
10
+ - Every release-relevant change since the prior version tag has a higher package version and matching CHANGELOG section.
11
+ - The same reviewed change is present on GitHub and in a new npm version.
10
12
 
11
13
  `npm publish` runs `release:check` through `prepublishOnly`, so npm publication is blocked until all GitHub state is synchronized.
12
14
 
@@ -18,8 +20,11 @@ The release invariant is:
18
20
  npm version <version> --no-git-tag-version
19
21
  ```
20
22
 
21
- 2. Add the matching `CHANGELOG.md` section.
22
- 3. Run `npm run check`, review the diff, and commit all release changes to `main`.
23
+ 2. Add the matching dated `CHANGELOG.md` section.
24
+ 3. Run `npm run release-impact:check`, `npm run privacy:check`, `npm run check`, both dependency audits, `npm audit signatures`, and generate a CycloneDX `npm sbom`.
25
+ 4. Inspect the complete diff and `npm pack --dry-run`, then commit and push all release changes to `main`.
26
+
27
+ A privacy/security documentation correction is not “docs only” for release purposes. It requires a replacement npm version and, when appropriate, deprecation or unpublication of the affected version.
23
28
 
24
29
  ## Publish GitHub source and release
25
30
 
@@ -29,7 +34,7 @@ From a clean `main` worktree:
29
34
  npm run release:publish
30
35
  ```
31
36
 
32
- The command validates the project, fast-forwards `origin/main`, creates or verifies the annotated version tag, pushes it, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state.
37
+ The command validates the project, including repository privacy checks, fast-forwards `origin/main`, creates or verifies the annotated version tag, pushes it, builds the npm tarball, creates or updates the GitHub Release, uploads the tarball, and verifies the resulting state. Read-only GitHub operations, Git pushes, and idempotent release updates use bounded retries only for classified transient network failures; ambiguous Release creation responses are resolved by querying server state before continuing.
33
38
 
34
39
  To verify without changing anything:
35
40
 
@@ -51,7 +56,7 @@ Only after `release:check` succeeds:
51
56
  npm publish --access public
52
57
  ```
53
58
 
54
- The npm lifecycle repeats the full project checks and the GitHub synchronization check before upload.
59
+ The npm lifecycle repeats the full project checks and the GitHub synchronization check before upload. Do not leave a code or documentation fix only on GitHub; the corresponding npm version must be published, and the operator should be explicitly reminded until registry publication is confirmed.
55
60
 
56
61
  ## Authentication requirements
57
62
 
package/docs/TESTING.md CHANGED
@@ -8,8 +8,11 @@ The project treats transport, authorization, local authority, and state removal
8
8
  npm run check
9
9
  ```
10
10
 
11
+ The repository requires Node.js 26 and npm 12. `.node-version`, `.nvmrc`, `packageManager`, `devEngines`, and strict engine checks keep local and CI execution on the same baseline.
12
+
11
13
  The suite includes:
12
14
 
15
+ - release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
13
16
  - generated Cloudflare Worker types and strict TypeScript checking;
14
17
  - syntax validation for every shipped JavaScript entry point;
15
18
  - shared tool-catalog schema, annotation, and profile-inventory checks;
@@ -25,11 +28,15 @@ The suite includes:
25
28
  - one-shot timeout, descendant termination, cancellation, and process-session interaction;
26
29
  - layered fixed runtime diagnostics for filesystem, direct process, shell, managed-job storage, and resource availability;
27
30
  - local resource CLI registration, permission checks, dynamic reload, state-path redaction, and content non-disclosure;
31
+ - real Ed25519 and RSA generation, idempotent reuse, public/private correspondence, mode enforcement, incomplete/mismatched/symlink rejection, and private-content non-disclosure;
32
+ - real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
33
+ - deterministic injected atomic-replace failures and repeated Windows full-sandbox runs to catch transient file-sharing races;
34
+ - canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
28
35
  - managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, cancellation escalation, plan scrubbing, and dead-runner recovery;
29
36
  - daemon/startup locking and state corruption recovery;
30
37
  - guarded state-root removal, schema migration, policy-origin persistence, and legacy implicit-default migration;
31
38
  - no filename-based sensitive-file denial under unrestricted policy;
32
- - log redaction, control-character handling, message/field bounds, default success-log suppression, and service warning-level configuration;
39
+ - log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, and service warning-level configuration;
33
40
  - CLI parsing, policy profiles, and client configuration boundaries;
34
41
  - live stdio MCP initialization, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
35
42
  - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, and cancellation.
@@ -40,11 +47,14 @@ The suite includes:
40
47
  npm run worker:dry-run
41
48
  npm audit --audit-level=high
42
49
  npm audit --omit=dev --audit-level=high
50
+ npm audit signatures
51
+ npm sbom --sbom-format cyclonedx
43
52
  npm pack --dry-run
44
53
  npm run version:check
54
+ npm run release-impact:check
45
55
  ```
46
56
 
47
- GitHub Actions executes the main suite on Linux, macOS, and Windows. Node 22 and 24 are covered on Linux; Node 22 is covered on macOS and Windows. A separate package-audit job audits both the complete dependency graph and the production-only graph, then performs a dry-run package build. Dependency and GitHub Actions updates are monitored by Dependabot.
57
+ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pinned Node 26/npm 12 baseline. Because Node 26 currently bundles npm 11, CI explicitly disables setup-node's automatic package-manager cache and upgrades npm from the runner temporary directory before any project-local npm command can trigger strict `devEngines`. Checkout fetches version tags so the release-impact gate can compare the branch with the latest release. A separate package-audit job audits both the complete dependency graph and the production-only graph, verifies registry signatures and attestations, validates a CycloneDX SBOM written under the runner temporary directory, then performs a dry-run package build. Dependency and GitHub Actions updates are monitored by Dependabot.
48
58
 
49
59
  ## Test design rules
50
60
 
@@ -57,3 +67,13 @@ GitHub Actions executes the main suite on Linux, macOS, and Windows. Node 22 and
57
67
  - Secret-bearing resource tests must assert absence of raw, path, base64, and hex forms from MCP-visible results.
58
68
  - Logs and public metadata should be tested for absence of sensitive fields, arguments, outputs, and routine success noise—not only presence of expected fields.
59
69
  - Cross-platform tests must avoid shell syntax, URL-path conversion, and executable-shim assumptions specific to one operating system.
70
+
71
+ ## Privacy hygiene
72
+
73
+ Run `npm run privacy:check` before committing and before packaging. Developers should maintain an ignored owner-only `.privacy-denylist` for machine aliases, usernames, internal codenames, and other private identifiers that a generic scanner cannot know. See [Repository privacy hygiene](PRIVACY.md).
74
+
75
+ ## Package manifest
76
+
77
+ `npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, and verifies that privacy guidance, contribution/release discipline, and both privacy/release-impact checkers are present in the published package.
78
+
79
+ The stdio integration test also sends an oversized line, verifies bounded rejection, and confirms that the next valid request is still processed.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.6.0",
4
- "description": "Cross-client MCP bridge for policy-controlled local files, Git, images, patches, and processes over stdio or OAuth-protected remote relay.",
3
+ "version": "0.7.1",
4
+ "description": "Cross-client MCP bridge for canonical local automation, files, Git, SSH resources, managed jobs, and processes over stdio or an OAuth-protected remote relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
8
- "node": ">=22.0.0"
8
+ "node": ">=26.0.0"
9
9
  },
10
10
  "bin": {
11
11
  "machine-bridge-mcp": "bin/machine-mcp.mjs",
@@ -20,11 +20,15 @@
20
20
  "src/shared",
21
21
  "src/worker/index.ts",
22
22
  "scripts/sync-worker-version.mjs",
23
+ "scripts/privacy-check.mjs",
24
+ "scripts/release-impact-check.mjs",
25
+ "scripts/network-retry.mjs",
23
26
  "wrangler.jsonc",
24
27
  "tsconfig.json",
25
28
  "README.md",
26
29
  "SECURITY.md",
27
30
  "CHANGELOG.md",
31
+ "CONTRIBUTING.md",
28
32
  "docs",
29
33
  "LICENSE"
30
34
  ],
@@ -35,12 +39,12 @@
35
39
  "version:sync": "node scripts/sync-worker-version.mjs",
36
40
  "version:check": "node scripts/sync-worker-version.mjs --check",
37
41
  "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts",
38
- "prepack": "npm run version:check",
42
+ "prepack": "npm run version:check && npm run privacy:check && npm run release-impact:check",
39
43
  "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
40
44
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
41
45
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
42
- "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/local-self-test.mjs && node --check tests/daemon-self-test.mjs && node --check tests/managed-jobs-test.mjs",
43
- "check": "npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
46
+ "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/daemon.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/local-self-test.mjs && node --check tests/daemon-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs",
47
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run network-retry:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
44
48
  "worker:dry-run": "wrangler deploy --dry-run",
45
49
  "worker:integration-test": "node tests/worker-integration-test.mjs",
46
50
  "release:check": "node scripts/github-release.mjs --check",
@@ -48,15 +52,24 @@
48
52
  "release:backfill": "node scripts/github-release.mjs --backfill",
49
53
  "stdio:integration-test": "node tests/stdio-integration-test.mjs",
50
54
  "catalog:test": "node tests/catalog-test.mjs",
51
- "managed-jobs:test": "node tests/managed-jobs-test.mjs"
55
+ "managed-jobs:test": "node tests/managed-jobs-test.mjs",
56
+ "ssh-key:test": "node tests/ssh-key-test.mjs",
57
+ "full-access:test": "node tests/full-access-test.mjs",
58
+ "atomic-fs:test": "node tests/atomic-fs-test.mjs",
59
+ "privacy:check": "node scripts/privacy-check.mjs",
60
+ "package:test": "node tests/package-test.mjs",
61
+ "release-impact:check": "node scripts/release-impact-check.mjs",
62
+ "release-impact:test": "node tests/release-impact-test.mjs",
63
+ "privacy:test": "node tests/privacy-test.mjs",
64
+ "network-retry:test": "node tests/network-retry-test.mjs"
52
65
  },
53
66
  "dependencies": {
54
67
  "wrangler": "4.110.0",
55
68
  "ws": "8.21.0"
56
69
  },
57
70
  "devDependencies": {
58
- "@types/node": "^22.20.1",
59
- "typescript": "^7.0.2"
71
+ "@types/node": "26.1.1",
72
+ "typescript": "7.0.2"
60
73
  },
61
74
  "keywords": [
62
75
  "mcp",
@@ -68,7 +81,10 @@
68
81
  "stdio",
69
82
  "coding-agent",
70
83
  "oauth",
71
- "cross-platform"
84
+ "cross-platform",
85
+ "ssh",
86
+ "automation",
87
+ "managed-jobs"
72
88
  ],
73
89
  "repository": {
74
90
  "type": "git",
@@ -83,5 +99,18 @@
83
99
  "sharp@0.34.5": true,
84
100
  "workerd@1.20260708.1": true,
85
101
  "fsevents": false
102
+ },
103
+ "packageManager": "npm@12.0.1",
104
+ "devEngines": {
105
+ "runtime": {
106
+ "name": "node",
107
+ "version": ">=26.0.0",
108
+ "onFail": "error"
109
+ },
110
+ "packageManager": {
111
+ "name": "npm",
112
+ "version": ">=12.0.0",
113
+ "onFail": "error"
114
+ }
86
115
  }
87
116
  }
@@ -0,0 +1,47 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
4
+ const TRANSIENT_NETWORK_FAILURE = /(?:SSL_ERROR_SYSCALL|SSL_connect|TLS handshake timeout|Client network socket disconnected before secure TLS connection was established|unexpected EOF|unexpected eof while reading|\bEOF\b|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|ECONNREFUSED|Could not resolve host|Failed to connect|connection (?:was )?reset|remote end hung up|unexpected disconnect|HTTP (?:429|5\d\d)|(?:502|503|504) (?:Bad Gateway|Service Unavailable|Gateway Timeout))/i;
5
+
6
+ export function runNetworkCommand(command, args, options = {}) {
7
+ const attempts = clampInteger(options.attempts, 3, 1, 5);
8
+ const baseDelayMs = clampInteger(options.baseDelayMs, 750, 0, 10_000);
9
+ const actualArgs = networkCommandArgs(command, args);
10
+ let result;
11
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
12
+ result = spawnSync(command, actualArgs, {
13
+ cwd: options.cwd,
14
+ encoding: "utf8",
15
+ stdio: "pipe",
16
+ env: options.env,
17
+ windowsHide: true,
18
+ });
19
+ result.attempts = attempt;
20
+ if (!isTransientNetworkFailure(result) || attempt === attempts) return result;
21
+ sleepSync(Math.min(baseDelayMs * attempt, 5_000));
22
+ }
23
+ return result;
24
+ }
25
+
26
+ export function networkCommandArgs(command, args) {
27
+ return command === "git" ? ["-c", "http.version=HTTP/1.1", ...args] : [...args];
28
+ }
29
+
30
+ export function isTransientNetworkFailure(result) {
31
+ if (result?.error) {
32
+ const code = String(result.error.code || "");
33
+ if (["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH", "ECONNREFUSED"].includes(code)) return true;
34
+ }
35
+ const detail = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}\n${result?.error?.message ?? ""}`;
36
+ return TRANSIENT_NETWORK_FAILURE.test(detail);
37
+ }
38
+
39
+ function sleepSync(milliseconds) {
40
+ if (milliseconds > 0) Atomics.wait(WAIT_BUFFER, 0, 0, milliseconds);
41
+ }
42
+
43
+ function clampInteger(value, fallback, minimum, maximum) {
44
+ const parsed = Number.parseInt(String(value ?? ""), 10);
45
+ const number = Number.isFinite(parsed) ? parsed : fallback;
46
+ return Math.min(Math.max(number, minimum), maximum);
47
+ }
@@ -0,0 +1,177 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const selfPath = "scripts/privacy-check.mjs";
8
+ const candidates = collectCandidateFiles(root);
9
+ const denylist = loadDenylist(path.join(root, ".privacy-denylist"));
10
+ const findings = [];
11
+
12
+ for (const relativePath of candidates) {
13
+ if (relativePath === ".privacy-denylist") continue;
14
+ scanDenylistPath(relativePath, denylist, findings);
15
+ const fullPath = path.join(root, relativePath);
16
+ let info;
17
+ try { info = lstatSync(fullPath); } catch { continue; }
18
+ if (info.isSymbolicLink()) {
19
+ findings.push({ path: relativePath, line: 1, rule: "symbolic link in publication surface" });
20
+ continue;
21
+ }
22
+ if (!info.isFile()) continue;
23
+ let buffer;
24
+ try { buffer = readFileSync(fullPath); } catch { continue; }
25
+ if (buffer.length > 5 * 1024 * 1024) {
26
+ findings.push({ path: relativePath, line: 1, rule: "file exceeds privacy scanner size limit and requires manual review" });
27
+ continue;
28
+ }
29
+ if (buffer.includes(0)) {
30
+ findings.push({ path: relativePath, line: 1, rule: "binary file in publication surface requires manual review" });
31
+ continue;
32
+ }
33
+ let text;
34
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
35
+ findings.push({ path: relativePath, line: 1, rule: "non-UTF-8 file in publication surface requires manual review" });
36
+ continue;
37
+ }
38
+ if (relativePath !== selfPath) scanBuiltIn(relativePath, text, findings);
39
+ scanDenylist(relativePath, text, denylist, findings);
40
+ }
41
+
42
+ if (findings.length) {
43
+ for (const finding of findings.slice(0, 100)) {
44
+ process.stderr.write(`${redactReportPath(finding.path, denylist)}:${finding.line}: ${finding.rule}\n`);
45
+ }
46
+ if (findings.length > 100) process.stderr.write(`... ${findings.length - 100} additional findings omitted\n`);
47
+ process.stderr.write("Privacy check failed. Replace private identifiers with synthetic examples or review the local denylist.\n");
48
+ process.exit(1);
49
+ }
50
+
51
+ process.stderr.write(`privacy check ok (${candidates.length} tracked/unignored files; ${denylist.length} local denylist entries)\n`);
52
+
53
+ function collectCandidateFiles(directory) {
54
+ try {
55
+ return execFileSync("git", ["-C", directory, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
56
+ encoding: "buffer",
57
+ maxBuffer: 32 * 1024 * 1024,
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ }).toString("utf8").split("\0").filter(Boolean).sort();
60
+ } catch {
61
+ const excluded = new Set([".git", ".wrangler", "node_modules"]);
62
+ const files = [];
63
+ const stack = [""];
64
+ while (stack.length) {
65
+ const relative = stack.pop();
66
+ const absolute = path.join(directory, relative);
67
+ let entries;
68
+ try { entries = readdirSync(absolute, { withFileTypes: true }); } catch { continue; }
69
+ for (const entry of entries) {
70
+ if (excluded.has(entry.name) || entry.name === ".privacy-denylist") continue;
71
+ const child = relative ? path.join(relative, entry.name) : entry.name;
72
+ if (entry.isDirectory()) stack.push(child);
73
+ else if (entry.isFile() || entry.isSymbolicLink()) files.push(child.split(path.sep).join("/"));
74
+ }
75
+ }
76
+ return files.sort();
77
+ }
78
+ }
79
+
80
+ function redactReportPath(relativePath, entries) {
81
+ let shown = String(relativePath);
82
+ for (const entry of entries) {
83
+ const expression = new RegExp(escapeRegExp(entry), "gi");
84
+ shown = shown.replace(expression, "<private>");
85
+ }
86
+ return shown;
87
+ }
88
+
89
+ function escapeRegExp(value) {
90
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ }
92
+
93
+ function scanBuiltIn(relativePath, text, out) {
94
+ const rules = [
95
+ ["private key material", /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g],
96
+ ["AWS access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
97
+ ["GitHub access token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g],
98
+ ["API secret token", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g],
99
+ ["absolute macOS/Linux home path", /(?:^|[\s"'=(:,])\/(?:Users|home)\/([^/\s"'<>]+)\//gm],
100
+ ["absolute Windows home path", /(?:^|[\s"'=(:,])\b[A-Za-z]:\\Users\\([^\\\s"'<>]+)\\/gm],
101
+ ];
102
+ for (const [rule, expression] of rules) {
103
+ for (const match of text.matchAll(expression)) {
104
+ if (rule.includes("home path") && isSyntheticUser(String(match[1] || ""))) continue;
105
+ out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule });
106
+ }
107
+ }
108
+ const sshLike = /\b[A-Za-z_][A-Za-z0-9._-]*@([A-Za-z0-9.-]+)\b/g;
109
+ for (const match of text.matchAll(sshLike)) {
110
+ if (isReservedExampleHost(match[1]) || !hasNearbySshContext(text, match.index || 0)) continue;
111
+ out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule: "non-example SSH user@host identifier" });
112
+ }
113
+ }
114
+
115
+ function scanDenylistPath(relativePath, entries, out) {
116
+ const lower = relativePath.toLocaleLowerCase("en-US");
117
+ for (const entry of entries) {
118
+ if (lower.includes(entry)) out.push({ path: relativePath, line: 1, rule: "local private-identifier denylist match in file path" });
119
+ }
120
+ }
121
+
122
+ function scanDenylist(relativePath, text, entries, out) {
123
+ const lower = text.toLocaleLowerCase("en-US");
124
+ for (const entry of entries) {
125
+ let offset = 0;
126
+ while ((offset = lower.indexOf(entry, offset)) !== -1) {
127
+ out.push({ path: relativePath, line: lineNumber(text, offset), rule: "local private-identifier denylist match" });
128
+ offset += Math.max(1, entry.length);
129
+ }
130
+ }
131
+ }
132
+
133
+ function loadDenylist(file) {
134
+ if (!existsSync(file)) return [];
135
+ let text;
136
+ try { text = readFileSync(file, "utf8"); } catch { return []; }
137
+ return [...new Set(text.split(/\r?\n/)
138
+ .map((line) => line.trim())
139
+ .filter((line) => line && !line.startsWith("#") && line.length >= 3)
140
+ .map((line) => line.toLocaleLowerCase("en-US")))];
141
+ }
142
+
143
+ function isSyntheticUser(value) {
144
+ return /^(?:user|username|example|test|name|home|runner|workspace|tmp|private|path|package)$/i.test(value)
145
+ || /^<[^>]+>$/.test(value);
146
+ }
147
+
148
+ function hasNearbySshContext(text, offset) {
149
+ const lineStart = Math.max(0, text.lastIndexOf("\n", offset - 1));
150
+ let start = lineStart;
151
+ for (let count = 0; count < 4 && start > 0; count += 1) start = Math.max(0, text.lastIndexOf("\n", start - 1));
152
+ let end = offset;
153
+ for (let count = 0; count < 4 && end < text.length; count += 1) {
154
+ const next = text.indexOf("\n", end + 1);
155
+ end = next === -1 ? text.length : next;
156
+ }
157
+ return /\b(?:ssh|scp|sftp)\b/i.test(text.slice(start, end));
158
+ }
159
+
160
+ function isReservedExampleHost(host) {
161
+ const value = String(host || "").toLowerCase().replace(/\.$/, "");
162
+ return value === "localhost"
163
+ || value === "127.0.0.1"
164
+ || value === "::1"
165
+ || value === "example"
166
+ || value.endsWith(".example")
167
+ || value.endsWith(".example.com")
168
+ || value.endsWith(".example.net")
169
+ || value.endsWith(".example.org")
170
+ || value.endsWith(".invalid");
171
+ }
172
+
173
+ function lineNumber(text, offset) {
174
+ let line = 1;
175
+ for (let index = 0; index < offset; index += 1) if (text.charCodeAt(index) === 10) line += 1;
176
+ return line;
177
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+
6
+ const VERSION_TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
7
+
8
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
+ const current = parseVersion(String(pkg.version || ""));
10
+ if (!current) fail("package.json contains an invalid version");
11
+
12
+ const tags = git(["tag", "--merged", "HEAD", "--sort=-version:refname", "--list", "v[0-9]*"])
13
+ .split("\n")
14
+ .map((value) => value.trim())
15
+ .filter((value) => VERSION_TAG.test(value));
16
+ if (!tags.length) {
17
+ process.stderr.write("release impact check skipped: no reachable version tag\n");
18
+ process.exit(0);
19
+ }
20
+
21
+ const latestTag = tags[0];
22
+ const latest = parseVersion(latestTag.slice(1));
23
+ const changed = new Set([
24
+ ...lines(git(["diff", "--name-only", latestTag, "--"])),
25
+ ...lines(git(["ls-files", "--others", "--exclude-standard"])),
26
+ ]);
27
+ const relevant = [...changed].sort();
28
+
29
+ if (!relevant.length) {
30
+ process.stderr.write(`release impact check ok: no release-relevant changes since ${latestTag}\n`);
31
+ process.exit(0);
32
+ }
33
+ if (compareVersions(current, latest) <= 0) {
34
+ fail(`release-relevant changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
35
+ }
36
+
37
+ const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
38
+ const heading = new RegExp(`^## ${escapeRegExp(pkg.version)}(?:\\s+-[^\\n]*)?$`, "m");
39
+ if (!heading.test(changelog)) fail(`CHANGELOG.md has no section for ${pkg.version}`);
40
+
41
+ process.stderr.write(`release impact check ok: ${relevant.length} release-relevant file(s) since ${latestTag}; next npm version ${pkg.version}\n`);
42
+
43
+ function git(args) {
44
+ try {
45
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
46
+ } catch (error) {
47
+ const detail = String(error?.stderr || error?.message || error).trim();
48
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
49
+ }
50
+ }
51
+
52
+ function lines(value) {
53
+ return value ? value.split("\n").map((item) => item.trim()).filter(Boolean) : [];
54
+ }
55
+
56
+ function parseVersion(value) {
57
+ const match = String(value).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
58
+ return match ? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] || "" } : null;
59
+ }
60
+
61
+ function compareVersions(left, right) {
62
+ for (const key of ["major", "minor", "patch"]) {
63
+ if (left[key] !== right[key]) return left[key] - right[key];
64
+ }
65
+ if (left.prerelease === right.prerelease) return 0;
66
+ if (!left.prerelease) return 1;
67
+ if (!right.prerelease) return -1;
68
+ return left.prerelease.localeCompare(right.prerelease);
69
+ }
70
+
71
+ function escapeRegExp(value) {
72
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
+ }
74
+
75
+ function fail(message) {
76
+ process.stderr.write(`release impact check failed: ${message}\n`);
77
+ process.exit(1);
78
+ }