Skip to content
Draft
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
57 changes: 57 additions & 0 deletions examples/mcp-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
write_file: 'tool:write',
execute_command: 'tool:exec',
};

export async function guardMcpToolCall<T>(
call: McpToolCall,
invokeDownstream: () => Promise<T>,
) {
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.
210 changes: 210 additions & 0 deletions src/authorization.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
try {
manifestRecord = JSON.parse(manifestRaw) as Record<string, unknown>;
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<T>(
request: AuthorizationRequest,
handler: () => T | Promise<T>,
): Promise<{ decision: AuthorizationDecision; value?: T }> {
const decision = authorize(request);

if (decision.outcome !== 'allow') {
return { decision };
}

return {
decision,
value: await handler(),
};
}
18 changes: 14 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -27,6 +28,9 @@ switch (cmd) {
case 'scope':
runScope(cwd());
break;
case 'authorize':
runAuthorize(cwd(), argv[1]);
break;
case undefined:
case '-h':
case '--help':
Expand All @@ -35,10 +39,16 @@ switch (cmd) {
Usage: nxtlinq-attest <command>

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 <capability> 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.
Expand Down
19 changes: 19 additions & 0 deletions src/commands/authorize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 18 additions & 7 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Loading