Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions .github/workflows/publish-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,29 +126,22 @@ jobs:
echo "Unexpected file in package tarball"
exit 1
fi
# Secret scan, with ONE deliberate exclusion. Unlike its sibling packages, this one SHIPS the
# redaction logic: dist/telemetry.js defines SECRET_VALUE_PATTERN, whose source text contains
# a literal `-----BEGIN [A-Z ]*PRIVATE KEY-----` alternative (plus a doc comment quoting a
# full PEM header, describing the bug that alternative fixes). Scanning it with the siblings'
# verbatim pattern is a guaranteed false positive on EVERY release -- verified against the
# 0.1.0 bootstrap tarball. Excluding the module by name keeps the check meaningful for the
# other 92 files rather than weakening the pattern for all of them; if a second module ever
# legitimately needs excluding, that is a signal to reconsider the approach, not to extend
# this list quietly.
# Secret scan over EVERY packed file, including dist/telemetry.js.
#
# This package is unusual: it SHIPS the redaction logic, so dist/telemetry.js contains
# SECRET_VALUE_PATTERN, whose source text carries a literal `-----BEGIN [A-Z ]*PRIVATE KEY-----`
# alternative. That form does NOT match the pattern below (`[A-Z ]*` sits where the pattern wants
# `(RSA |EC |OPENSSH )?`), so the module scans clean and needs no exclusion -- verified against the
# packed tarball. The module's doc comment DESCRIBES that header rather than quoting it, for
# exactly this reason (see the comment above SECRET_VALUE_PATTERN); scripts/check-contract-package.ts
# runs the same scan in test:ci, so a regression fails pre-merge rather than at release time.
SCAN_DIR="$(mktemp -d)"
tar -xzf "$TARBALL_PATH" -C "$SCAN_DIR"
if grep -rqE '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_|gh[pousr]_|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)' \
"$SCAN_DIR/package" \
--exclude='telemetry.js' --exclude='telemetry.js.map' --exclude='telemetry.d.ts'; then
"$SCAN_DIR/package"; then
echo "Secret-like content found in package tarball"
exit 1
fi
# And prove the exclusion stayed narrow: the excluded module must still be the ONLY thing that
# would have tripped the scan. If a real secret ever lands in telemetry.js this still fires.
if grep -rqE '(github_pat_|gh[pousr]_[A-Za-z0-9]{16,}|gts_[0-9a-f]{64})' "$SCAN_DIR/package"; then
echo "Real token-shaped content found in package tarball (including the redaction module)"
exit 1
fi
TMP="$(mktemp -d)"
npm --prefix "$TMP" init -y >/dev/null
npm --prefix "$TMP" install "$TARBALL_PATH" --no-save >/dev/null
Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"packages/loopover-contract": "0.1.0",
"packages/loopover-mcp": "3.15.2",
"packages/loopover-engine": "3.16.1",
"packages/loopover-miner": "3.15.2",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"drizzle:generate": "drizzle-kit generate",
"build:mcp": "npm --workspace @loopover/mcp run build",
"build:miner": "turbo run build --filter=@loopover/engine && npm --workspace @loopover/miner run build",
"test:contract-pack": "tsx scripts/check-contract-package.ts",
"test:mcp-pack": "tsx scripts/check-mcp-package.ts",
"test:miner-pack": "tsx scripts/check-miner-package.ts",
"test:engine-pack": "tsx scripts/check-engine-package.ts",
Expand Down Expand Up @@ -136,7 +137,7 @@
"test:smoke:browser:install": "playwright install chromium",
"test:smoke:browser": "node --experimental-strip-types scripts/smoke-ui-browser.ts",
"pretest:ci": "npm run check-node-version",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:ci": "git diff --check && npm run actionlint && npm run lint:composite-actions && npm run db:migrations:check && npm run db:migrations:immutable:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run build --workspace @loopover/discovery-index && npm run build:mcp && npm run build:miner && npm run build --workspace @loopover/ui-kit && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run validate:mcp && npm run test --workspace @loopover/engine && npm run test:workers && npm run test:mcp-pack && npm run test:contract-pack && npm run test:miner-pack && npm run test:engine-pack && npm run test:ui-kit-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run control-plane:contract:check && npm run control-plane:openapi:check && npm run ui:version-audit && npm run docs:drift-check && npm run coverage-boltons:check && npm run import-specifiers:check && npm run ui-derived-types:check && npm run server-manifest:check && npm run dead-source-files:check && npm run publishable-deps:check && npm run regate-sort-key:check && npm run command-redelivery-guards:check && npm run dispatch-gate-reasons:check && npm run validate:no-hand-written-js && npm run replay-runner-manifest:check && npm run coco-dev-versions:check && npm run branding-drift:check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run engines-nvmrc:check && npm run release-manifest:sync:check && npm run release-linked-versions:check && npm run command-reference:check && npm run mcp:tool-reference:check && npm run mcp:client-config:check && npm run contract:api-schemas:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build",
"test:release": "npm run test:ci && npm run changelog:check",
"test:release:mcp": "npm run test:ci",
"test:watch": "vitest",
Expand Down
10 changes: 10 additions & 0 deletions packages/loopover-contract/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## 0.1.0

