@rigour-labs/core 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import type { AgentAction } from './types.js';
2
+ export declare function normalizeMcpAction(input: {
3
+ actorId: string;
4
+ taskId: string;
5
+ server: string;
6
+ tool: string;
7
+ args?: Record<string, unknown>;
8
+ }): AgentAction;
@@ -0,0 +1,44 @@
1
+ const DESTRUCTIVE_WORDS = /(?:^|[_./-])(delete|destroy|drop|purge|remove|terminate|merge)(?:$|[_./-])/i;
2
+ const WRITE_WORDS = /(?:^|[_./-])(create|update|write|post|put|patch|send|deploy|restart|apply)(?:$|[_./-])/i;
3
+ const READ_WORDS = /(?:^|[_./-])(get|list|read|search|find|fetch|view|status)(?:$|[_./-])/i;
4
+ function classifyEnvironment(resource, args) {
5
+ const text = `${resource} ${JSON.stringify(args)}`.toLowerCase();
6
+ if (/\b(prod|production)\b/.test(text))
7
+ return 'production';
8
+ if (/\b(stage|staging|preprod)\b/.test(text))
9
+ return 'staging';
10
+ if (/\b(dev|development)\b/.test(text))
11
+ return 'development';
12
+ if (/\b(local|localhost|127\.0\.0\.1)\b/.test(text))
13
+ return 'local';
14
+ return 'unknown';
15
+ }
16
+ function classifySideEffect(tool) {
17
+ if (DESTRUCTIVE_WORDS.test(tool))
18
+ return 'destructive';
19
+ if (WRITE_WORDS.test(tool))
20
+ return 'external-write';
21
+ if (READ_WORDS.test(tool))
22
+ return 'read';
23
+ return 'external-write';
24
+ }
25
+ export function normalizeMcpAction(input) {
26
+ const args = input.args ?? {};
27
+ const resource = `mcp://${input.server}/${input.tool}`;
28
+ const sideEffect = classifySideEffect(input.tool);
29
+ return {
30
+ version: 1,
31
+ actorId: input.actorId,
32
+ taskId: input.taskId,
33
+ channel: 'mcp',
34
+ operation: input.tool,
35
+ resource,
36
+ environment: classifyEnvironment(resource, args),
37
+ sideEffect,
38
+ reversibility: sideEffect === 'read' ? 'high' : sideEffect === 'destructive' ? 'low' : 'unknown',
39
+ sensitivity: /secret|credential|token|password|private/i.test(`${input.tool} ${JSON.stringify(args)}`)
40
+ ? 'restricted'
41
+ : 'normal',
42
+ blastRadius: sideEffect === 'read' ? 'single-resource' : sideEffect === 'destructive' ? 'broad' : 'unknown',
43
+ };
44
+ }
@@ -9,6 +9,8 @@ export interface BrokerConfig {
9
9
  agentScopes: AgentScopeRecord[];
10
10
  agentId?: string;
11
11
  taskId?: string;
12
+ /** Legacy preserves the pre-6.2 auto-issue behaviour for existing callers. */
13
+ authorizationMode?: 'legacy' | 'observe' | 'enforce';
12
14
  }
13
15
  export declare class CapabilityBroker {
14
16
  private readonly config;
@@ -53,6 +53,25 @@ export class CapabilityBroker {
53
53
  if (input.capabilityId) {
54
54
  return this.evaluateWithCapability(input.capabilityId, input.action, input.resource, timestamp);
55
55
  }
56
+ if (this.config.authorizationMode === 'enforce') {
57
+ return this.record({
58
+ decision: 'deny',
59
+ reason: `Explicit capability required for ${input.action}:${input.resource}`,
60
+ ruleId: 'capability.required',
61
+ policyHash: this.policyHash,
62
+ timestamp,
63
+ });
64
+ }
65
+ if (this.config.authorizationMode === 'observe') {
66
+ return this.record({
67
+ decision: 'allow',
68
+ simulatedDecision: 'deny',
69
+ reason: `Observe mode: explicit capability would be required for ${input.action}:${input.resource}`,
70
+ ruleId: 'capability.observe-required',
71
+ policyHash: this.policyHash,
72
+ timestamp,
73
+ });
74
+ }
56
75
  const grant = this.issue(input.action, input.resource, input.args);
57
76
  return this.record({
58
77
  decision: 'allow',
@@ -0,0 +1,8 @@
1
+ import type { ExecutionReceipt } from './types.js';
2
+ export declare function appendExecutionReceipt(cwd: string, input: Omit<ExecutionReceipt, 'version' | 'id' | 'repositoryId' | 'createdAt' | 'previousReceipt' | 'digest' | 'signature'>, controlRoot?: string): Promise<ExecutionReceipt>;
3
+ export declare function listExecutionReceipts(cwd: string, limit?: number, controlRoot?: string): Promise<ExecutionReceipt[]>;
4
+ export declare function verifyExecutionReceiptChain(cwd: string, controlRoot?: string): Promise<{
5
+ valid: boolean;
6
+ count: number;
7
+ reason?: string;
8
+ }>;
@@ -0,0 +1,107 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
2
+ import fs from 'fs-extra';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import path from 'path';
5
+ import { getRepositoryControlId, getTrustedControlDir } from './trusted-control.js';
6
+ async function receiptKey(cwd, controlRoot) {
7
+ const dir = getTrustedControlDir(cwd, controlRoot);
8
+ const keyPath = path.join(dir, 'receipt.key');
9
+ await fs.ensureDir(dir, 0o700);
10
+ if (await fs.pathExists(keyPath)) {
11
+ const existing = await fs.readFile(keyPath);
12
+ if (existing.length !== 32)
13
+ throw new Error('Receipt signing key is invalid');
14
+ return existing;
15
+ }
16
+ const key = randomBytes(32);
17
+ await fs.writeFile(keyPath, key, { mode: 0o600 });
18
+ return key;
19
+ }
20
+ async function acquireReceiptLock(logPath) {
21
+ const lockPath = `${logPath}.append-lock`;
22
+ for (let attempt = 0; attempt < 100; attempt += 1) {
23
+ try {
24
+ return await fs.promises.open(lockPath, 'wx', 0o600);
25
+ }
26
+ catch (error) {
27
+ if (error?.code !== 'EEXIST')
28
+ throw error;
29
+ await delay(10);
30
+ }
31
+ }
32
+ throw new Error('Timed out waiting to append execution receipt');
33
+ }
34
+ export async function appendExecutionReceipt(cwd, input, controlRoot) {
35
+ const dir = getTrustedControlDir(cwd, controlRoot);
36
+ const logPath = path.join(dir, 'receipts.jsonl');
37
+ await fs.ensureDir(dir, 0o700);
38
+ const lock = await acquireReceiptLock(logPath);
39
+ try {
40
+ let previousReceipt;
41
+ if (await fs.pathExists(logPath)) {
42
+ const lines = (await fs.readFile(logPath, 'utf8')).trim().split('\n').filter(Boolean);
43
+ const previous = lines.at(-1);
44
+ if (previous)
45
+ previousReceipt = JSON.parse(previous).digest;
46
+ }
47
+ const unsigned = {
48
+ version: 1,
49
+ id: randomUUID(),
50
+ repositoryId: getRepositoryControlId(cwd),
51
+ ...input,
52
+ createdAt: new Date().toISOString(),
53
+ previousReceipt,
54
+ };
55
+ const serialized = JSON.stringify(unsigned);
56
+ const digest = createHash('sha256').update(serialized).digest('hex');
57
+ const signature = createHmac('sha256', await receiptKey(cwd, controlRoot)).update(digest).digest('hex');
58
+ const receipt = { ...unsigned, digest, signature };
59
+ await fs.appendFile(logPath, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
60
+ // Studio consumes this untrusted projection. Enforcement always reads the
61
+ // signed log outside the repository, never this workspace-owned copy.
62
+ const projectionDir = path.join(cwd, '.rigour');
63
+ try {
64
+ await fs.ensureDir(projectionDir);
65
+ await fs.appendFile(path.join(projectionDir, 'execution-receipts.jsonl'), `${JSON.stringify(receipt)}\n`);
66
+ }
67
+ catch {
68
+ // A read-only agent sandbox may block the optional Studio projection.
69
+ // The canonical trusted receipt is already durable and remains valid.
70
+ }
71
+ return receipt;
72
+ }
73
+ finally {
74
+ await lock.close();
75
+ await fs.remove(`${logPath}.append-lock`);
76
+ }
77
+ }
78
+ export async function listExecutionReceipts(cwd, limit = 50, controlRoot) {
79
+ const source = path.join(getTrustedControlDir(cwd, controlRoot), 'receipts.jsonl');
80
+ if (!await fs.pathExists(source))
81
+ return [];
82
+ return (await fs.readFile(source, 'utf8')).trim().split('\n').filter(Boolean)
83
+ .slice(-Math.max(1, limit)).map((line) => JSON.parse(line));
84
+ }
85
+ export async function verifyExecutionReceiptChain(cwd, controlRoot) {
86
+ try {
87
+ const receipts = await listExecutionReceipts(cwd, Number.MAX_SAFE_INTEGER, controlRoot);
88
+ const key = receipts.length > 0 ? await receiptKey(cwd, controlRoot) : null;
89
+ let previous;
90
+ for (const receipt of receipts) {
91
+ const { digest, signature, ...unsigned } = receipt;
92
+ if (unsigned.previousReceipt !== previous) {
93
+ return { valid: false, count: receipts.length, reason: `Broken chain at receipt ${receipt.id}` };
94
+ }
95
+ const expectedDigest = createHash('sha256').update(JSON.stringify(unsigned)).digest('hex');
96
+ const expectedSignature = createHmac('sha256', key).update(expectedDigest).digest('hex');
97
+ if (digest !== expectedDigest || signature !== expectedSignature) {
98
+ return { valid: false, count: receipts.length, reason: `Invalid signature at receipt ${receipt.id}` };
99
+ }
100
+ previous = digest;
101
+ }
102
+ return { valid: true, count: receipts.length };
103
+ }
104
+ catch (error) {
105
+ return { valid: false, count: 0, reason: error?.message ?? String(error) };
106
+ }
107
+ }
@@ -49,6 +49,17 @@ describe('scope enforcement', () => {
49
49
  });
50
50
  });
51
51
  describe('capability broker', () => {
52
+ it('requires explicit authority in enforce mode and reports shadow decisions in observe mode', () => {
53
+ const base = { defaultTtlMs: 1000, toolAllowlist: ['*'], agentScopes: [] };
54
+ const enforced = new CapabilityBroker({ ...base, authorizationMode: 'enforce' });
55
+ expect(enforced.evaluateProposal({ action: 'mcp.call', resource: 'github__get_issue' })).toMatchObject({
56
+ decision: 'deny', ruleId: 'capability.required',
57
+ });
58
+ const observed = new CapabilityBroker({ ...base, authorizationMode: 'observe' });
59
+ expect(observed.evaluateProposal({ action: 'mcp.call', resource: 'github__get_issue' })).toMatchObject({
60
+ decision: 'allow', simulatedDecision: 'deny', ruleId: 'capability.observe-required',
61
+ });
62
+ });
52
63
  it('denies undeclared MCP tools', () => {
53
64
  const broker = new CapabilityBroker({
54
65
  defaultTtlMs: 1000,
@@ -8,3 +8,6 @@ export * from './attestation.js';
8
8
  export * from './adversarial.js';
9
9
  export * from './mcp-gateway.js';
10
10
  export * from './arbitration-token.js';
11
+ export * from './action-ir.js';
12
+ export * from './trusted-control.js';
13
+ export * from './execution-receipt.js';
@@ -8,3 +8,6 @@ export * from './attestation.js';
8
8
  export * from './adversarial.js';
9
9
  export * from './mcp-gateway.js';
10
10
  export * from './arbitration-token.js';
11
+ export * from './action-ir.js';
12
+ export * from './trusted-control.js';
13
+ export * from './execution-receipt.js';
@@ -0,0 +1,44 @@
1
+ import type { CapabilityAction, CapabilityGrant, EnforcementMode } from './types.js';
2
+ export interface GatewayServerConfig {
3
+ command: string;
4
+ args?: string[];
5
+ allow: string[];
6
+ env?: Record<string, string>;
7
+ }
8
+ export interface GatewayControlConfig {
9
+ version: 1;
10
+ mode: EnforcementMode;
11
+ principalId: string;
12
+ agentId: string;
13
+ taskId: string;
14
+ servers: Record<string, GatewayServerConfig>;
15
+ }
16
+ export declare function getRepositoryControlId(cwd: string): string;
17
+ export declare function getTrustedControlDir(cwd: string, controlRoot?: string): string;
18
+ export declare function getGatewayPolicyHash(cwd: string, config: GatewayControlConfig | null): string;
19
+ export declare function validateGatewayConfig(value: unknown): GatewayControlConfig;
20
+ export declare function saveGatewayConfig(cwd: string, config: GatewayControlConfig, controlRoot?: string): Promise<string>;
21
+ export declare function loadGatewayConfig(cwd: string, controlRoot?: string): Promise<GatewayControlConfig | null>;
22
+ interface GrantInput {
23
+ issuerId: string;
24
+ subjectId: string;
25
+ taskId: string;
26
+ action: CapabilityAction;
27
+ resource: string;
28
+ ttlMs: number;
29
+ parentCapabilityId?: string;
30
+ }
31
+ export declare function loadTrustedGrant(cwd: string, id: string, controlRoot?: string): Promise<CapabilityGrant | null>;
32
+ export declare function issueTrustedGrant(cwd: string, input: GrantInput, controlRoot?: string): Promise<CapabilityGrant>;
33
+ export declare function consumeTrustedGrant(cwd: string, input: {
34
+ id: string;
35
+ subjectId: string;
36
+ taskId: string;
37
+ action: CapabilityAction;
38
+ resource: string;
39
+ }, controlRoot?: string): Promise<{
40
+ ok: boolean;
41
+ reason: string;
42
+ grant?: CapabilityGrant;
43
+ }>;
44
+ export {};
@@ -0,0 +1,236 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import fs from 'fs-extra';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { hashPolicy } from './policy-hash.js';
6
+ function canonicalRepository(cwd) {
7
+ try {
8
+ return fs.realpathSync(cwd);
9
+ }
10
+ catch {
11
+ return path.resolve(cwd);
12
+ }
13
+ }
14
+ export function getRepositoryControlId(cwd) {
15
+ return createHash('sha256').update(canonicalRepository(cwd)).digest('hex').slice(0, 24);
16
+ }
17
+ export function getTrustedControlDir(cwd, controlRoot = path.join(os.homedir(), '.rigour', 'control')) {
18
+ return path.join(controlRoot, getRepositoryControlId(cwd));
19
+ }
20
+ export function getGatewayPolicyHash(cwd, config) {
21
+ return hashPolicy({ repositoryId: getRepositoryControlId(cwd), ...(config ?? {}) });
22
+ }
23
+ function requiredString(input, key) {
24
+ if (typeof input[key] !== 'string' || !String(input[key]).trim())
25
+ throw new Error(`${key} is required`);
26
+ }
27
+ function validateStringArray(value, label, required) {
28
+ if (value === undefined && !required)
29
+ return;
30
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
31
+ throw new Error(`${label} must contain only non-empty strings`);
32
+ }
33
+ }
34
+ function validateEnvironment(value, serverName) {
35
+ if (value === undefined)
36
+ return;
37
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
38
+ throw new Error(`Server ${serverName} env must be a string map`);
39
+ }
40
+ if (Object.values(value).some((item) => typeof item !== 'string')) {
41
+ throw new Error(`Server ${serverName} env must be a string map`);
42
+ }
43
+ }
44
+ function validateServer(name, server) {
45
+ if (!/^[a-z0-9_-]+$/i.test(name))
46
+ throw new Error(`Invalid server name: ${name}`);
47
+ if (!server || typeof server.command !== 'string' || !server.command.trim()) {
48
+ throw new Error(`Server ${name} command is required`);
49
+ }
50
+ validateStringArray(server.args, `Server ${name} args`, false);
51
+ validateStringArray(server.allow, `Server ${name} allow`, true);
52
+ validateEnvironment(server.env, name);
53
+ }
54
+ function validateConfigHeader(input) {
55
+ if (input.version !== 1)
56
+ throw new Error('Gateway config version must be 1');
57
+ if (input.mode !== 'observe' && input.mode !== 'enforce')
58
+ throw new Error('Gateway mode must be observe or enforce');
59
+ }
60
+ function validateConfigIdentities(input) {
61
+ for (const key of ['principalId', 'agentId', 'taskId'])
62
+ requiredString(input, key);
63
+ }
64
+ function validateServers(input) {
65
+ if (!input.servers || typeof input.servers !== 'object')
66
+ throw new Error('At least one downstream server is required');
67
+ const servers = input.servers;
68
+ if (Object.keys(servers).length === 0)
69
+ throw new Error('At least one downstream server is required');
70
+ for (const [name, server] of Object.entries(servers))
71
+ validateServer(name, server);
72
+ }
73
+ async function ensureTrustedDir(cwd, controlRoot) {
74
+ const dir = getTrustedControlDir(cwd, controlRoot);
75
+ await fs.ensureDir(dir, 0o700);
76
+ await fs.chmod(dir, 0o700).catch(() => undefined);
77
+ return dir;
78
+ }
79
+ export function validateGatewayConfig(value) {
80
+ if (!value || typeof value !== 'object')
81
+ throw new Error('Gateway config must be an object');
82
+ const input = value;
83
+ validateConfigHeader(input);
84
+ validateConfigIdentities(input);
85
+ validateServers(input);
86
+ return input;
87
+ }
88
+ export async function saveGatewayConfig(cwd, config, controlRoot) {
89
+ const trusted = validateGatewayConfig(config);
90
+ const dir = await ensureTrustedDir(cwd, controlRoot);
91
+ const destination = path.join(dir, 'gateway.json');
92
+ await fs.writeJson(destination, trusted, { spaces: 2, mode: 0o600 });
93
+ await fs.chmod(destination, 0o600).catch(() => undefined);
94
+ return destination;
95
+ }
96
+ export async function loadGatewayConfig(cwd, controlRoot) {
97
+ const source = path.join(getTrustedControlDir(cwd, controlRoot), 'gateway.json');
98
+ if (!await fs.pathExists(source))
99
+ return null;
100
+ return validateGatewayConfig(await fs.readJson(source));
101
+ }
102
+ function grantPath(cwd, id, controlRoot) {
103
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
104
+ throw new Error('Invalid capability id');
105
+ }
106
+ return path.join(getTrustedControlDir(cwd, controlRoot), 'capabilities', `${id}.json`);
107
+ }
108
+ export async function loadTrustedGrant(cwd, id, controlRoot) {
109
+ let source;
110
+ try {
111
+ source = grantPath(cwd, id, controlRoot);
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ if (!await fs.pathExists(source))
117
+ return null;
118
+ return fs.readJson(source);
119
+ }
120
+ function assertDelegationSubset(parent, input) {
121
+ if (parent.used)
122
+ throw new Error('Parent capability is already consumed');
123
+ if (Date.now() > parent.expiresAt)
124
+ throw new Error('Parent capability is expired');
125
+ if (parent.subjectId !== input.issuerId)
126
+ throw new Error('Delegating issuer is not the parent subject');
127
+ if (parent.taskId !== input.taskId)
128
+ throw new Error('Child capability must preserve parent task');
129
+ if (parent.action !== input.action || parent.resource !== input.resource) {
130
+ throw new Error('Child capability must preserve parent action and resource');
131
+ }
132
+ if (Date.now() + input.ttlMs > parent.expiresAt)
133
+ throw new Error('Child capability cannot outlive parent capability');
134
+ }
135
+ function validateGrantInput(input) {
136
+ if (!input.issuerId || !input.subjectId || !input.taskId || !input.resource) {
137
+ throw new Error('Grant identity, task, and resource are required');
138
+ }
139
+ if (!Number.isFinite(input.ttlMs) || input.ttlMs < 1_000 || input.ttlMs > 3_600_000) {
140
+ throw new Error('Grant TTL must be between 1 second and 1 hour');
141
+ }
142
+ }
143
+ async function consumeParentGrant(cwd, input, controlRoot) {
144
+ if (!input.parentCapabilityId)
145
+ return;
146
+ const parent = await loadTrustedGrant(cwd, input.parentCapabilityId, controlRoot);
147
+ if (!parent)
148
+ throw new Error('Parent capability not found');
149
+ assertDelegationSubset(parent, input);
150
+ const consumed = await consumeTrustedGrant(cwd, {
151
+ id: parent.id,
152
+ subjectId: input.issuerId,
153
+ taskId: input.taskId,
154
+ action: input.action,
155
+ resource: input.resource,
156
+ }, controlRoot);
157
+ if (!consumed.ok)
158
+ throw new Error(`Parent capability could not be consumed: ${consumed.reason}`);
159
+ }
160
+ function validateGrantAgainstConfig(input, config) {
161
+ if (!config)
162
+ return;
163
+ if (input.subjectId !== config.agentId || input.taskId !== config.taskId) {
164
+ throw new Error('Grant subject and task must match the trusted gateway configuration');
165
+ }
166
+ if (!input.parentCapabilityId && input.issuerId !== config.principalId) {
167
+ throw new Error('Root capability issuer must match the trusted gateway principal');
168
+ }
169
+ }
170
+ export async function issueTrustedGrant(cwd, input, controlRoot) {
171
+ validateGrantInput(input);
172
+ const config = await loadGatewayConfig(cwd, controlRoot);
173
+ validateGrantAgainstConfig(input, config);
174
+ await consumeParentGrant(cwd, input, controlRoot);
175
+ const policyHash = getGatewayPolicyHash(cwd, config);
176
+ const grant = {
177
+ id: randomUUID(),
178
+ action: input.action,
179
+ resource: input.resource,
180
+ expiresAt: Date.now() + input.ttlMs,
181
+ taskId: input.taskId,
182
+ agentId: input.subjectId,
183
+ issuerId: input.issuerId,
184
+ subjectId: input.subjectId,
185
+ parentCapabilityId: input.parentCapabilityId,
186
+ policyHash,
187
+ used: false,
188
+ };
189
+ const dir = path.dirname(grantPath(cwd, grant.id, controlRoot));
190
+ await fs.ensureDir(dir, 0o700);
191
+ await fs.writeJson(grantPath(cwd, grant.id, controlRoot), grant, { spaces: 2, mode: 0o600 });
192
+ return grant;
193
+ }
194
+ export async function consumeTrustedGrant(cwd, input, controlRoot) {
195
+ let source;
196
+ try {
197
+ source = grantPath(cwd, input.id, controlRoot);
198
+ }
199
+ catch {
200
+ return { ok: false, reason: 'Invalid capability id' };
201
+ }
202
+ const lock = `${source}.consume-lock`;
203
+ let handle;
204
+ try {
205
+ await fs.ensureDir(path.dirname(source), 0o700);
206
+ handle = await fs.promises.open(lock, 'wx', 0o600);
207
+ }
208
+ catch (error) {
209
+ if (error?.code === 'EEXIST')
210
+ return { ok: false, reason: 'Capability consumption already in progress' };
211
+ throw error;
212
+ }
213
+ try {
214
+ const grant = await loadTrustedGrant(cwd, input.id, controlRoot);
215
+ if (!grant)
216
+ return { ok: false, reason: 'Unknown capability id' };
217
+ if (grant.used)
218
+ return { ok: false, reason: 'Capability already consumed' };
219
+ if (Date.now() > grant.expiresAt)
220
+ return { ok: false, reason: 'Capability expired' };
221
+ if (grant.subjectId !== input.subjectId || grant.taskId !== input.taskId)
222
+ return { ok: false, reason: 'Capability identity or task mismatch' };
223
+ if (grant.action !== input.action || grant.resource !== input.resource)
224
+ return { ok: false, reason: 'Capability action or resource mismatch' };
225
+ const config = await loadGatewayConfig(cwd, controlRoot);
226
+ if (grant.policyHash !== getGatewayPolicyHash(cwd, config))
227
+ return { ok: false, reason: 'Capability policy is stale' };
228
+ grant.used = true;
229
+ await fs.writeJson(source, grant, { spaces: 2, mode: 0o600 });
230
+ return { ok: true, reason: 'Capability consumed', grant };
231
+ }
232
+ finally {
233
+ await handle.close();
234
+ await fs.remove(lock);
235
+ }
236
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import fs from 'fs-extra';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { appendExecutionReceipt, consumeTrustedGrant, getTrustedControlDir, issueTrustedGrant, normalizeMcpAction, saveGatewayConfig, verifyExecutionReceiptChain, } from './index.js';
6
+ const repositories = [];
7
+ const controlRoots = [];
8
+ async function repository() {
9
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-gateway-test-'));
10
+ repositories.push(cwd);
11
+ return cwd;
12
+ }
13
+ async function controlRoot() {
14
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-control-test-'));
15
+ controlRoots.push(root);
16
+ return root;
17
+ }
18
+ afterEach(async () => {
19
+ for (const cwd of repositories.splice(0)) {
20
+ await fs.remove(cwd);
21
+ }
22
+ for (const root of controlRoots.splice(0))
23
+ await fs.remove(root);
24
+ });
25
+ describe('trusted gateway control', () => {
26
+ it('stores configuration outside the repository', async () => {
27
+ const cwd = await repository();
28
+ const root = await controlRoot();
29
+ const location = await saveGatewayConfig(cwd, {
30
+ version: 1,
31
+ mode: 'observe',
32
+ principalId: 'owner',
33
+ agentId: 'agent-1',
34
+ taskId: 'task-1',
35
+ servers: { github: { command: 'github-mcp', allow: ['get_issue'] } },
36
+ }, root);
37
+ expect(location.startsWith(cwd)).toBe(false);
38
+ expect(await fs.pathExists(location)).toBe(true);
39
+ });
40
+ it('consumes a capability exactly once', async () => {
41
+ const cwd = await repository();
42
+ const root = await controlRoot();
43
+ const grant = await issueTrustedGrant(cwd, {
44
+ issuerId: 'owner', subjectId: 'agent-1', taskId: 'task-1',
45
+ action: 'mcp.call', resource: 'mcp://github/create_issue', ttlMs: 60_000,
46
+ }, root);
47
+ const proposal = {
48
+ id: grant.id, subjectId: 'agent-1', taskId: 'task-1',
49
+ action: 'mcp.call', resource: 'mcp://github/create_issue',
50
+ };
51
+ expect((await consumeTrustedGrant(cwd, proposal, root)).ok).toBe(true);
52
+ expect((await consumeTrustedGrant(cwd, proposal, root)).ok).toBe(false);
53
+ });
54
+ it('rejects path traversal in a capability id', async () => {
55
+ const cwd = await repository();
56
+ const root = await controlRoot();
57
+ const result = await consumeTrustedGrant(cwd, {
58
+ id: '../../gateway', subjectId: 'agent-1', taskId: 'task-1',
59
+ action: 'mcp.call', resource: 'mcp://github/create_issue',
60
+ }, root);
61
+ expect(result).toMatchObject({ ok: false, reason: 'Invalid capability id' });
62
+ });
63
+ it('rejects delegated authority that expands its parent', async () => {
64
+ const cwd = await repository();
65
+ const root = await controlRoot();
66
+ const parent = await issueTrustedGrant(cwd, {
67
+ issuerId: 'owner', subjectId: 'lead-agent', taskId: 'task-1',
68
+ action: 'mcp.call', resource: 'mcp://github/get_issue', ttlMs: 60_000,
69
+ }, root);
70
+ await expect(issueTrustedGrant(cwd, {
71
+ issuerId: 'lead-agent', subjectId: 'worker-agent', taskId: 'task-1',
72
+ action: 'mcp.call', resource: 'mcp://github/delete_repo', ttlMs: 30_000,
73
+ parentCapabilityId: parent.id,
74
+ }, root)).rejects.toThrow('preserve parent action and resource');
75
+ });
76
+ it('consumes the parent when delegated authority is issued', async () => {
77
+ const cwd = await repository();
78
+ const root = await controlRoot();
79
+ const parent = await issueTrustedGrant(cwd, {
80
+ issuerId: 'owner', subjectId: 'lead-agent', taskId: 'task-1',
81
+ action: 'mcp.call', resource: 'mcp://github/get_issue', ttlMs: 60_000,
82
+ }, root);
83
+ const child = await issueTrustedGrant(cwd, {
84
+ issuerId: 'lead-agent', subjectId: 'worker-agent', taskId: 'task-1',
85
+ action: 'mcp.call', resource: 'mcp://github/get_issue', ttlMs: 30_000,
86
+ parentCapabilityId: parent.id,
87
+ }, root);
88
+ expect(child.parentCapabilityId).toBe(parent.id);
89
+ const parentResult = await consumeTrustedGrant(cwd, {
90
+ id: parent.id, subjectId: 'lead-agent', taskId: 'task-1',
91
+ action: 'mcp.call', resource: 'mcp://github/get_issue',
92
+ }, root);
93
+ expect(parentResult).toMatchObject({ ok: false, reason: 'Capability already consumed' });
94
+ });
95
+ it('invalidates grants when the trusted policy changes', async () => {
96
+ const cwd = await repository();
97
+ const root = await controlRoot();
98
+ const base = {
99
+ version: 1, principalId: 'owner', agentId: 'agent-1', taskId: 'task-1',
100
+ servers: { github: { command: 'github-mcp', allow: ['get_issue'] } },
101
+ };
102
+ await saveGatewayConfig(cwd, { ...base, mode: 'observe' }, root);
103
+ const grant = await issueTrustedGrant(cwd, {
104
+ issuerId: 'owner', subjectId: 'agent-1', taskId: 'task-1',
105
+ action: 'mcp.call', resource: 'mcp://github/get_issue', ttlMs: 60_000,
106
+ }, root);
107
+ await saveGatewayConfig(cwd, { ...base, mode: 'enforce' }, root);
108
+ const result = await consumeTrustedGrant(cwd, {
109
+ id: grant.id, subjectId: 'agent-1', taskId: 'task-1',
110
+ action: 'mcp.call', resource: 'mcp://github/get_issue',
111
+ }, root);
112
+ expect(result).toMatchObject({ ok: false, reason: 'Capability policy is stale' });
113
+ });
114
+ });
115
+ describe('action normalization and receipts', () => {
116
+ it('defaults unknown tools to external writes instead of assuming read-only', () => {
117
+ const action = normalizeMcpAction({
118
+ actorId: 'agent-1', taskId: 'task-1', server: 'custom', tool: 'execute', args: {},
119
+ });
120
+ expect(action.sideEffect).toBe('external-write');
121
+ expect(action.blastRadius).toBe('unknown');
122
+ });
123
+ it('detects receipt tampering', async () => {
124
+ const cwd = await repository();
125
+ const root = await controlRoot();
126
+ const action = normalizeMcpAction({
127
+ actorId: 'agent-1', taskId: 'task-1', server: 'github', tool: 'get_issue', args: {},
128
+ });
129
+ await appendExecutionReceipt(cwd, {
130
+ action, mode: 'observe', decision: 'allow', simulatedDecision: 'deny',
131
+ reason: 'Observed', policyHash: 'policy', outcome: 'forwarded',
132
+ }, root);
133
+ expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 1 });
134
+ const log = path.join(getTrustedControlDir(cwd, root), 'receipts.jsonl');
135
+ const receipt = JSON.parse((await fs.readFile(log, 'utf8')).trim());
136
+ receipt.reason = 'tampered';
137
+ await fs.writeFile(log, `${JSON.stringify(receipt)}\n`);
138
+ expect((await verifyExecutionReceiptChain(cwd, root)).valid).toBe(false);
139
+ });
140
+ it('serializes concurrent receipt appends into one valid chain', async () => {
141
+ const cwd = await repository();
142
+ const root = await controlRoot();
143
+ const action = normalizeMcpAction({
144
+ actorId: 'agent-1', taskId: 'task-1', server: 'github', tool: 'get_issue', args: {},
145
+ });
146
+ await Promise.all(Array.from({ length: 8 }, () => appendExecutionReceipt(cwd, {
147
+ action, mode: 'observe', decision: 'allow', reason: 'Concurrent call',
148
+ policyHash: 'policy', outcome: 'forwarded',
149
+ }, root)));
150
+ expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 8 });
151
+ });
152
+ it('keeps the trusted receipt when the Studio projection is blocked', async () => {
153
+ const cwd = await repository();
154
+ const root = await controlRoot();
155
+ await fs.writeFile(path.join(cwd, '.rigour'), 'sandbox blocks this projection');
156
+ const action = normalizeMcpAction({
157
+ actorId: 'agent-1', taskId: 'task-1', server: 'github', tool: 'get_issue', args: {},
158
+ });
159
+ await expect(appendExecutionReceipt(cwd, {
160
+ action, mode: 'observe', decision: 'allow', reason: 'Projection optional',
161
+ policyHash: 'policy', outcome: 'forwarded',
162
+ }, root)).resolves.toMatchObject({ outcome: 'forwarded' });
163
+ expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 1 });
164
+ });
165
+ });
@@ -14,6 +14,9 @@ export interface CapabilityGrant {
14
14
  taskId?: string;
15
15
  policyHash: string;
16
16
  used: boolean;
17
+ issuerId?: string;
18
+ subjectId?: string;
19
+ parentCapabilityId?: string;
17
20
  }
18
21
  export interface PolicyEvaluation {
19
22
  decision: FirewallDecision;
@@ -22,6 +25,38 @@ export interface PolicyEvaluation {
22
25
  capabilityId?: string;
23
26
  policyHash: string;
24
27
  timestamp: string;
28
+ simulatedDecision?: Exclude<FirewallDecision, 'allow'>;
29
+ }
30
+ export type EnforcementMode = 'observe' | 'enforce';
31
+ export interface AgentAction {
32
+ version: 1;
33
+ actorId: string;
34
+ taskId: string;
35
+ channel: 'mcp' | 'shell';
36
+ operation: string;
37
+ resource: string;
38
+ environment: 'local' | 'development' | 'staging' | 'production' | 'unknown';
39
+ sideEffect: 'read' | 'local-write' | 'external-write' | 'destructive';
40
+ reversibility: 'high' | 'medium' | 'low' | 'unknown';
41
+ sensitivity: 'normal' | 'sensitive' | 'restricted';
42
+ blastRadius: 'single-resource' | 'bounded' | 'broad' | 'unknown';
43
+ }
44
+ export interface ExecutionReceipt {
45
+ version: 1;
46
+ id: string;
47
+ repositoryId: string;
48
+ action: AgentAction;
49
+ mode: EnforcementMode;
50
+ decision: FirewallDecision;
51
+ simulatedDecision?: Exclude<FirewallDecision, 'allow'>;
52
+ reason: string;
53
+ capabilityId?: string;
54
+ policyHash: string;
55
+ outcome: 'forwarded' | 'denied' | 'failed';
56
+ createdAt: string;
57
+ previousReceipt?: string;
58
+ digest: string;
59
+ signature: string;
25
60
  }
26
61
  export interface TypedCommand {
27
62
  bin: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -67,11 +67,11 @@
67
67
  "pg": "^8.16.3",
68
68
  "sqlite3": "^5.1.7",
69
69
  "openai": "^4.104.0",
70
- "@rigour-labs/brain-darwin-arm64": "6.1.0",
71
- "@rigour-labs/brain-darwin-x64": "6.1.0",
72
- "@rigour-labs/brain-linux-x64": "6.1.0",
73
- "@rigour-labs/brain-win-x64": "6.1.0",
74
- "@rigour-labs/brain-linux-arm64": "6.1.0"
70
+ "@rigour-labs/brain-darwin-arm64": "6.2.0",
71
+ "@rigour-labs/brain-linux-arm64": "6.2.0",
72
+ "@rigour-labs/brain-darwin-x64": "6.2.0",
73
+ "@rigour-labs/brain-win-x64": "6.2.0",
74
+ "@rigour-labs/brain-linux-x64": "6.2.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@types/fs-extra": "^11.0.4",