diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00688b4..dce87d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,5 +14,6 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - run: bun install --frozen-lockfile --ignore-scripts - run: bun run check:secrets + - run: bun run contracts:manifest - run: bun run typecheck - run: bun run test diff --git a/bun.lock b/bun.lock index f0c470a..099fae8 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "@hasnaxyz/open-security", + "name": "@hasna/shield", "dependencies": { "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.29.0", @@ -17,6 +17,7 @@ "zod": "^3.24.4", }, "devDependencies": { + "@hasna/contracts": "0.8.5", "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.2.12", "@types/express": "^5.0.2", @@ -28,6 +29,8 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@hasna/contracts": ["@hasna/contracts@0.8.5", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-Iyk5srD6J3R7ci2g0gkyt+MCa6Da/lghPosGtgWBRgtJeGYCIAAnHF1yweoXnnqGAWZajFuDgJEWDVH11qpUhA=="], + "@hasna/events": ["@hasna/events@0.1.6", "", { "dependencies": { "commander": "^13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-rKLxjBIat45UxciUwh9hKM63l/mcurJXAhgVkYHvrsGnOK9HwExrwgOlylNzDBDb+huiQw9DWdLb31q5oWDfIw=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], diff --git a/docs/contract-conformance.md b/docs/contract-conformance.md new file mode 100644 index 0000000..867ca79 --- /dev/null +++ b/docs/contract-conformance.md @@ -0,0 +1,53 @@ +# Service Contract Conformance Status + +Shield tracks the Hasna Service Contract v1 published by `@hasna/contracts`. The +declaration lives in [`hasna.contract.json`](../hasna.contract.json), which is +shipped inside the published tarball (`package.json` → `files`), so it is read by +consumers and tooling, not just by CI. + +## Gates + +| Command | What it checks | Enforced | +| --- | --- | --- | +| `bun run contracts:manifest` | `hasna.contract.json` is a valid `hasna.service_contract.v1` manifest | Yes — CI step, plus `src/contract-manifest.test.ts` in the test suite | +| `bun run contracts:check` | Full repo self-check (`contracts repo-conformance .`) | Not yet — see the open gates below | + +Both scripts resolve the `contracts` binary from the pinned +`@hasna/contracts` devDependency, so the gate is reproducible from the lockfile +rather than from whatever version a package runner resolves at run time. + +## Declared surfaces + +`shield` is a `cli-with-store` repo: a CLI over a package-owned local SQLite +store at `~/.hasna/security/shield.db`, plus an MCP server and a REST/dashboard +server. That is the path `getDbPath()` resolves and the path `postinstall` +creates; `~/.hasna/shield/shield.db` is a legacy location shield migrates *from* +and never writes to, so it must not be declared as the store. + +- `cli` — supported (`shield`) +- `mcp` — supported (`shield-mcp`) +- `sdk` — supported (`./sdk` export, `@hasna/shield-sdk`) +- `api` — **deferred**. `shield-serve` answers `/api/*` on localhost but does not + serve the contract topology (`GET /health`, `GET /ready`, `GET /version`) and + publishes no OpenAPI document. + +## Open gates + +`bun run contracts:check` currently exits non-zero. These are real capability +gaps, not manifest defects, and each needs product work rather than a metadata +edit. They are recorded here rather than papered over with a waiver, because a +`cli-with-store` repo that ships `-serve` is not eligible for either a +storage-engine waiver (`WAIVABLE_STORAGE_ENGINES` excludes `sqlite`, and the +serve bin disqualifies the `postgres` waiver) or a service-surface waiver +(library-only). A waiver declared here would be silently ignored by conformance +while reading like an approved exception. + +| Check | Gap | What closes it | +| --- | --- | --- | +| `storage_capabilities` | Shield has no PostgreSQL backend. `src/db` is `bun:sqlite` throughout, and `resolveStorageMode()` rejects every non-local mode. `src/db/pg-migrations.ts` holds translated DDL but nothing executes it. | Implement the PostgreSQL engine behind the existing db layer, then declare `storage.engines` and `storage.pgTestGate` (a live-PG proof command). | +| `surface_matrix`, `service_api_topology` | No `GET /health`, `GET /ready`, or `GET /version` on `shield-serve`, so the API surface cannot be declared `supported`. | Serve the three contract endpoints, then promote the `shield-api` surface to `supported`. | +| `self_host_artifact` | No `Dockerfile` or compose file for `shield-serve`. | Add one self-host deployment artifact. | +| `surface_bindings` | The SDK is hand-written (`sdk/src/client.ts`), so `generatedFrom` cannot honestly point at an OpenAPI document. | Publish an OpenAPI document from `shield-serve` and generate the SDK client from it. | + +Until those land, `contracts:check` is the honest status report and +`contracts:manifest` is the enforced gate. diff --git a/hasna.contract.json b/hasna.contract.json new file mode 100644 index 0000000..4f33f5f --- /dev/null +++ b/hasna.contract.json @@ -0,0 +1,59 @@ +{ + "schema": "hasna.service_contract.v1", + "name": "shield", + "class": "cli-with-store", + "contractVersion": "v1", + "kitVersion": "0.8.5", + "description": "AI-powered security scanner for git repos. Ships the shield CLI, an MCP server, and a REST/dashboard server over a package-owned local SQLite store.", + "bins": [ + "shield", + "shield-mcp", + "shield-serve" + ], + "hosting": [ + "user-hosted" + ], + "storage": { + "mode": "sqlite", + "envPrefix": "HASNA_SHIELD_", + "sqlitePath": "~/.hasna/security/shield.db" + }, + "serviceSurfaces": [ + { + "name": "shield-cli", + "kind": "cli", + "status": "supported", + "bin": "shield", + "authMode": "local-only" + }, + { + "name": "shield-mcp", + "kind": "mcp", + "status": "supported", + "mcpBin": "shield-mcp", + "authMode": "local-only" + }, + { + "name": "shield-sdk", + "kind": "sdk", + "status": "supported", + "exportSubpath": "./sdk", + "authMode": "local-only" + }, + { + "name": "shield-api", + "kind": "api", + "status": "deferred", + "bin": "shield-serve", + "authMode": "local-only", + "deferReason": "shield-serve exposes /api/* over localhost only and does not yet serve the contract topology (GET /health, GET /ready, GET /version) or publish an OpenAPI document. Declaring it supported would assert endpoints this build does not answer." + } + ], + "metadata": { + "release": { + "artifactScan": { + "script": "artifact-scan" + } + } + } +} diff --git a/package.json b/package.json index 44a45fe..926e7a5 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,11 @@ } }, "scripts": { + "contracts:manifest": "contracts validate hasna.contract.json", + "contracts:check": "contracts repo-conformance .", "build": "bun run build:lib && bun run build:types && bun run build:cli && bun run build:mcp && bun run build:server && bun run build:sdk && bun run build:dashboard", "build:lib": "bun build src/index.ts --outdir dist --target node", + "artifact-scan": "bun scripts/no-cloud-artifact-scan.mjs", "build:types": "tsc -p tsconfig.build.json", "build:cli": "bun build src/cli/index.tsx --outfile dist/cli/index.js --target node", "build:mcp": "bun build src/mcp/index.ts --outfile dist/mcp/index.js --target node", @@ -37,7 +40,7 @@ "check:oss-policy": "bun run src/cli/index.tsx oss-secrets-policy . --strict", "no-cloud:source": "bun test src/no-cloud-boundary.test.ts", "no-cloud:pack": "bun run build && bun scripts/no-cloud-artifact-scan.mjs", - "prepack": "bun run build && bun scripts/no-cloud-artifact-scan.mjs", + "prepack": "bun run build && bun run artifact-scan", "prepublishOnly": "bun run check:secrets && bun run test && bun run build && bun scripts/no-cloud-artifact-scan.mjs", "test:coverage": "bun test --coverage", "typecheck": "tsc --noEmit", @@ -58,6 +61,7 @@ "zod": "^3.24.4" }, "devDependencies": { + "@hasna/contracts": "0.8.5", "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.2.12", "@types/express": "^5.0.2", @@ -70,6 +74,7 @@ "sdk/dist", "dashboard/dist", "README.md", + "hasna.contract.json", "LICENSE" ], "publishConfig": { diff --git a/src/contract-manifest.test.ts b/src/contract-manifest.test.ts new file mode 100644 index 0000000..b34656c --- /dev/null +++ b/src/contract-manifest.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { ServiceContractManifestSchema } from "@hasna/contracts/schemas"; +import { resolveDbPath } from "./db/database.js"; + +/** + * hasna.contract.json is shipped inside the published tarball (package.json + * `files`), so an invalid manifest is not a local lint problem — it reaches + * every consumer and every tool that reads the contract. These assertions run + * against the real `@hasna/contracts` schema rather than a local copy so the + * repo cannot drift away from the kit it claims to track. + */ + +const repoRoot = join(import.meta.dir, ".."); + +function readJson(relativePath: string): Record { + return JSON.parse(readFileSync(join(repoRoot, relativePath), "utf-8")) as Record; +} + +const rawManifest = readJson("hasna.contract.json"); +const pkg = readJson("package.json") as { + bin?: Record; + files?: string[]; + scripts?: Record; + exports?: Record; +}; + +const STORE_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "SECURITY_DB", + "HASNA_SHIELD_STORAGE_MODE", + "HASNA_SECURITY_STORAGE_MODE", +] as const; + +/** + * Resolves the store the way the shield CLI does — throwaway HOME, no + * `SECURITY_DB` override, no storage-mode override, and a working directory with + * neither a `.security` nor a `.shield` folder so the project-local branches of + * `getDbPath()` do not win — then reports it in the manifest's own `~`-relative + * form. Comparing the declared `storage.sqlitePath` against this is the only + * assertion that fails when the manifest names a directory shield does not + * actually write to; the schema itself only checks the `.db` suffix. + */ +function resolveStorePathUnderTempHome(): string { + const saved = Object.fromEntries(STORE_ENV_KEYS.map((key) => [key, process.env[key]])); + const originalCwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "shield-contract-store-")); + const home = join(root, "home"); + const workDir = join(root, "work"); + try { + mkdirSync(home, { recursive: true }); + mkdirSync(workDir, { recursive: true }); + for (const key of STORE_ENV_KEYS) delete process.env[key]; + process.env.HOME = home; + process.chdir(workDir); + + const resolved = resolveDbPath(); + expect(resolved.startsWith(home + "/")).toBe(true); + return "~" + resolved.slice(home.length); + } finally { + process.chdir(originalCwd); + for (const key of STORE_ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); + } +} + +describe("hasna.contract.json", () => { + test("validates against the hasna.service_contract.v1 schema", () => { + const result = ServiceContractManifestSchema.safeParse(rawManifest); + const issues = result.success + ? [] + : result.error.issues.map((issue) => `${issue.path.join(".") || ""} ${issue.message}`); + expect(issues).toEqual([]); + expect(result.success).toBe(true); + }); + + test("declares the app identity the published package uses", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect(manifest.schema).toBe("hasna.service_contract.v1"); + expect(manifest.name).toBe("shield"); + expect(manifest.contractVersion).toBe("v1"); + expect(manifest.kitVersion).toBe("0.8.5"); + }); + + test("declared bins match package.json bin", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect([...manifest.bins].sort()).toEqual(Object.keys(pkg.bin ?? {}).sort()); + }); + + test("declares the local SQLite storage boundary shield actually owns", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect(manifest.class).toBe("cli-with-store"); + expect(manifest.storage?.mode).toBe("sqlite"); + expect(manifest.storage?.envPrefix).toBe("HASNA_SHIELD_"); + // The schema only checks the `.db` suffix, so a suffix assertion would let + // any wrong directory ship. Resolve the store the way the CLI does and + // compare: fleet tooling reads this value to find shield's SQLite file, and + // `~/.hasna/shield/shield.db` is a legacy migration source, not the store. + expect(manifest.storage?.sqlitePath).toBe(resolveStorePathUnderTempHome()); + }); + + test("every supported surface binds to a real package entrypoint", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + const bins = Object.keys(pkg.bin ?? {}); + const exportSubpaths = Object.keys(pkg.exports ?? {}); + const supported = manifest.serviceSurfaces.filter((surface) => surface.status === "supported"); + // cli, mcp, and sdk are the three surfaces shield ships today; the API + // surface is declared `deferred` until shield-serve answers the contract + // topology, so it is deliberately absent here. + expect(supported.map((surface) => surface.kind).sort()).toEqual(["cli", "mcp", "sdk"]); + for (const surface of supported) { + if (surface.bin) expect(bins).toContain(surface.bin); + if (surface.mcpBin) expect(bins).toContain(surface.mcpBin); + if (surface.exportSubpath) expect(exportSubpaths).toContain(surface.exportSubpath); + } + }); + + test("the declared release gate names a real script that prepack reaches", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + const scriptName = manifest.metadata?.release?.artifactScan?.script; + expect(scriptName).toBeTruthy(); + const scripts = pkg.scripts ?? {}; + expect(Object.keys(scripts)).toContain(scriptName!); + expect(scripts.prepack ?? "").toContain(scriptName!); + }); + + test("is shipped in the published tarball", () => { + expect(pkg.files ?? []).toContain("hasna.contract.json"); + }); + + test("claims no conformance waiver it is not eligible for", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + // A `cli-with-store` repo shipping `shield-serve` may not waive a storage + // engine or a service surface (WAIVABLE_STORAGE_ENGINES excludes sqlite, + // and surface waivers are library-only), so a declared waiver here would + // be silently ignored by conformance while reading as an approved + // exception. + expect(manifest.metadata?.conformance?.waivedStorageEngines ?? []).toEqual([]); + expect(manifest.metadata?.conformance?.waivedSurfaces ?? []).toEqual([]); + }); +}); diff --git a/src/db/database.ts b/src/db/database.ts index c817f15..7ccb4e1 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -47,6 +47,17 @@ function getDbPath(): string { return dbPath; } +/** + * Resolves — and prepares, exactly as `getDb()` would — the SQLite file this + * store lives in, without opening a connection or touching the singleton. + * `hasna.contract.json` declares that location to fleet tooling, so the + * contract test asserts the declaration against this resolver instead of a + * hardcoded string that could silently drift from `getDbPath()`. + */ +export function resolveDbPath(): string { + return getDbPath(); +} + let _postInitCallbacks: Array<() => void> = []; let _initialized = false;