Initial release. `@loopover/contract` is the single zod source of truth for LoopOver's MCP tool and API
contracts — the schemas, tool metadata, and derived projections every server and client reads.

It is published because it is a **runtime** dependency of `@loopover/mcp` and `@loopover/miner`, which
import it from code that ships (#9749). It must be published *before* any release of those packages that
depends on a new version of it.
6 changes: 5 additions & 1 deletion packages/loopover-contract/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,11 @@ const SECRET_KEY_PATTERN = /token|secret|password|passwd|dsn|credential|api[_-]?
* The token prefixes carry their own `\b`; the PEM header must NOT, because a word boundary before
* a leading hyphen never matches and the whole alternative would be dead. That is not hypothetical
* -- it was, until the forbidden-content test in test/unit/mcp-dispatch-telemetry.test.ts caught a
* full `-----BEGIN RSA PRIVATE KEY-----` passing through untouched.
* complete RSA PEM private-key header passing through untouched.
*
* That header is described rather than quoted on purpose: this file is PUBLISHED (#9749), and a literal
* key marker in the tarball trips every secret scanner that reads it -- ours in check-contract-package.ts,
* and a consumer's own. The pattern on the next line is what must be exact; the prose need not be.
*/
const SECRET_VALUE_PATTERN = /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{16,}|phc_[A-Za-z0-9]{16,})|-----BEGIN [A-Z ]*PRIVATE KEY-----/;

Expand Down
67 changes: 67 additions & 0 deletions scripts/check-contract-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Pack check for @loopover/contract (#9654), mirroring check-mcp-package.ts.
//
// The contract package became publishable in #9749 because it is a RUNTIME dependency of both published
// CLIs. A published tarball is immutable, so the file list and its contents are checked BEFORE the
// publish job ever sees them — the same reasoning as the MCP check, against this package's own allowlist.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { FORBIDDEN_CONTENT } from "./forbidden-content";
import { CONTRACT_PACKAGE_ALLOWED_FILE_PATTERNS } from "./contract-package-allowlist";

const FORBIDDEN_PATH = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i;

type PackedFile = string | { path: string };
type ReadContentFn = (file: string) => string;

/** PURE over an already-resolved pack listing, so the rules are unit-testable without running npm. */
export function validateContractPackFileList(files: readonly PackedFile[], readContent: ReadContentFn): string[] {
const paths = files.map((file) => (typeof file === "string" ? file : file.path)).sort();
if (paths.length === 0) throw new Error("Contract package tarball is empty");
for (const file of paths) {
if (FORBIDDEN_PATH.test(file)) throw new Error(`Forbidden file in contract package: ${file}`);
if (!CONTRACT_PACKAGE_ALLOWED_FILE_PATTERNS.some((pattern) => pattern.test(file))) {
throw new Error(`Unexpected file in contract package: ${file}`);
}
if (FORBIDDEN_CONTENT.test(readContent(file))) throw new Error(`Secret-like content found in contract package file: ${file}`);
}
return paths;
}

function loadContractPackFromNpm(): { files: PackedFile[] } {
const result = spawnSync("npm", ["pack", "--workspace", "@loopover/contract", "--dry-run", "--json"], { encoding: "utf8" });
if (result.status !== 0) throw new Error(`npm pack failed: ${result.stderr || result.stdout}`);
const parsed = JSON.parse(result.stdout) as Array<{ files: PackedFile[] }>;
const pack = parsed[0];
if (!pack) throw new Error("npm pack returned no package entry");
return pack;
}

export function runContractPackCheck(
options: { pack?: { files: PackedFile[] }; packageRoot?: string; readContent?: ReadContentFn } = {},
): string {
const packageRoot = options.packageRoot ?? join(fileURLToPath(import.meta.url), "..", "..", "packages", "loopover-contract");
const readContent: ReadContentFn =
options.readContent ??
((file) => {
try {
return readFileSync(join(packageRoot, file), "utf8");
} catch {
// A binary or unreadable file has no text to scan; the allowlist above already bounds what may ship.
return "";
}
});
const paths = validateContractPackFileList((options.pack ?? loadContractPackFromNpm()).files, readContent);
return `check-contract-package: OK — ${paths.length} packed file(s), all allowlisted and clean.`;
}

if (process.argv[1]?.endsWith("check-contract-package.ts")) {
try {
console.log(runContractPackCheck());
} catch (error) {
console.error(String(error instanceof Error ? error.message : error));
process.exit(1);
}
}
Loading
Loading