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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions docs/contract-conformance.md
Original file line number Diff line number Diff line change
@@ -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 `<name>-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.
59 changes: 59 additions & 0 deletions hasna.contract.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -70,6 +74,7 @@
"sdk/dist",
"dashboard/dist",
"README.md",
"hasna.contract.json",
"LICENSE"
],
"publishConfig": {
Expand Down
148 changes: 148 additions & 0 deletions src/contract-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return JSON.parse(readFileSync(join(repoRoot, relativePath), "utf-8")) as Record<string, unknown>;
}

const rawManifest = readJson("hasna.contract.json");
const pkg = readJson("package.json") as {
bin?: Record<string, string>;
files?: string[];
scripts?: Record<string, string>;
exports?: Record<string, unknown>;
};

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(".") || "<root>"} ${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([]);
});
});
11 changes: 11 additions & 0 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading