diff --git a/examples/mcp-gateway/README.md b/examples/mcp-gateway/README.md new file mode 100644 index 0000000..8f91b37 --- /dev/null +++ b/examples/mcp-gateway/README.md @@ -0,0 +1,57 @@ +# Minimal MCP gateway adapter + +This example shows the intended protocol-neutral seam. It is deliberately not +a complete MCP server or a second policy engine. + +An MCP gateway maps an observable operation into the signed Nxtlinq capability +vocabulary, calls `authorize()`, and forwards the operation only after an +`allow` decision. + +```ts +import { withAuthorization } from '@nxtlinq/attest'; + +type McpToolCall = { + name: string; + sessionId?: string; + resource?: string; +}; + +const capabilityByTool: Record = { + write_file: 'tool:write', + execute_command: 'tool:exec', +}; + +export async function guardMcpToolCall( + call: McpToolCall, + invokeDownstream: () => Promise, +) { + const capability = capabilityByTool[call.name]; + + if (capability == null) { + return { + decision: { + outcome: 'deny', + reason: 'capability_not_in_scope', + capability: `mcp:${call.name}`, + }, + }; + } + + return withAuthorization( + { + capability, + protocol: 'mcp', + sessionId: call.sessionId, + resource: call.resource, + }, + invokeDownstream, + ); +} +``` + +The important invariant is: + +> A denied or unverifiable request never invokes the downstream handler. + +The adapter owns protocol mapping. Nxtlinq remains authoritative for signed +manifest verification, artifact verification, and capability scope. diff --git a/src/authorization.ts b/src/authorization.ts new file mode 100644 index 0000000..5890786 --- /dev/null +++ b/src/authorization.ts @@ -0,0 +1,210 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { canonicalString } from './lib/canonical.js'; +import { sha256Hex, verifyEd25519Hex } from './lib/crypto.js'; +import { listArtifactFiles, computeArtifactHash } from './lib/artifact.js'; +import { assertRequiredFields, type AgentManifest } from './lib/manifest.js'; + +const NXTLINQ_DIR = 'nxtlinq'; +const MANIFEST_BASENAME = 'agent.manifest.json'; +const SIG_BASENAME = 'agent.manifest.sig'; +const PUBLIC_KEY_BASENAME = 'public.key'; + +export type AuthorizationOutcome = 'allow' | 'deny'; + +export interface AuthorizationRequest { + /** + * Capability from the signed manifest scope, for example `tool:write`. + * Bare names are normalized to the `tool:` namespace. + */ + capability: string; + /** + * Project root containing the `nxtlinq/` directory. + * Defaults to process.cwd(). + */ + cwd?: string; + /** + * Optional protocol metadata for evidence and adapters. + * It does not affect authorization semantics. + */ + protocol?: string; + sessionId?: string; + resource?: string; +} + +export interface AuthorizationEvidence { + manifestDigest: string; + artifactDigest: string; + protocol?: string; + sessionId?: string; + resource?: string; +} + +export interface AuthorizationDecision { + outcome: AuthorizationOutcome; + reason: + | 'authorized' + | 'capability_not_in_scope' + | 'invalid_request' + | 'manifest_unavailable' + | 'invalid_manifest' + | 'manifest_integrity_failed' + | 'invalid_signature' + | 'artifact_integrity_failed' + | 'artifact_file_count_mismatch' + | 'verification_failed'; + capability: string; + evidence?: AuthorizationEvidence; +} + +type VerificationSuccess = { + ok: true; + manifest: AgentManifest; + manifestDigest: string; + artifactDigest: string; +}; + +type VerificationFailure = { + ok: false; + reason: AuthorizationDecision['reason']; +}; + +type VerificationResult = VerificationSuccess | VerificationFailure; + +function normalizeCapability(capability: string): string { + const value = capability.trim(); + if (value.length === 0) return ''; + return value.includes(':') ? value : `tool:${value}`; +} + +/** + * Verify the signed manifest and covered artifact without writing output or + * terminating the process. This is intentionally separate from the existing + * human-oriented `verify` command. + */ +export function verifyAuthorizationContext(cwd = process.cwd()): VerificationResult { + const nxtlinqPath = join(cwd, NXTLINQ_DIR); + const manifestPath = join(nxtlinqPath, MANIFEST_BASENAME); + const sigPath = join(nxtlinqPath, SIG_BASENAME); + const publicKeyPath = join(nxtlinqPath, PUBLIC_KEY_BASENAME); + + let manifestRaw: string; + let signatureHex: string; + let publicKeyPem: string; + + try { + manifestRaw = readFileSync(manifestPath, 'utf8'); + signatureHex = readFileSync(sigPath, 'utf8').trim(); + publicKeyPem = readFileSync(publicKeyPath, 'utf8'); + } catch { + return { ok: false, reason: 'manifest_unavailable' }; + } + + let manifestRecord: Record; + try { + manifestRecord = JSON.parse(manifestRaw) as Record; + assertRequiredFields(manifestRecord); + } catch { + return { ok: false, reason: 'invalid_manifest' }; + } + + const manifest = manifestRecord as AgentManifest; + + try { + const { contentHash: _drop, ...manifestForHash } = manifest; + const computedContentHash = sha256Hex(canonicalString(manifestForHash)); + + if (computedContentHash !== manifest.contentHash) { + return { ok: false, reason: 'manifest_integrity_failed' }; + } + + if (!verifyEd25519Hex(manifest.contentHash, signatureHex, publicKeyPem)) { + return { ok: false, reason: 'invalid_signature' }; + } + + const artifactFiles = listArtifactFiles(cwd); + const computedArtifactHash = computeArtifactHash(cwd, artifactFiles); + + if (computedArtifactHash !== manifest.artifactHash) { + return { ok: false, reason: 'artifact_integrity_failed' }; + } + + if ( + manifest.artifactFileCount != null && + artifactFiles.length !== manifest.artifactFileCount + ) { + return { ok: false, reason: 'artifact_file_count_mismatch' }; + } + + return { + ok: true, + manifest, + manifestDigest: manifest.contentHash, + artifactDigest: manifest.artifactHash, + }; + } catch { + return { ok: false, reason: 'verification_failed' }; + } +} + +/** + * Produce one fail-closed authorization decision from the signed Nxtlinq + * context. Protocol adapters should call this function before forwarding a + * protected operation. + */ +export function authorize(request: AuthorizationRequest): AuthorizationDecision { + const capability = normalizeCapability(request.capability); + + if (capability.length === 0) { + return { + outcome: 'deny', + reason: 'invalid_request', + capability, + }; + } + + const verification = verifyAuthorizationContext(request.cwd); + + if (!verification.ok) { + return { + outcome: 'deny', + reason: verification.reason, + capability, + }; + } + + const allowed = verification.manifest.scope.includes(capability); + + return { + outcome: allowed ? 'allow' : 'deny', + reason: allowed ? 'authorized' : 'capability_not_in_scope', + capability, + evidence: { + manifestDigest: verification.manifestDigest, + artifactDigest: verification.artifactDigest, + ...(request.protocol ? { protocol: request.protocol } : {}), + ...(request.sessionId ? { sessionId: request.sessionId } : {}), + ...(request.resource ? { resource: request.resource } : {}), + }, + }; +} + +/** + * Guard a downstream operation. The handler is never invoked unless the + * authorization decision is `allow`. + */ +export async function withAuthorization( + request: AuthorizationRequest, + handler: () => T | Promise, +): Promise<{ decision: AuthorizationDecision; value?: T }> { + const decision = authorize(request); + + if (decision.outcome !== 'allow') { + return { decision }; + } + + return { + decision, + value: await handler(), + }; +} diff --git a/src/cli.ts b/src/cli.ts index 440e85f..d84bd09 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { runInit } from './commands/init.js'; import { runSign } from './commands/sign.js'; import { runVerify } from './commands/verify.js'; import { runScope } from './commands/scope.js'; +import { runAuthorize } from './commands/authorize.js'; import { getCliVersion } from './lib/version.js'; const argv = process.argv.slice(2); @@ -27,6 +28,9 @@ switch (cmd) { case 'scope': runScope(cwd()); break; + case 'authorize': + runAuthorize(cwd(), argv[1]); + break; case undefined: case '-h': case '--help': @@ -35,10 +39,16 @@ switch (cmd) { Usage: nxtlinq-attest Commands: - init Initialize nxtlinq/ (keys and agent.manifest.json) - sign Sign manifest and artifact, write nxtlinq/agent.manifest.sig - verify Verify manifest and artifact integrity (exit 1 on failure) - scope Print manifest scope as JSON to stdout (for any runtime to call) + init Initialize nxtlinq/ (keys and agent.manifest.json) + sign Sign manifest and artifact, write nxtlinq/agent.manifest.sig + verify Verify manifest and artifact integrity (exit 1 on failure) + scope Print manifest scope as JSON to stdout + authorize Verify and authorize one signed capability as JSON + +Authorize exit codes: + 0 allow + 2 deny + 1 invalid command usage Options: -h, --help Show this help. diff --git a/src/commands/authorize.ts b/src/commands/authorize.ts new file mode 100644 index 0000000..01edbf9 --- /dev/null +++ b/src/commands/authorize.ts @@ -0,0 +1,19 @@ +import { authorize } from '../authorization.js'; + +export function runAuthorize(cwd: string, capability?: string): never { + if (capability == null || capability.trim().length === 0) { + console.error( + JSON.stringify({ + outcome: 'deny', + reason: 'invalid_request', + capability: '', + }), + ); + process.exit(1); + } + + const decision = authorize({ capability, cwd }); + console.log(JSON.stringify(decision)); + + process.exit(decision.outcome === 'allow' ? 0 : 2); +} diff --git a/src/runtime.ts b/src/runtime.ts index dada240..b09326a 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,12 +1,25 @@ /** - * Runtime API for consumers: read manifest scope and check tool allowance. - * Use this in your agent app to enforce attested scope without re-implementing file read logic. + * Runtime API for consumers: read manifest scope, check tool allowance, and + * produce verified authorization decisions. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentManifest } from './lib/manifest.js'; +export { + authorize, + verifyAuthorizationContext, + withAuthorization, +} from './authorization.js'; + +export type { + AuthorizationDecision, + AuthorizationEvidence, + AuthorizationOutcome, + AuthorizationRequest, +} from './authorization.js'; + const NXTLINQ_DIR = 'nxtlinq'; const MANIFEST_BASENAME = 'agent.manifest.json'; @@ -51,11 +64,9 @@ export function getAttestScope(cwd?: string): string[] { } /** - * Check if a tool is allowed by the attested manifest scope. - * Scope entries are typically "tool:ToolName"; we accept either "ToolName" or "tool:ToolName". - * Missing, invalid, and empty scopes fail closed by default. - * Set options.allowEmptyScope only when intentionally preserving the legacy - * permissive behavior during migration. + * Check if a tool is allowed by the manifest scope without performing signature + * or artifact verification. Use `authorize()` when an enforcement decision is + * required. */ export function isToolInAttestScope( toolName: string, diff --git a/test/authorize.test.mjs b/test/authorize.test.mjs new file mode 100644 index 0000000..7329e54 --- /dev/null +++ b/test/authorize.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { authorize, withAuthorization } from '../dist/runtime.js'; + +const cliPath = fileURLToPath(new URL('../dist/cli.js', import.meta.url)); + +function runCli(cwd, ...args) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: 'utf8', + }); +} + +function createSignedFixture(scope = ['tool:write']) { + const cwd = mkdtempSync(join(tmpdir(), 'nxtlinq-authorize-')); + writeFileSync(join(cwd, 'artifact.txt'), 'original\n'); + + const init = runCli(cwd, 'init'); + assert.equal(init.status, 0, init.stderr); + + const manifestPath = join(cwd, 'nxtlinq', 'agent.manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + manifest.scope = scope; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const sign = runCli(cwd, 'sign'); + assert.equal(sign.status, 0, sign.stderr); + + return cwd; +} + +test('authorize allows a signed in-scope capability', () => { + const cwd = createSignedFixture(['tool:write']); + const decision = authorize({ capability: 'tool:write', cwd }); + + assert.equal(decision.outcome, 'allow'); + assert.equal(decision.reason, 'authorized'); + assert.equal(decision.capability, 'tool:write'); + assert.ok(decision.evidence?.manifestDigest); + assert.ok(decision.evidence?.artifactDigest); +}); + +test('authorize normalizes a bare tool capability', () => { + const cwd = createSignedFixture(['tool:write']); + const decision = authorize({ capability: 'write', cwd }); + + assert.equal(decision.outcome, 'allow'); + assert.equal(decision.capability, 'tool:write'); +}); + +test('authorize denies a signed out-of-scope capability', () => { + const cwd = createSignedFixture(['tool:write']); + const decision = authorize({ capability: 'tool:exec', cwd }); + + assert.equal(decision.outcome, 'deny'); + assert.equal(decision.reason, 'capability_not_in_scope'); +}); + +test('authorize fails closed after an artifact is altered', () => { + const cwd = createSignedFixture(['tool:write']); + writeFileSync(join(cwd, 'artifact.txt'), 'altered\n'); + + const decision = authorize({ capability: 'tool:write', cwd }); + + assert.equal(decision.outcome, 'deny'); + assert.equal(decision.reason, 'artifact_integrity_failed'); +}); + +test('withAuthorization never invokes a denied handler', async () => { + const cwd = createSignedFixture(['tool:write']); + let calls = 0; + + const result = await withAuthorization( + { capability: 'tool:exec', cwd }, + () => { + calls += 1; + return 'executed'; + }, + ); + + assert.equal(result.decision.outcome, 'deny'); + assert.equal(calls, 0); + assert.equal(result.value, undefined); +}); + +test('withAuthorization invokes an allowed handler exactly once', async () => { + const cwd = createSignedFixture(['tool:write']); + let calls = 0; + + const result = await withAuthorization( + { capability: 'tool:write', cwd }, + () => { + calls += 1; + return 'executed'; + }, + ); + + assert.equal(result.decision.outcome, 'allow'); + assert.equal(calls, 1); + assert.equal(result.value, 'executed'); +}); + +test('authorize CLI emits machine-readable JSON and stable exit codes', () => { + const cwd = createSignedFixture(['tool:write']); + + const allowed = runCli(cwd, 'authorize', 'tool:write'); + assert.equal(allowed.status, 0, allowed.stderr); + assert.equal(JSON.parse(allowed.stdout).outcome, 'allow'); + + const denied = runCli(cwd, 'authorize', 'tool:exec'); + assert.equal(denied.status, 2, denied.stderr); + assert.equal(JSON.parse(denied.stdout).outcome, 'deny'); +});