diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6cf69c8 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +bun scripts/check-publication.ts --message-file "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ca0b97d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +bun scripts/check-publication.ts --staged +gitleaks git --pre-commit --staged --no-banner --redact --config .gitleaks.toml . diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2cc8436 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +cd "$(git rev-parse --show-toplevel)" +bun scripts/check-publication.ts +while read -r local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" != 0000000000000000000000000000000000000000 ] || continue + if [ "$remote_sha" = 0000000000000000000000000000000000000000 ]; then + if git rev-parse --verify --quiet origin/dev >/dev/null; then + base=$(git merge-base origin/dev "$local_sha") + range="$base..$local_sha" + else + range="$local_sha" + fi + else + range="$remote_sha..$local_sha" + fi + bun scripts/check-publication.ts --range "$range" + gitleaks git --no-banner --redact --config .gitleaks.toml --log-opts="$range" . +done diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..66d8e47 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Change + +Describe the problem and resulting behavior. + +## Validation + +Commands run and relevant results. State any missing proof. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb2830..38ff753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,27 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.0 + - name: Validate public contribution text + env: + PUBLIC_TITLE: ${{ github.event.pull_request.title }} + PUBLIC_BODY: ${{ github.event.pull_request.body }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: bun scripts/check-publication.ts --metadata-env --range "$BASE_SHA..HEAD" + + - name: Install verified Gitleaks + env: + GITLEAKS_ARCHIVE: gitleaks_8.30.1_linux_x64.tar.gz + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_VERSION: 8.30.1 + run: | + set -euo pipefail + curl --fail --silent --show-error --location \ + --output "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" \ + "https://github.com/gitleaks/gitleaks/releases/download/v$GITLEAKS_VERSION/$GITLEAKS_ARCHIVE" + printf '%s %s\n' "$GITLEAKS_SHA256" "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" | sha256sum --check --strict + tar --extract --gzip --file "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" --directory "$RUNNER_TEMP" gitleaks + printf '%s\n' "$RUNNER_TEMP" >> "$GITHUB_PATH" + - name: Prepare pinned Rust toolchain run: rustup toolchain install 1.90 --profile minimal --component rustfmt --target wasm32-wasip2 - run: bun install --frozen-lockfile diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..1c51ba7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "Rootform editor secret scanning" + +[extend] +useDefault = true diff --git a/AGENTS.md b/AGENTS.md index e072a8b..b60c336 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,3 +30,6 @@ Read `docs/editor-contract.md` and the affected editor package before editing. Local development evidence stays outside the public repository. Do not commit private implementation, project paths, inputs, credentials or operator notes. + +Configure `git config core.hooksPath .githooks` before contributing. Validate +public messages before sending; diagnostics never reproduce sensitive values. diff --git a/README.md b/README.md index 2e57e2d..adfabc3 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,37 @@ # Rootform editors -VS Code and Zed extensions for Rootform `.rf.hcl` source, powered by -`rootform lsp` from a local Rootform CLI. The extensions do not include or -download Rootform; language semantics come from Rootform's compiler, and the -clients own syntax presentation, launch and lifecycle. +VS Code and Zed extensions for `.rf.hcl` source: diagnostics, completion, +hover, definitions and formatting through `rootform lsp`. -- [VS Code](vscode/README.md), for the Visual Studio Marketplace and Open VSX -- [Zed](zed/README.md), for the Zed extension registry +Install a local [Rootform CLI](https://docs.rootform.dev/installation/), then +choose your editor: -## Development +| Editor | Setup | +| --- | --- | +| VS Code (Visual Studio Marketplace, Open VSX) | [Extension guide](vscode/README.md) | +| Zed (extension registry) | [Extension guide](zed/README.md) | -```sh -bun install --frozen-lockfile -bun run verify -``` - -`verify` checks that both manifests share one version and description, checks -formatting, builds and unit-tests the VS Code client, tests the Zed queries -against the pinned HCL grammar and builds the Zed package for `wasm32-wasip2` -with Rust 1.90, the toolchain the Zed registry packages with. +Both clients launch an existing executable from settings or `PATH`. They ship +no compiler, download no Rootform binary and execute no providers. +See [editing Rootform source](https://docs.rootform.dev/language/editors/). -Real integration qualification uses an explicitly provided Rootform executable -and a local VS Code. Nothing is downloaded: +## Develop ```sh -VSCODE_EXECUTABLE_PATH="/Applications/Visual Studio Code.app/Contents/MacOS/Code" \ -ROOTFORM_BINARY="/absolute/path/to/rootform" \ -bun run --cwd vscode test:integration +bun install --frozen-lockfile +bun run verify ``` -The runner uses a temporary multi-root fixture workspace with unique VS Code -user-data and extension directories. The fixture source is synthetic. - -To try the Zed package, run `zed: install dev extension` in Zed and select the -`zed/` directory. Zed may need network access to prepare the pinned HCL -grammar and `wasi-sdk` while building it; the language server always comes -from the local Rootform executable. - -Native editor runtime qualification currently covers macOS arm64. The Zed -package also builds for `wasm32-wasip2`; Linux and Windows editor runtimes -have not yet been qualified. - -## Releasing +The gate validates manifests, formatting, VS Code build and tests, Zed grammar +queries and the `wasm32-wasip2` package with Rust 1.90. Real editor integration +requires an explicitly provided local Rootform executable; see the +[editor contract](docs/editor-contract.md). +Native editor qualification covers macOS arm64. Linux and Windows runtime +qualification remains separate from package checks. -[Releasing](docs/releasing.md) covers versioning, the release workflow and -publication to each store. +[Releasing](docs/releasing.md) covers versioning and store publication. ## License -Source is [Apache-2.0](LICENSE). Third-party dependencies retain their original -licenses. Rootform name and logo: trademark rights reserved, see -[TRADEMARKS.md](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md). +[Apache-2.0](LICENSE). Third-party dependencies retain their licenses. +[Rootform trademark terms](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md) apply. diff --git a/scripts/check-publication.ts b/scripts/check-publication.ts new file mode 100644 index 0000000..b8a5920 --- /dev/null +++ b/scripts/check-publication.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env bun +import { execFileSync } from "node:child_process"; +import { lstatSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { assertPublicMessage, publicationIssues } from "./publication-safety.ts"; + +export function trackedPublicationIssues( + directory: string, + revision?: string, +): Array<{ path: string; rule: string; line: number }> { + const git = (args: string[]) => { + try { + return execFileSync("git", args, { + cwd: directory, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + throw new Error("Publication refused: cannot read tracked Git content"); + } + }; + const records = git( + revision && revision !== ":" ? ["ls-tree", "-r", "-z", revision] : ["ls-files", "-s", "-z"], + ) + .toString() + .split("\0") + .filter(Boolean); + const entries = new Map( + records.map((entry) => { + const separator = entry.indexOf("\t"); + return [entry.slice(separator + 1), entry.slice(0, separator).split(" ")[0]] as const; + }), + ); + return [...entries].flatMap(([path, mode]) => { + const full = join(directory, path); + let body: Buffer; + if (revision) { + body = git(["show", `${revision === ":" ? "" : revision}:${path}`]); + if (mode === "120000") { + return [{ path, rule: "symlink", line: 1 }]; + } + if (mode !== "100644" && mode !== "100755") + return [{ path, rule: "irregular-entry", line: 1 }]; + } else { + let stat: ReturnType; + try { + stat = lstatSync(full); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw new Error("Publication refused: cannot inspect tracked entry"); + } + if (stat.isSymbolicLink()) { + return [{ path, rule: "symlink", line: 1 }]; + } + if (!stat.isFile()) return [{ path, rule: "irregular-entry", line: 1 }]; + try { + body = readFileSync(full); + } catch { + throw new Error("Publication refused: cannot read tracked file"); + } + } + // Binary metadata is included; a tracked path is never skipped because it is ignored. + return publicationIssues(body.toString("utf8")).map((issue) => ({ path, ...issue })); + }); +} + +export function checkPublication(args: string[]): void { + const option = (name: string) => { + const index = args.indexOf(name); + return index < 0 ? undefined : args[index + 1]; + }; + const directory = resolve(option("--repo") ?? process.cwd()); + const git = (args: string[]) => { + try { + return execFileSync("git", args, { + cwd: directory, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }).toString(); + } catch { + throw new Error("Publication refused: cannot inspect Git history"); + } + }; + const message = option("--message-file"); + if (message) assertPublicMessage(readFileSync(message, "utf8")); + if (args.includes("--metadata-env")) + assertPublicMessage([process.env.PUBLIC_TITLE ?? "", process.env.PUBLIC_BODY ?? ""]); + const issues = message + ? [] + : trackedPublicationIssues(directory, args.includes("--staged") ? ":" : undefined); + const range = option("--range"); + if (range) { + for (const commit of git(["rev-list", range]).trim().split("\n").filter(Boolean)) { + assertPublicMessage(git(["show", "-s", "--format=%B", commit])); + issues.push(...trackedPublicationIssues(directory, commit)); + } + } + const issue = issues[0]; + if (issue) { + // A malicious filename can itself contain sensitive text. + const location = publicationIssues(issue.path).length ? "tracked file" : issue.path; + throw new Error(`Publication refused: ${issue.rule} (${location}:${issue.line})`); + } +} + +if (import.meta.main) { + try { + checkPublication(process.argv.slice(2)); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + console.error( + /^(?:Publication|Public message) refused: /u.test(message) + ? message + : "Publication refused: cannot inspect contribution", + ); + process.exit(1); + } +} diff --git a/scripts/publication-safety.test.ts b/scripts/publication-safety.test.ts new file mode 100644 index 0000000..32c337d --- /dev/null +++ b/scripts/publication-safety.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkPublication, trackedPublicationIssues } from "./check-publication.ts"; +import { assertPublicMessage, publicationIssues } from "./publication-safety.ts"; + +test("synthetic private content is refused without echoing its value", () => { + for (const value of [ + "/" + "Users/fictional/project", + "github_" + "pat_" + "SYNTHETIC".repeat(8), + "FixtureReviewer" + " 9.9 Max review", + "# Session" + " report", + "Includes 12" + " merged Engine pull requests", + ]) { + expect(publicationIssues(value).length).toBeGreaterThan(0); + try { + assertPublicMessage({ output: { summary: value } }); + throw new Error("Expected rejection"); + } catch (error) { + expect(String(error)).toContain("Public message refused:"); + expect(String(error)).not.toContain(value); + } + } +}); + +test("public AI, agent and spec terminology and technical provenance remain valid", () => { + expect(() => + assertPublicMessage([ + "AI integration uses a documented API.", + "The agent runs validation; this spec defines the public schema.", + { source_repository: "rootform-dev/engine", source_commit: "a".repeat(40) }, + "The binary handoff verifies exact provenance.", + ]), + ).not.toThrow(); +}); + +test("serialized and URL-encoded metadata is inspected before sending", () => { + expect(() => assertPublicMessage(undefined)).not.toThrow(); + const path = "/" + "Users/fictional/session"; + const token = "github_" + "pat_" + "SYNTHETIC".repeat(8); + for (const value of [path, token]) { + for (const serialized of [ + encodeURIComponent(value), + encodeURIComponent(encodeURIComponent(value)), + JSON.stringify({ value }).replaceAll("/", "\\/"), + JSON.stringify({ value }).replaceAll("/", "\\u002f"), + { encoding: "base64", content: btoa(value) }, + { encoding: "base64", content: btoa(JSON.stringify({ value }).replaceAll("/", "\\u002f")) }, + JSON.stringify({ API_TOKEN: value }), + { message: "Update public fixture", content: btoa(value) }, + ]) { + try { + assertPublicMessage(serialized); + throw new Error("Expected rejection"); + } catch (error) { + expect(String(error)).toContain("Public message refused:"); + expect(String(error)).not.toContain(value); + } + } + } + expect(() => + assertPublicMessage({ encoding: "base64", content: btoa("Public fixture") }), + ).not.toThrow(); + expect(() => assertPublicMessage({ encoding: "base64", content: "?" })).toThrow( + "uninspectable-content", + ); +}); + +test("nested JSON and rendered metadata cannot hide private text", () => { + const credential = "SYNTHETIC".repeat(8); + const path = "/" + "Users/fictional/session"; + for (const value of [ + { body: JSON.stringify({ API_KEY: credential }) }, + '"api_key": "' + credential + '"', + { body: JSON.stringify({ CLOUDFLARE_API_KEY: credential }) }, + { encoding: "base64", content: Buffer.from(path, "utf16le").toString("base64") }, + ["/", ";Users/", ";fictional/", ";session"].join(""), + "notes/" + "fictional-session/report.json", + ]) + expect(() => assertPublicMessage(value)).toThrow("Public message refused:"); + for (const value of [ + "ROOTFORM_DATADOG_CLOUDFLARE_KEY_SENTINEL", + "ROOTFORM_DATADOG_FASTLY_KEY_SENTINEL", + "ROOTFORM_HCP_DATADOG_API_SENTINEL", + "ROOTFORM_ATLAS_OBSERVABILITY_SECRET", + ]) + expect(() => assertPublicMessage('api_key = "' + value + '"')).not.toThrow(); +}); + +test("tracked ignored files and staged bytes cannot escape the publication scan", () => { + const directory = mkdtempSync(join(tmpdir(), "publication-fixture-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd: directory }); + try { + git("init", "-q"); + writeFileSync(join(directory, ".gitignore"), "artifacts/\n"); + mkdirSync(join(directory, "artifacts")); + writeFileSync(join(directory, "artifacts/tracked.txt"), "/" + "Users/fictional/hidden"); + git("add", ".gitignore"); + git("add", "-f", "artifacts/tracked.txt"); + expect(trackedPublicationIssues(directory)[0]?.rule).toBe("personal-path"); + writeFileSync(join(directory, "artifacts/tracked.txt"), "Clean working copy.\n"); + expect(() => checkPublication(["--repo", directory, "--staged"])).toThrow(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("large staged files are inspected and Git failures never echo their input", () => { + const directory = mkdtempSync(join(tmpdir(), "publication-large-fixture-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd: directory }); + try { + git("init", "-q"); + writeFileSync( + join(directory, "bundle.js"), + `${"x".repeat(2 * 1024 * 1024)}\n/${"Users/fictional/hidden"}`, + ); + git("add", "bundle.js"); + expect(trackedPublicationIssues(directory, ":")[0]?.rule).toBe("personal-path"); + const revision = "/" + "Users/fictional/invalid"; + expect(() => trackedPublicationIssues(directory, revision)).toThrow( + "cannot read tracked Git content", + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("staged symlink modes cannot be concealed by the working copy", () => { + const directory = mkdtempSync(join(tmpdir(), "publication-link-fixture-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd: directory }); + try { + git("init", "-q"); + symlinkSync("nonexistent-fixture", join(directory, "alias")); + git("add", "alias"); + rmSync(join(directory, "alias")); + writeFileSync(join(directory, "alias"), "Regular working copy.\n"); + expect(trackedPublicationIssues(directory)).toEqual([]); + expect(trackedPublicationIssues(directory, ":")[0]?.rule).toBe("symlink"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("named credentials are checked recursively while exact synthetic literals remain valid", () => { + const credential = "SYNTHETIC".repeat(8); + for (const payload of [ + { API_TOKEN: credential }, + { encoding: "base64", content: btoa(JSON.stringify({ api_key: credential })) }, + { + encoding: "base64", + content: btoa( + JSON.stringify({ encoding: "base64", content: btoa("github_" + "pat_" + credential) }), + ), + }, + ]) + expect(() => assertPublicMessage(payload)).toThrow("credential"); + for (const value of [ + "ROOTFORM_DATADOG_CLOUDFLARE_KEY_SENTINEL", + "ROOTFORM_DATADOG_FASTLY_KEY_SENTINEL", + "ROOTFORM_HCP_DATADOG_API_SENTINEL", + "ROOTFORM_ATLAS_OBSERVABILITY_SECRET", + ]) { + expect(() => assertPublicMessage({ api_key: value })).not.toThrow(); + expect(() => assertPublicMessage({ api_key: value + "_CHANGED" })).toThrow("credential"); + } + const path = "/" + "Users/fictional/session"; + expect(() => + assertPublicMessage({ + encoding: "base64", + content: btoa(JSON.stringify({ value: path }).replaceAll("/", "\\u002f")), + }), + ).toThrow("personal-path"); +}); diff --git a/scripts/publication-safety.ts b/scripts/publication-safety.ts new file mode 100644 index 0000000..dacbca3 --- /dev/null +++ b/scripts/publication-safety.ts @@ -0,0 +1,164 @@ +export type PublicationIssue = { rule: string; line: number }; + +// Exact non-credential literals used by public synthetic provider fixtures. +const syntheticCredentialValues = new Set([ + "fixture000000000000000000000000000000000", + "ROOTFORM_DATADOG_PROVIDER_API_KEY_SENTINEL", + "ROOTFORM_NEWRELIC_BOUNDARY_SECRET_SENTINEL", + "ROOTFORM_NEWRELIC_PROVIDER_KEY_SENTINEL", + "ROOTFORM_OKTA_BOUNDARY_SECRET_SENTINEL", + "ROOTFORM_OKTA_PROVIDER_TOKEN_SENTINEL", + "ROOTFORM_DATADOG_CLOUDFLARE_KEY_SENTINEL", + "ROOTFORM_DATADOG_FASTLY_KEY_SENTINEL", + "ROOTFORM_HCP_DATADOG_API_SENTINEL", + "ROOTFORM_ATLAS_OBSERVABILITY_SECRET", +]); + +const rules: Array<[string, RegExp]> = [ + [ + "personal-path", + /\/Users\/[^\s"'<>]+|\/home\/(?!rootform(?:\/|$)|runner(?:\/|$))[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\/gu, + ], + [ + "credential", + /BEGIN (?:RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|(?:github_pat_|gh[pousr]_|npm_)[A-Za-z0-9_]{12,}|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|xox[abprs]-[A-Za-z0-9-]{12,}|AIza[A-Za-z0-9_-]{35}|\bBearer\s+[A-Za-z0-9._~+/-]{24,}=*/gu, + ], + ["private-workspace", /\b(?:rootform-dev\/)?notes\/[A-Za-z0-9][A-Za-z0-9._/-]*/gu], + [ + "session-instructions", + /^\s*(?:#+\s*)?(?:system prompt|agent handoff|session report|rapport de session|instructions? (?:to|aux|pour) (?:models?|agents?)|model routing)\b|\b[A-Z][A-Za-z0-9_-]*[- ]V?\d+\.\d+\s+(?:Max|Ultra)\b.{0,40}\b(?:review|revue|instructions?)\b|\b(?:model routing|review with|revue avec)\s*[:=]/gimu, + ], + [ + "private-activity", + /(?:\d+\s+(?:merged\s+)?(?:private|engine|web)\s+(?:pull requests|PRs))|(?:private|engine|web)\s+(?:pull requests|PRs)\s+(?:included|merged|count)/giu, + ], +]; + +/** Return only rule and line; never retain or display the matched value. */ +export function publicationIssues(text: string): PublicationIssue[] { + const found: PublicationIssue[] = []; + const scan = (source: string) => { + for (let depth = 0; depth < 3; depth++) { + const decoded = source + .replace(/(?:%[0-9a-f]{2})+/giu, (encoded) => { + try { + return decodeURIComponent(encoded); + } catch { + return encoded; + } + }) + .replace( + /&(?:#(x[0-9a-f]+|[0-9]+)|amp|sol|colon|quot|apos);/giu, + (entity, numeric: string | undefined) => { + if (numeric) { + const code = numeric.toLowerCase().startsWith("x") + ? Number.parseInt(numeric.slice(1), 16) + : Number(numeric); + return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity; + } + return ( + ( + { + "&": "&", + "/": "/", + ":": ":", + """: '"', + "'": "'", + } as Record + )[entity.toLowerCase()] ?? entity + ); + }, + ) + .replaceAll("\0", ""); + if (decoded === source) break; + source = decoded; + } + for (const [rule, pattern] of rules) + for (const match of source.matchAll(pattern)) + found.push({ rule, line: source.slice(0, match.index).split("\n").length }); + const namedCredential = + /\b(?:CLOUDFLARE_API_(?:TOKEN|KEY)|AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|API_TOKEN|API_KEY)["']?\s*[:=]\s*["']?([A-Za-z0-9_+./~=-]{24,})/giu; + for (const match of source.matchAll(namedCredential)) + if (!syntheticCredentialValues.has(match[1] ?? "")) + found.push({ rule: "credential", line: source.slice(0, match.index).split("\n").length }); + }; + const inspect = (value: unknown, depth: number) => { + if (depth > 128) { + found.push({ rule: "uninspectable-content", line: 1 }); + return; + } + if (typeof value === "string") { + scan(value); + if (/^\s*[[{]/u.test(value)) { + let nested: unknown; + try { + nested = JSON.parse(value); + } catch { + return; + } + inspect(nested, depth + 1); + } + } else if (Array.isArray(value)) for (const item of value) inspect(item, depth + 1); + else if (value !== null && typeof value === "object") { + const payload = value as Record; + if ( + typeof payload.content === "string" && + (payload.encoding === "base64" || ("message" in payload && payload.encoding === undefined)) + ) { + try { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test( + payload.content, + ) + ) + throw new Error("Invalid encoding"); + const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode( + Uint8Array.from(atob(payload.content), (character) => character.charCodeAt(0)), + ); + inspect(decoded, depth + 1); + } catch { + found.push({ rule: "uninspectable-content", line: 1 }); + } + } + for (const [key, item] of Object.entries(payload)) { + if ( + /^(?:CLOUDFLARE_API_(?:TOKEN|KEY)|AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|API_TOKEN|API_KEY)$/iu.test( + key, + ) && + typeof item === "string" && + !syntheticCredentialValues.has(item) && + /^[A-Za-z0-9_+./~=-]{24,}$/u.test(item) + ) + found.push({ rule: "credential", line: 1 }); + scan(key); + inspect(item, depth + 1); + } + } + }; + scan(text); + // Parsing valid JSON reveals escapes without interpreting regex or source-code literals. + let structured: unknown; + try { + structured = JSON.parse(text); + } catch { + return found; + } + inspect(structured, 0); + return found; +} + +/** Validate all outbound strings before a bot makes its first write. */ +export function assertPublicMessage(value: unknown): void { + if (value === undefined) return; + let text: string; + try { + const serialized = typeof value === "string" ? value : JSON.stringify(value); + if (typeof serialized !== "string") throw new Error("Invalid payload"); + text = serialized; + } catch { + throw new Error("Public message refused: uninspectable-content"); + } + const issues = publicationIssues(text); + const issue = issues[0]; + if (issue) throw new Error(`Public message refused: ${issue.rule}`); +} diff --git a/scripts/verify.ts b/scripts/verify.ts index 48f39ec..049f7a1 100644 --- a/scripts/verify.ts +++ b/scripts/verify.ts @@ -1,5 +1,7 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; if (Bun.version !== "1.4.0") throw new Error("Bun 1.4.0 is required"); @@ -22,7 +24,40 @@ for (const [field, ...values] of mismatches) { } if (mismatches.length > 0) process.exit(1); +const scanRoot = mkdtempSync(join(tmpdir(), "rootform-editor-scan-")); +try { + const candidates = execFileSync("git", [ + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]) + .toString() + .split("\0") + .filter(Boolean); + for (const path of candidates) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error("Unexpected tracked entry in secret scan"); + const target = join(scanRoot, path); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(path, target); + } + const scan = spawnSync( + "gitleaks", + ["dir", "--no-banner", "--redact", "--config", join(process.cwd(), ".gitleaks.toml"), scanRoot], + { stdio: "inherit" }, + ); + if (scan.status !== 0) process.exit(scan.status ?? 1); +} finally { + rmSync(scanRoot, { recursive: true, force: true }); +} + for (const command of [ + ["bun", "scripts/check-publication.ts"], + ["bun", "test", "scripts/publication-safety.test.ts"], + ["gitleaks", "git", "--no-banner", "--redact", "--config", ".gitleaks.toml", "."], ["bun", "run", "check:format"], ["bun", "run", "build:vscode"], ["bun", "run", "--cwd", "vscode", "test:unit"], diff --git a/vscode/README.md b/vscode/README.md index af53a25..2a8bc91 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -1,31 +1,19 @@ # Rootform for VS Code -Write Rootform `.rf.hcl` source with diagnostics, completion, hover, go to -definition and formatting. The extension adds the Rootform language mode, with -syntax highlighting, comments, bracket matching, automatic closing, -indentation and folding, and connects VS Code to `rootform lsp` from your -local Rootform CLI. - -Rootform owns parsing, compilation and formatting. The extension contains no -compiler and does not include or download Rootform. Editing runs no Terraform, -OpenTofu, provider or cloud call. - -## Requirements - -- Rootform on your machine. Follow - [Install Rootform](https://docs.rootform.dev/installation/), then check that - `rootform version` works. -- VS Code 1.133 or later. +Write `.rf.hcl` with diagnostics, completion, hover, definitions and formatting. +The extension adds syntax highlighting and launches `rootform lsp` from your +local CLI. It ships no compiler and downloads no Rootform binary. Editing +executes no providers and makes no cloud calls. ## Get started -1. Install Rootform, then this extension. -2. Open a trusted workspace containing `.rf.hcl` files. -3. Edit a Dialect or Policy Pack. Diagnostics follow your unsaved changes. +1. [Install Rootform](https://docs.rootform.dev/installation/) and verify + `rootform version`. +2. Install the extension in VS Code 1.133 or later. +3. Open a trusted workspace containing `.rf.hcl` files. -The extension starts `rootform` from VS Code's `PATH`. If Rootform is -installed elsewhere, set **Rootform: Server Path** to the absolute path of the -executable: +The extension uses `rootform` from VS Code's `PATH`. For another location, set +**Rootform: Server Path**: ```json { @@ -33,45 +21,26 @@ executable: } ``` -`.rf.json` source files also reach the language server; VS Code keeps its JSON -presentation for them. - -## Settings and commands - -| Setting or command | Purpose | -| --- | --- | -| `rootform.server.path` | Absolute path, or command name resolved through `PATH`, of the local Rootform executable. Default: `rootform`. | -| **Rootform: Restart Language Server** | Restarts `rootform lsp`, for example after replacing the executable outside VS Code. | - -## Workspace trust - -The extension starts the configured executable only in a trusted workspace. -In Restricted Mode, syntax highlighting still works and no process starts. A -workspace cannot change `rootform.server.path` until you trust it. +The setting accepts an absolute path or a command name. Run **Rootform: Restart +Language Server** after changing the executable. `.rf.json` files also reach the +server, while VS Code keeps their JSON presentation. -## Troubleshooting +## Trust and troubleshooting -- **Rootform executable was not found**: install Rootform or set - `rootform.server.path` to an absolute path. A desktop application may not - see the `PATH` of your terminal. -- If the process does not complete LSP initialization within 10 seconds, the - extension stops it and points to the setting. A failed first start is not - retried: fix the setting, then run **Rootform: Restart Language Server**. -- A server that stops after a successful start is restarted by the language - client, within a bounded number of attempts. +In Restricted Mode, syntax highlighting works and no process starts. A workspace +cannot change the executable path until you trust it. -## Learn more +If Rootform is missing, set an absolute path; VS Code may have a different +`PATH` from your terminal. Initialization times out after 10 seconds. Fix the +setting, then restart the language server. After a successful start, unexpected +server exits have a bounded number of automatic retries. -- [Rootform in VS Code](https://docs.rootform.dev/integrations/vscode/) -- [Edit Rootform source](https://docs.rootform.dev/language/editors/) -- [`rootform lsp` reference](https://docs.rootform.dev/reference/cli/lsp/) -- [Report an issue](https://github.com/rootform-dev/editors/issues) +[VS Code guide](https://docs.rootform.dev/integrations/vscode/) | +[`rootform lsp`](https://docs.rootform.dev/reference/cli/lsp/) | +[Report an issue](https://github.com/rootform-dev/editors/issues) ## License -The extension source is licensed under [Apache-2.0](https://github.com/rootform-dev/editors/blob/dev/vscode/LICENSE). -Bundled runtime packages keep their own licenses, listed in -[THIRD_PARTY_NOTICES.md](https://github.com/rootform-dev/editors/blob/dev/vscode/THIRD_PARTY_NOTICES.md). -Rootform name and logo: trademark rights reserved, see -[TRADEMARKS.md](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md). +[Third-party notices](https://github.com/rootform-dev/editors/blob/dev/vscode/THIRD_PARTY_NOTICES.md) +and [Rootform trademark terms](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md) apply. diff --git a/vscode/test/extensionHost.cjs b/vscode/test/extensionHost.cjs index 6c3ac2c..27a0beb 100644 --- a/vscode/test/extensionHost.cjs +++ b/vscode/test/extensionHost.cjs @@ -365,14 +365,35 @@ async function exerciseCompletionMatrix(firstRoot) { } } -async function rootformLogContains(message) { +async function rootformLogBodies() { const logs = path.join(process.env.ROOTFORM_TEST_USER_DATA, "logs"); + const bodies = []; for (const relative of await fs.readdir(logs, { recursive: true })) { if (relative.endsWith("Rootform.log")) { - if ((await fs.readFile(path.join(logs, relative), "utf8")).includes(message)) return true; + bodies.push(await fs.readFile(path.join(logs, relative), "utf8")); } } - return false; + return bodies; +} + +async function rootformLogContains(message) { + return (await rootformLogBodies()).some((body) => body.includes(message)); +} + +async function rootformLogOccurrences(message) { + return (await rootformLogBodies()).reduce( + (count, body) => count + body.split(message).length - 1, + 0, + ); +} + +function rootformChildProcesses(matches) { + return cp + .execFileSync("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" }) + .split("\n") + .map((line) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)) + .filter((match) => match && Number(match[2]) === process.pid && matches(match[3])) + .map((match) => Number(match[1])); } function referencePosition(document) { @@ -745,6 +766,46 @@ async function run() { }); if (process.platform === "darwin" || process.platform === "linux") { + // A configured absolute path that does not exist must report the + // not-found remediation once, leave no Rootform process behind, and + // recover when the provided executable path is restored. + const absentServer = path.join(testRoot, "absent-server", "rootform"); + assert.equal(path.isAbsolute(absentServer), true, "The absent path must be absolute."); + await assert.rejects(fs.access(absentServer), "The absent path fixture must not exist."); + await settings.update("server.path", absentServer, vscode.ConfigurationTarget.Global); + const notFound = `Rootform executable "${absentServer}" was not found. Install Rootform or set "rootform.server.path" to a local executable.`; + await until("nonexistent executable reported with its remediation", () => + rootformLogContains(notFound), + ); + await delay(500); + assert.equal( + await rootformLogOccurrences(`Rootform executable "${absentServer}" was not found.`), + 1, + "A nonexistent executable must be reported once, without a retry loop.", + ); + await until( + "no Rootform process left behind while the executable is absent", + () => rootformChildProcesses((command) => command.endsWith(" lsp")).length === 0, + 5000, + ); + assert.deepEqual( + rootformChildProcesses((command) => command.includes(absentServer)), + [], + "A nonexistent executable must not start a process.", + ); + await settings.update("server.path", rootformBinary, vscode.ConfigurationTarget.Global); + await until( + "hover after restoring the provided executable path", + async () => + ( + await vscode.commands.executeCommand( + "vscode.executeHoverProvider", + rule.uri, + referencePosition(rule), + ) + )?.length > 0, + ); + // A process that exits before initialize must fail once, then recover when // the configured executable is corrected. Use a local recording fixture. const incompatible = path.join(testRoot, "incompatible-server"); @@ -817,18 +878,9 @@ async function run() { } } const children = () => - cp - .execFileSync("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8" }) - .split("\n") - .map((line) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)) - .filter(Boolean) - .filter( - (item) => - Number(item[2]) === process.pid && - item[3].includes(rootformBinary) && - item[3].endsWith(" lsp"), - ) - .map((item) => Number(item[1])); + rootformChildProcesses( + (command) => command.includes(rootformBinary) && command.endsWith(" lsp"), + ); const [serverPid] = await until( "one running local Rootform child", () => children().length === 1 && children(), diff --git a/zed/README.md b/zed/README.md index 2247a09..19c7623 100644 --- a/zed/README.md +++ b/zed/README.md @@ -1,23 +1,18 @@ # Rootform for Zed -Write Rootform `.rf.hcl` source with diagnostics, completion, hover, go to -definition and formatting. The extension adds Rootform syntax highlighting, -bracket matching, indentation and folding, and starts `rootform lsp` from your -local Rootform CLI. - -Rootform owns parsing, compilation and formatting. The extension does not -include or download Rootform. +Write `.rf.hcl` with diagnostics, completion, hover, definitions and formatting. +The extension adds syntax highlighting and launches `rootform lsp` from your +local CLI. It ships no compiler and downloads no Rootform binary. ## Get started -1. [Install Rootform](https://docs.rootform.dev/installation/) and check that - `rootform version` works. -2. In Zed, open **Extensions**, search for **Rootform** and install it. +1. [Install Rootform](https://docs.rootform.dev/installation/) and verify + `rootform version`. +2. In Zed's **Extensions**, install **Rootform**. 3. Open a `.rf.hcl` file. -The extension finds `rootform` on the worktree `PATH` and starts it with the -`lsp` argument. If Rootform is installed elsewhere, set both the path and the -arguments in Zed's settings: +The extension finds `rootform` on the worktree `PATH`. To choose another +executable, set both its path and arguments: ```json { @@ -32,26 +27,16 @@ arguments in Zed's settings: } ``` -With `binary.path` set, Zed launches that executable directly and passes -`binary.env` to it. Keep `arguments` set to `["lsp"]`: without it, Rootform -starts without a subcommand. - -## Troubleshooting - -A missing executable produces a message with the complete setting. After -fixing a launch error or replacing Rootform, run -`editor: restart language server`. - -## Learn more +Zed launches that executable directly and passes `binary.env` to it. +Keep `arguments` set to `["lsp"]`. After a launch error or executable replacement, +run `editor: restart language server`. -- [Rootform in Zed](https://docs.rootform.dev/integrations/zed/) -- [Edit Rootform source](https://docs.rootform.dev/language/editors/) -- [Report an issue](https://github.com/rootform-dev/editors/issues) +[Zed guide](https://docs.rootform.dev/integrations/zed/) | +[`rootform lsp`](https://docs.rootform.dev/reference/cli/lsp/) | +[Report an issue](https://github.com/rootform-dev/editors/issues) ## License -The extension source is licensed under [Apache-2.0](LICENSE). The pinned HCL -Tree-sitter grammar and the Rust crates compiled into the extension keep their -own licenses, listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). -Rootform name and logo: trademark rights reserved, see -[TRADEMARKS.md](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md). +[Apache-2.0](LICENSE). [Third-party notices](THIRD_PARTY_NOTICES.md) cover the +pinned HCL grammar and Rust dependencies. +[Rootform trademark terms](https://github.com/rootform-dev/rootform/blob/dev/TRADEMARKS.md) apply.