@rigour-labs/core 5.5.4 → 6.1.0-beta.1
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.
- package/dist/context/automatic-index.d.ts +18 -0
- package/dist/context/automatic-index.js +142 -0
- package/dist/context/automatic-index.test.d.ts +1 -0
- package/dist/context/automatic-index.test.js +45 -0
- package/dist/context/cache-engine.d.ts +25 -0
- package/dist/context/dependency-graph.d.ts +13 -0
- package/dist/context/dependency-graph.js +97 -0
- package/dist/context/dependency-graph.test.d.ts +1 -0
- package/dist/context/dependency-graph.test.js +13 -0
- package/dist/context/index-bridge.js +21 -2
- package/dist/firewall/adversarial.d.ts +13 -0
- package/dist/firewall/adversarial.js +125 -0
- package/dist/firewall/arbitration-token.d.ts +8 -0
- package/dist/firewall/arbitration-token.js +58 -0
- package/dist/firewall/attestation.d.ts +38 -0
- package/dist/firewall/attestation.js +197 -0
- package/dist/firewall/capability-broker.d.ts +47 -0
- package/dist/firewall/capability-broker.js +209 -0
- package/dist/firewall/firewall.test.d.ts +1 -0
- package/dist/firewall/firewall.test.js +102 -0
- package/dist/firewall/index.d.ts +10 -0
- package/dist/firewall/index.js +10 -0
- package/dist/firewall/mcp-gateway.d.ts +40 -0
- package/dist/firewall/mcp-gateway.js +64 -0
- package/dist/firewall/policy-hash.d.ts +1 -0
- package/dist/firewall/policy-hash.js +5 -0
- package/dist/firewall/scope-enforcement.d.ts +26 -0
- package/dist/firewall/scope-enforcement.js +155 -0
- package/dist/firewall/transaction.d.ts +43 -0
- package/dist/firewall/transaction.js +216 -0
- package/dist/firewall/typed-command.d.ts +16 -0
- package/dist/firewall/typed-command.js +129 -0
- package/dist/firewall/types.d.ts +96 -0
- package/dist/firewall/types.js +5 -0
- package/dist/hooks/checker.d.ts +2 -0
- package/dist/hooks/checker.js +25 -1
- package/dist/hooks/standalone-checker.js +5 -1
- package/dist/hooks/templates.js +3 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/pattern-index/embeddings.js +10 -3
- package/dist/pattern-index/indexer.d.ts +2 -0
- package/dist/pattern-index/indexer.js +72 -0
- package/dist/services/agent-history.d.ts +76 -0
- package/dist/services/agent-history.js +201 -0
- package/dist/services/agent-history.test.d.ts +1 -0
- package/dist/services/agent-history.test.js +69 -0
- package/dist/services/context-telemetry-service.d.ts +21 -0
- package/dist/services/context-telemetry-service.js +36 -7
- package/dist/services/context-telemetry-service.test.js +13 -0
- package/dist/services/engineering-knowledge-graph.d.ts +55 -0
- package/dist/services/engineering-knowledge-graph.js +177 -0
- package/dist/services/engineering-knowledge-graph.test.d.ts +1 -0
- package/dist/services/engineering-knowledge-graph.test.js +59 -0
- package/dist/services/model-pricing.d.ts +2 -0
- package/dist/services/model-pricing.js +2 -0
- package/dist/services/system-health.d.ts +34 -0
- package/dist/services/system-health.js +53 -0
- package/dist/storage/db.js +130 -1
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/lessons.d.ts +60 -0
- package/dist/storage/lessons.js +322 -0
- package/dist/storage/lessons.test.d.ts +1 -0
- package/dist/storage/lessons.test.js +50 -0
- package/dist/storage/local-encryption.d.ts +2 -0
- package/dist/storage/local-encryption.js +41 -0
- package/dist/storage/team-store.d.ts +75 -0
- package/dist/storage/team-store.js +321 -0
- package/dist/storage/team-store.test.d.ts +1 -0
- package/dist/storage/team-store.test.js +11 -0
- package/dist/storage/team-vector-store.d.ts +25 -0
- package/dist/storage/team-vector-store.js +183 -0
- package/dist/storage/team-vector-store.test.d.ts +1 -0
- package/dist/storage/team-vector-store.test.js +42 -0
- package/package.json +8 -6
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed attestation bundles for completed transactions.
|
|
3
|
+
* Keys prefer env / home directory — not agent-writable workspace keys for CI admit.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
6
|
+
import fs from 'fs-extra';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { execa } from 'execa';
|
|
10
|
+
const KEY_FILE = 'attestation.key';
|
|
11
|
+
export async function resolveAttestationKey(cwd) {
|
|
12
|
+
const envKey = process.env.RIGOUR_ATTESTATION_KEY;
|
|
13
|
+
if (envKey && envKey.length >= 32) {
|
|
14
|
+
return { key: Buffer.from(envKey, 'utf8'), source: 'env' };
|
|
15
|
+
}
|
|
16
|
+
const homeKeyPath = path.join(os.homedir(), '.rigour', KEY_FILE);
|
|
17
|
+
if (await fs.pathExists(homeKeyPath)) {
|
|
18
|
+
return { key: await fs.readFile(homeKeyPath), source: 'home' };
|
|
19
|
+
}
|
|
20
|
+
const workspaceKeyPath = path.join(cwd, '.rigour', KEY_FILE);
|
|
21
|
+
if (await fs.pathExists(workspaceKeyPath)) {
|
|
22
|
+
return { key: await fs.readFile(workspaceKeyPath), source: 'workspace' };
|
|
23
|
+
}
|
|
24
|
+
// Create home key by default (outside agent workspace)
|
|
25
|
+
await fs.ensureDir(path.join(os.homedir(), '.rigour'));
|
|
26
|
+
const key = randomBytes(32);
|
|
27
|
+
await fs.writeFile(homeKeyPath, key, { mode: 0o600 });
|
|
28
|
+
return { key, source: 'home' };
|
|
29
|
+
}
|
|
30
|
+
/** @deprecated use resolveAttestationKey */
|
|
31
|
+
export async function ensureAttestationKey(cwd) {
|
|
32
|
+
const { key } = await resolveAttestationKey(cwd);
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
function payloadForSign(bundle) {
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
version: bundle.version,
|
|
38
|
+
transactionId: bundle.transactionId,
|
|
39
|
+
agentId: bundle.agentId,
|
|
40
|
+
userId: bundle.userId,
|
|
41
|
+
scope: bundle.scope,
|
|
42
|
+
policyHash: bundle.policyHash,
|
|
43
|
+
capabilities: bundle.capabilities,
|
|
44
|
+
filesUsed: bundle.filesUsed,
|
|
45
|
+
toolsUsed: bundle.toolsUsed,
|
|
46
|
+
gateResults: bundle.gateResults,
|
|
47
|
+
overrides: bundle.overrides,
|
|
48
|
+
artifactDigest: bundle.artifactDigest,
|
|
49
|
+
commitSha: bundle.commitSha,
|
|
50
|
+
treeDigest: bundle.treeDigest,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function computeArtifactDigest(files, contents) {
|
|
54
|
+
const h = createHash('sha256');
|
|
55
|
+
for (const f of [...files].sort()) {
|
|
56
|
+
h.update(f);
|
|
57
|
+
h.update('\0');
|
|
58
|
+
h.update(contents.get(f) ?? '');
|
|
59
|
+
h.update('\0');
|
|
60
|
+
}
|
|
61
|
+
return h.digest('hex');
|
|
62
|
+
}
|
|
63
|
+
export async function getGitCommitSha(dir) {
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { cwd: dir, shell: false });
|
|
66
|
+
return stdout.trim() || null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function getGitTreeDigest(dir) {
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = await execa('git', ['rev-parse', 'HEAD^{tree}'], { cwd: dir, shell: false });
|
|
75
|
+
return stdout.trim() || null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function createAttestation(cwd, input) {
|
|
82
|
+
const { key } = await resolveAttestationKey(cwd);
|
|
83
|
+
const verifyRoot = input.verifyRoot || input.transaction.worktreePath || cwd;
|
|
84
|
+
const commitSha = input.commitSha ?? (await getGitCommitSha(verifyRoot)) ?? undefined;
|
|
85
|
+
const treeDigest = input.treeDigest ?? (await getGitTreeDigest(verifyRoot)) ?? undefined;
|
|
86
|
+
const files = input.transaction.filesChanged;
|
|
87
|
+
const contents = input.fileContents ?? new Map();
|
|
88
|
+
if (files.length > 0 && contents.size === 0) {
|
|
89
|
+
for (const f of files) {
|
|
90
|
+
const abs = path.join(verifyRoot, f);
|
|
91
|
+
if (await fs.pathExists(abs)) {
|
|
92
|
+
contents.set(f, await fs.readFile(abs, 'utf-8'));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let artifactDigest = computeArtifactDigest(files, contents);
|
|
97
|
+
if (files.length === 0 && treeDigest) {
|
|
98
|
+
artifactDigest = createHash('sha256').update(`tree:${treeDigest}`).digest('hex');
|
|
99
|
+
}
|
|
100
|
+
if (!treeDigest && files.length === 0) {
|
|
101
|
+
throw new Error('Attestation requires treeDigest or non-empty filesChanged with contents');
|
|
102
|
+
}
|
|
103
|
+
const unsigned = {
|
|
104
|
+
version: 1,
|
|
105
|
+
transactionId: input.transaction.id,
|
|
106
|
+
agentId: input.transaction.agentId,
|
|
107
|
+
userId: input.userId,
|
|
108
|
+
scope: input.transaction.scope,
|
|
109
|
+
policyHash: input.transaction.policyHash,
|
|
110
|
+
capabilities: input.transaction.capabilitiesIssued,
|
|
111
|
+
filesUsed: files,
|
|
112
|
+
toolsUsed: input.toolsUsed ?? [],
|
|
113
|
+
gateResults: input.gateResults,
|
|
114
|
+
overrides: input.overrides ?? [],
|
|
115
|
+
artifactDigest,
|
|
116
|
+
commitSha,
|
|
117
|
+
treeDigest,
|
|
118
|
+
};
|
|
119
|
+
const signedAt = new Date().toISOString();
|
|
120
|
+
const signature = createHmac('sha256', key)
|
|
121
|
+
.update(payloadForSign(unsigned) + signedAt)
|
|
122
|
+
.digest('hex');
|
|
123
|
+
const bundle = { ...unsigned, signedAt, signature };
|
|
124
|
+
const outDir = path.join(cwd, '.rigour', 'attestations');
|
|
125
|
+
await fs.ensureDir(outDir);
|
|
126
|
+
await fs.writeJson(path.join(outDir, `${bundle.transactionId}.json`), bundle, { spaces: 2 });
|
|
127
|
+
await fs.writeJson(path.join(cwd, '.rigour', 'attestation-latest.json'), bundle, { spaces: 2 });
|
|
128
|
+
return bundle;
|
|
129
|
+
}
|
|
130
|
+
export async function verifyAttestation(cwd, bundle) {
|
|
131
|
+
const { key } = await resolveAttestationKey(cwd);
|
|
132
|
+
const { signature, signedAt, ...rest } = bundle;
|
|
133
|
+
const expected = createHmac('sha256', key)
|
|
134
|
+
.update(payloadForSign(rest) + signedAt)
|
|
135
|
+
.digest('hex');
|
|
136
|
+
return expected === signature;
|
|
137
|
+
}
|
|
138
|
+
export async function loadLatestAttestation(cwd) {
|
|
139
|
+
const p = path.join(cwd, '.rigour', 'attestation-latest.json');
|
|
140
|
+
if (!(await fs.pathExists(p)))
|
|
141
|
+
return null;
|
|
142
|
+
try {
|
|
143
|
+
return await fs.readJson(p);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* CI admission: valid signature, PASS gates, bound commit/tree, fresh, non-workspace-key unless allowed.
|
|
151
|
+
*/
|
|
152
|
+
export async function admitForCi(cwd) {
|
|
153
|
+
const { source } = await resolveAttestationKey(cwd);
|
|
154
|
+
if (source === 'workspace' && process.env.RIGOUR_ALLOW_WORKSPACE_ATTESTATION_KEY !== '1') {
|
|
155
|
+
return {
|
|
156
|
+
admit: false,
|
|
157
|
+
reason: 'Attestation key is workspace-local; set RIGOUR_ATTESTATION_KEY or ~/.rigour/attestation.key',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const bundle = await loadLatestAttestation(cwd);
|
|
161
|
+
if (!bundle) {
|
|
162
|
+
return { admit: false, reason: 'No attestation bundle found' };
|
|
163
|
+
}
|
|
164
|
+
const valid = await verifyAttestation(cwd, bundle);
|
|
165
|
+
if (!valid) {
|
|
166
|
+
return { admit: false, reason: 'Attestation signature invalid' };
|
|
167
|
+
}
|
|
168
|
+
if (bundle.gateResults.status !== 'PASS') {
|
|
169
|
+
return { admit: false, reason: `Gates not PASS (${bundle.gateResults.status})` };
|
|
170
|
+
}
|
|
171
|
+
if (!bundle.treeDigest && (!bundle.filesUsed || bundle.filesUsed.length === 0)) {
|
|
172
|
+
return { admit: false, reason: 'Attestation missing treeDigest and filesUsed' };
|
|
173
|
+
}
|
|
174
|
+
if (!bundle.artifactDigest || bundle.artifactDigest.length < 16) {
|
|
175
|
+
return { admit: false, reason: 'Attestation artifactDigest missing or trivial' };
|
|
176
|
+
}
|
|
177
|
+
const maxAgeMs = Number(process.env.RIGOUR_ATTESTATION_MAX_AGE_MS || 24 * 60 * 60 * 1000);
|
|
178
|
+
const age = Date.now() - Date.parse(bundle.signedAt);
|
|
179
|
+
if (!Number.isFinite(age) || age < 0 || age > maxAgeMs) {
|
|
180
|
+
return { admit: false, reason: 'Attestation expired or has invalid signedAt' };
|
|
181
|
+
}
|
|
182
|
+
const headSha = await getGitCommitSha(cwd);
|
|
183
|
+
if (bundle.commitSha && headSha && bundle.commitSha !== headSha) {
|
|
184
|
+
return {
|
|
185
|
+
admit: false,
|
|
186
|
+
reason: `Attestation commitSha ${bundle.commitSha.slice(0, 8)}≠ HEAD ${headSha.slice(0, 8)}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const headTree = await getGitTreeDigest(cwd);
|
|
190
|
+
if (bundle.treeDigest && headTree && bundle.treeDigest !== headTree) {
|
|
191
|
+
return {
|
|
192
|
+
admit: false,
|
|
193
|
+
reason: `Attestation treeDigest mismatch vs HEAD tree`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { admit: true, reason: 'Valid attestation with PASS gates and bound tree/commit' };
|
|
197
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability broker — issues one-use, short-lived capabilities.
|
|
3
|
+
*/
|
|
4
|
+
import type { CapabilityAction, CapabilityGrant, PolicyEvaluation } from './types.js';
|
|
5
|
+
import { type AgentScopeRecord } from './scope-enforcement.js';
|
|
6
|
+
export interface BrokerConfig {
|
|
7
|
+
defaultTtlMs: number;
|
|
8
|
+
toolAllowlist: string[];
|
|
9
|
+
agentScopes: AgentScopeRecord[];
|
|
10
|
+
agentId?: string;
|
|
11
|
+
taskId?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class CapabilityBroker {
|
|
14
|
+
private readonly config;
|
|
15
|
+
private grants;
|
|
16
|
+
private decisions;
|
|
17
|
+
private readonly policyHash;
|
|
18
|
+
constructor(config: BrokerConfig);
|
|
19
|
+
getPolicyHash(): string;
|
|
20
|
+
getDecisions(): PolicyEvaluation[];
|
|
21
|
+
listGrants(): CapabilityGrant[];
|
|
22
|
+
issue(action: CapabilityAction, resource: string, constraints?: Record<string, unknown>): CapabilityGrant;
|
|
23
|
+
evaluateProposal(input: {
|
|
24
|
+
action: CapabilityAction;
|
|
25
|
+
resource: string;
|
|
26
|
+
args?: Record<string, unknown>;
|
|
27
|
+
command?: string;
|
|
28
|
+
capabilityId?: string;
|
|
29
|
+
}): PolicyEvaluation;
|
|
30
|
+
private prechecks;
|
|
31
|
+
private denyUndeclaredMcp;
|
|
32
|
+
private denyOutOfScopeWrite;
|
|
33
|
+
private evaluateWithCapability;
|
|
34
|
+
private consumeCapability;
|
|
35
|
+
private denyCap;
|
|
36
|
+
private record;
|
|
37
|
+
persistDecisions(cwd: string): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** Pure evaluate for adversarial replay (fresh broker per call). */
|
|
40
|
+
export declare function evaluateActionDeterministic(input: {
|
|
41
|
+
action: CapabilityAction;
|
|
42
|
+
resource: string;
|
|
43
|
+
command?: string;
|
|
44
|
+
toolAllowlist: string[];
|
|
45
|
+
agentScopes: AgentScopeRecord[];
|
|
46
|
+
agentId?: string;
|
|
47
|
+
}): PolicyEvaluation;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability broker — issues one-use, short-lived capabilities.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { hashPolicy } from './policy-hash.js';
|
|
8
|
+
import { evaluateTypedCommand } from './typed-command.js';
|
|
9
|
+
import { isPathInScope } from './scope-enforcement.js';
|
|
10
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
11
|
+
export class CapabilityBroker {
|
|
12
|
+
config;
|
|
13
|
+
grants = new Map();
|
|
14
|
+
decisions = [];
|
|
15
|
+
policyHash;
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.policyHash = hashPolicy({
|
|
19
|
+
toolAllowlist: [...config.toolAllowlist].sort(),
|
|
20
|
+
scopes: config.agentScopes,
|
|
21
|
+
ttl: config.defaultTtlMs ?? DEFAULT_TTL_MS,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
getPolicyHash() {
|
|
25
|
+
return this.policyHash;
|
|
26
|
+
}
|
|
27
|
+
getDecisions() {
|
|
28
|
+
return [...this.decisions];
|
|
29
|
+
}
|
|
30
|
+
listGrants() {
|
|
31
|
+
return [...this.grants.values()];
|
|
32
|
+
}
|
|
33
|
+
issue(action, resource, constraints) {
|
|
34
|
+
const grant = {
|
|
35
|
+
id: randomUUID(),
|
|
36
|
+
action,
|
|
37
|
+
resource,
|
|
38
|
+
constraints,
|
|
39
|
+
expiresAt: Date.now() + (this.config.defaultTtlMs ?? DEFAULT_TTL_MS),
|
|
40
|
+
agentId: this.config.agentId,
|
|
41
|
+
taskId: this.config.taskId,
|
|
42
|
+
policyHash: this.policyHash,
|
|
43
|
+
used: false,
|
|
44
|
+
};
|
|
45
|
+
this.grants.set(grant.id, grant);
|
|
46
|
+
return grant;
|
|
47
|
+
}
|
|
48
|
+
evaluateProposal(input) {
|
|
49
|
+
const timestamp = new Date().toISOString();
|
|
50
|
+
const early = this.prechecks(input, timestamp);
|
|
51
|
+
if (early)
|
|
52
|
+
return early;
|
|
53
|
+
if (input.capabilityId) {
|
|
54
|
+
return this.evaluateWithCapability(input.capabilityId, input.action, input.resource, timestamp);
|
|
55
|
+
}
|
|
56
|
+
const grant = this.issue(input.action, input.resource, input.args);
|
|
57
|
+
return this.record({
|
|
58
|
+
decision: 'allow',
|
|
59
|
+
reason: `Capability issued for ${input.action}:${input.resource}`,
|
|
60
|
+
ruleId: 'capability.issue',
|
|
61
|
+
capabilityId: grant.id,
|
|
62
|
+
policyHash: this.policyHash,
|
|
63
|
+
timestamp,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
prechecks(input, timestamp) {
|
|
67
|
+
if (input.action === 'mcp.call') {
|
|
68
|
+
const deny = this.denyUndeclaredMcp(input.resource, timestamp);
|
|
69
|
+
if (deny)
|
|
70
|
+
return deny;
|
|
71
|
+
}
|
|
72
|
+
if (input.action === 'filesystem.write') {
|
|
73
|
+
const deny = this.denyOutOfScopeWrite(input.resource, timestamp);
|
|
74
|
+
if (deny)
|
|
75
|
+
return deny;
|
|
76
|
+
}
|
|
77
|
+
if (input.action === 'shell.exec' && input.command) {
|
|
78
|
+
const cmdEv = evaluateTypedCommand(input.command);
|
|
79
|
+
if (cmdEv.decision !== 'allow') {
|
|
80
|
+
return this.record({ ...cmdEv, policyHash: this.policyHash, timestamp });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (input.action === 'secret.lease') {
|
|
84
|
+
const grant = this.issue('secret.lease', input.resource, { purpose: input.args?.purpose });
|
|
85
|
+
return this.record({
|
|
86
|
+
decision: 'allow',
|
|
87
|
+
reason: `Secret lease capability issued for ${input.resource} (secret not exposed to agent)`,
|
|
88
|
+
ruleId: 'secret.lease',
|
|
89
|
+
capabilityId: grant.id,
|
|
90
|
+
policyHash: this.policyHash,
|
|
91
|
+
timestamp,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
denyUndeclaredMcp(tool, timestamp) {
|
|
97
|
+
const allow = this.config.toolAllowlist;
|
|
98
|
+
if (allow.includes('*') || allow.includes(tool))
|
|
99
|
+
return null;
|
|
100
|
+
return this.record({
|
|
101
|
+
decision: 'deny',
|
|
102
|
+
reason: `MCP tool "${tool}" is not on the agent allowlist`,
|
|
103
|
+
ruleId: 'mcp.undeclared',
|
|
104
|
+
policyHash: this.policyHash,
|
|
105
|
+
timestamp,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
denyOutOfScopeWrite(resource, timestamp) {
|
|
109
|
+
if (this.config.agentScopes.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
if (!this.config.agentId) {
|
|
112
|
+
return this.record({
|
|
113
|
+
decision: 'scope-violation',
|
|
114
|
+
reason: 'Writer agentId is required when agent scopes are registered (fail-closed; no union-allow)',
|
|
115
|
+
ruleId: 'scope.unbound',
|
|
116
|
+
policyHash: this.policyHash,
|
|
117
|
+
timestamp,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const agents = this.config.agentScopes.filter(a => a.agentId === this.config.agentId);
|
|
121
|
+
const rel = resource.replace(/\\/g, '/');
|
|
122
|
+
const ok = agents.some(a => isPathInScope(rel, a.taskScope));
|
|
123
|
+
if (ok)
|
|
124
|
+
return null;
|
|
125
|
+
return this.record({
|
|
126
|
+
decision: 'scope-violation',
|
|
127
|
+
reason: `Write to "${resource}" outside assigned scope`,
|
|
128
|
+
ruleId: 'scope.out-of-bounds',
|
|
129
|
+
policyHash: this.policyHash,
|
|
130
|
+
timestamp,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
evaluateWithCapability(capabilityId, action, resource, timestamp) {
|
|
134
|
+
const consumed = this.consumeCapability(capabilityId, action, resource, timestamp);
|
|
135
|
+
if (consumed.kind === 'deny')
|
|
136
|
+
return consumed.evaluation;
|
|
137
|
+
return this.record({
|
|
138
|
+
decision: 'allow',
|
|
139
|
+
reason: `Capability ${capabilityId} consumed for ${action}:${resource}`,
|
|
140
|
+
ruleId: 'capability.consume',
|
|
141
|
+
capabilityId,
|
|
142
|
+
policyHash: this.policyHash,
|
|
143
|
+
timestamp,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
consumeCapability(capabilityId, action, resource, timestamp) {
|
|
147
|
+
const grant = this.grants.get(capabilityId);
|
|
148
|
+
if (!grant) {
|
|
149
|
+
return { kind: 'deny', evaluation: this.denyCap('Unknown capability id', 'capability.unknown', timestamp) };
|
|
150
|
+
}
|
|
151
|
+
if (grant.used) {
|
|
152
|
+
return {
|
|
153
|
+
kind: 'deny',
|
|
154
|
+
evaluation: this.denyCap('Capability already consumed (one-use)', 'capability.reuse', timestamp, grant.id),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() > grant.expiresAt) {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'deny',
|
|
160
|
+
evaluation: this.denyCap('Capability expired', 'capability.expired', timestamp, grant.id),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (grant.action !== action || grant.resource !== resource) {
|
|
164
|
+
return {
|
|
165
|
+
kind: 'deny',
|
|
166
|
+
evaluation: this.denyCap('Capability does not match proposed action/resource', 'capability.mismatch', timestamp, grant.id),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
grant.used = true;
|
|
170
|
+
return { kind: 'ok' };
|
|
171
|
+
}
|
|
172
|
+
denyCap(reason, ruleId, timestamp, capabilityId) {
|
|
173
|
+
return this.record({
|
|
174
|
+
decision: 'deny',
|
|
175
|
+
reason,
|
|
176
|
+
ruleId,
|
|
177
|
+
capabilityId,
|
|
178
|
+
policyHash: this.policyHash,
|
|
179
|
+
timestamp,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
record(ev) {
|
|
183
|
+
this.decisions.push(ev);
|
|
184
|
+
return ev;
|
|
185
|
+
}
|
|
186
|
+
async persistDecisions(cwd) {
|
|
187
|
+
const dir = path.join(cwd, '.rigour');
|
|
188
|
+
await fs.ensureDir(dir);
|
|
189
|
+
const file = path.join(dir, 'firewall-decisions.jsonl');
|
|
190
|
+
const lines = this.decisions.map(d => JSON.stringify(d)).join('\n');
|
|
191
|
+
if (lines) {
|
|
192
|
+
await fs.appendFile(file, lines + '\n');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Pure evaluate for adversarial replay (fresh broker per call). */
|
|
197
|
+
export function evaluateActionDeterministic(input) {
|
|
198
|
+
const broker = new CapabilityBroker({
|
|
199
|
+
defaultTtlMs: DEFAULT_TTL_MS,
|
|
200
|
+
toolAllowlist: input.toolAllowlist,
|
|
201
|
+
agentScopes: input.agentScopes,
|
|
202
|
+
agentId: input.agentId,
|
|
203
|
+
});
|
|
204
|
+
return broker.evaluateProposal({
|
|
205
|
+
action: input.action,
|
|
206
|
+
resource: input.resource,
|
|
207
|
+
command: input.command,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluateTypedCommand, parseCommandLine, evaluateWriteScope, evaluateActionDeterministic, runAdversarialCorpus, CapabilityBroker, validateAgentClaimedScope, } from './index.js';
|
|
3
|
+
describe('typed command firewall', () => {
|
|
4
|
+
it('denies shell metacharacters', () => {
|
|
5
|
+
const ev = evaluateTypedCommand('ls; rm -rf /');
|
|
6
|
+
expect(ev.decision).toBe('deny');
|
|
7
|
+
expect(ev.ruleId).toBe('shell.no-meta');
|
|
8
|
+
});
|
|
9
|
+
it('denies unknown binaries including node/npx', () => {
|
|
10
|
+
expect(evaluateTypedCommand('curl https://evil.example').decision).toBe('deny');
|
|
11
|
+
expect(evaluateTypedCommand('node -e "1"').decision).toBe('deny');
|
|
12
|
+
expect(evaluateTypedCommand('npx cowsay hi').decision).toBe('deny');
|
|
13
|
+
});
|
|
14
|
+
it('denies git push', () => {
|
|
15
|
+
const ev = evaluateTypedCommand('git push origin main');
|
|
16
|
+
expect(ev.decision).toBe('deny');
|
|
17
|
+
expect(ev.ruleId).toBe('git.destructive');
|
|
18
|
+
});
|
|
19
|
+
it('allows vitest', () => {
|
|
20
|
+
const ev = evaluateTypedCommand('vitest run packages/rigour-core');
|
|
21
|
+
expect(ev.decision).toBe('allow');
|
|
22
|
+
expect(parseCommandLine('vitest run x')?.bin).toBe('vitest');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('scope enforcement', () => {
|
|
26
|
+
it('denies unbound writer when scopes exist', () => {
|
|
27
|
+
const ev = evaluateWriteScope('/repo', 'packages/rigour-core/src/x.ts', [
|
|
28
|
+
{ agentId: 'a1', taskScope: ['packages/rigour-core/**'] },
|
|
29
|
+
]);
|
|
30
|
+
expect(ev.decision).toBe('scope-violation');
|
|
31
|
+
expect(ev.ruleId).toBe('scope.unbound');
|
|
32
|
+
});
|
|
33
|
+
it('flags out-of-scope writes for bound agent', () => {
|
|
34
|
+
const ev = evaluateWriteScope('/repo', '.github/workflows/x.yml', [
|
|
35
|
+
{ agentId: 'a1', taskScope: ['packages/rigour-core/**'] },
|
|
36
|
+
], 'a1');
|
|
37
|
+
expect(ev.decision).toBe('scope-violation');
|
|
38
|
+
});
|
|
39
|
+
it('allows in-scope writes for bound agent', () => {
|
|
40
|
+
const ev = evaluateWriteScope('/repo', 'packages/rigour-core/src/firewall/types.ts', [
|
|
41
|
+
{ agentId: 'a1', taskScope: ['packages/rigour-core/**'] },
|
|
42
|
+
], 'a1');
|
|
43
|
+
expect(ev.decision).toBe('allow');
|
|
44
|
+
});
|
|
45
|
+
it('rejects broad self-registration scopes', () => {
|
|
46
|
+
expect(validateAgentClaimedScope(['**/*']).ok).toBe(false);
|
|
47
|
+
expect(validateAgentClaimedScope(['.github/**']).ok).toBe(false);
|
|
48
|
+
expect(validateAgentClaimedScope(['packages/rigour-core/**']).ok).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('capability broker', () => {
|
|
52
|
+
it('denies undeclared MCP tools', () => {
|
|
53
|
+
const broker = new CapabilityBroker({
|
|
54
|
+
defaultTtlMs: 1000,
|
|
55
|
+
toolAllowlist: ['rigour_check'],
|
|
56
|
+
agentScopes: [],
|
|
57
|
+
});
|
|
58
|
+
const ev = broker.evaluateProposal({
|
|
59
|
+
action: 'mcp.call',
|
|
60
|
+
resource: 'slack.post_message',
|
|
61
|
+
});
|
|
62
|
+
expect(ev.decision).toBe('deny');
|
|
63
|
+
expect(ev.ruleId).toBe('mcp.undeclared');
|
|
64
|
+
});
|
|
65
|
+
it('issues one-use capabilities', () => {
|
|
66
|
+
const broker = new CapabilityBroker({
|
|
67
|
+
defaultTtlMs: 60_000,
|
|
68
|
+
toolAllowlist: ['*'],
|
|
69
|
+
agentScopes: [],
|
|
70
|
+
});
|
|
71
|
+
const grant = broker.issue('mcp.call', 'rigour_check');
|
|
72
|
+
const ok = broker.evaluateProposal({
|
|
73
|
+
action: 'mcp.call',
|
|
74
|
+
resource: 'rigour_check',
|
|
75
|
+
capabilityId: grant.id,
|
|
76
|
+
});
|
|
77
|
+
expect(ok.decision).toBe('allow');
|
|
78
|
+
const again = broker.evaluateProposal({
|
|
79
|
+
action: 'mcp.call',
|
|
80
|
+
resource: 'rigour_check',
|
|
81
|
+
capabilityId: grant.id,
|
|
82
|
+
});
|
|
83
|
+
expect(again.decision).toBe('deny');
|
|
84
|
+
expect(again.ruleId).toBe('capability.reuse');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('adversarial corpus', () => {
|
|
88
|
+
it('passes default regression corpus', () => {
|
|
89
|
+
const report = runAdversarialCorpus();
|
|
90
|
+
expect(report.failed).toBe(0);
|
|
91
|
+
expect(report.passed).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
it('evaluateActionDeterministic matches expected deny', () => {
|
|
94
|
+
const ev = evaluateActionDeterministic({
|
|
95
|
+
action: 'mcp.call',
|
|
96
|
+
resource: 'github.merge',
|
|
97
|
+
toolAllowlist: [],
|
|
98
|
+
agentScopes: [],
|
|
99
|
+
});
|
|
100
|
+
expect(ev.decision).toBe('deny');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './policy-hash.js';
|
|
3
|
+
export * from './typed-command.js';
|
|
4
|
+
export * from './scope-enforcement.js';
|
|
5
|
+
export * from './capability-broker.js';
|
|
6
|
+
export * from './transaction.js';
|
|
7
|
+
export * from './attestation.js';
|
|
8
|
+
export * from './adversarial.js';
|
|
9
|
+
export * from './mcp-gateway.js';
|
|
10
|
+
export * from './arbitration-token.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './policy-hash.js';
|
|
3
|
+
export * from './typed-command.js';
|
|
4
|
+
export * from './scope-enforcement.js';
|
|
5
|
+
export * from './capability-broker.js';
|
|
6
|
+
export * from './transaction.js';
|
|
7
|
+
export * from './attestation.js';
|
|
8
|
+
export * from './adversarial.js';
|
|
9
|
+
export * from './mcp-gateway.js';
|
|
10
|
+
export * from './arbitration-token.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Gateway interface sketch (ADR 001).
|
|
3
|
+
* Full proxy packaging may move to packages/rigour-gateway later.
|
|
4
|
+
*/
|
|
5
|
+
import type { PolicyEvaluation } from './types.js';
|
|
6
|
+
import { CapabilityBroker, type BrokerConfig } from './capability-broker.js';
|
|
7
|
+
export interface ToolDescriptor {
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface McpGatewayInvokeRequest {
|
|
12
|
+
agentId: string;
|
|
13
|
+
taskId: string;
|
|
14
|
+
tool: string;
|
|
15
|
+
args: Record<string, unknown>;
|
|
16
|
+
capabilityId?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface McpGatewayInvokeResult {
|
|
19
|
+
decision: PolicyEvaluation['decision'];
|
|
20
|
+
reason: string;
|
|
21
|
+
ruleId?: string;
|
|
22
|
+
capabilityId?: string;
|
|
23
|
+
/** Present only when decision is allow and a downstream handler is wired. */
|
|
24
|
+
result?: unknown;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* In-process gateway that filters discovery and authorizes calls via CapabilityBroker.
|
|
28
|
+
* Does not forward to external MCP servers yet — that is the next packaging step.
|
|
29
|
+
*/
|
|
30
|
+
export declare class McpGateway {
|
|
31
|
+
private readonly config;
|
|
32
|
+
private broker;
|
|
33
|
+
constructor(config: BrokerConfig & {
|
|
34
|
+
downstream?: (tool: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
35
|
+
});
|
|
36
|
+
listTools(): ToolDescriptor[];
|
|
37
|
+
invoke(req: McpGatewayInvokeRequest): Promise<McpGatewayInvokeResult>;
|
|
38
|
+
leaseSecret(name: string, purpose: string): McpGatewayInvokeResult;
|
|
39
|
+
getBroker(): CapabilityBroker;
|
|
40
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Gateway interface sketch (ADR 001).
|
|
3
|
+
* Full proxy packaging may move to packages/rigour-gateway later.
|
|
4
|
+
*/
|
|
5
|
+
import { CapabilityBroker } from './capability-broker.js';
|
|
6
|
+
/**
|
|
7
|
+
* In-process gateway that filters discovery and authorizes calls via CapabilityBroker.
|
|
8
|
+
* Does not forward to external MCP servers yet — that is the next packaging step.
|
|
9
|
+
*/
|
|
10
|
+
export class McpGateway {
|
|
11
|
+
config;
|
|
12
|
+
broker;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.broker = new CapabilityBroker(config);
|
|
16
|
+
}
|
|
17
|
+
listTools() {
|
|
18
|
+
return this.config.toolAllowlist
|
|
19
|
+
.filter(t => t !== '*')
|
|
20
|
+
.map(name => ({ name, description: 'Allowlisted tool' }));
|
|
21
|
+
}
|
|
22
|
+
async invoke(req) {
|
|
23
|
+
const evaluation = this.broker.evaluateProposal({
|
|
24
|
+
action: 'mcp.call',
|
|
25
|
+
resource: req.tool,
|
|
26
|
+
args: req.args,
|
|
27
|
+
capabilityId: req.capabilityId,
|
|
28
|
+
});
|
|
29
|
+
if (evaluation.decision !== 'allow') {
|
|
30
|
+
return {
|
|
31
|
+
decision: evaluation.decision,
|
|
32
|
+
reason: evaluation.reason,
|
|
33
|
+
ruleId: evaluation.ruleId,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
let result;
|
|
37
|
+
if (this.config.downstream) {
|
|
38
|
+
result = await this.config.downstream(req.tool, req.args);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
decision: 'allow',
|
|
42
|
+
reason: evaluation.reason,
|
|
43
|
+
ruleId: evaluation.ruleId,
|
|
44
|
+
capabilityId: evaluation.capabilityId,
|
|
45
|
+
result,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
leaseSecret(name, purpose) {
|
|
49
|
+
const evaluation = this.broker.evaluateProposal({
|
|
50
|
+
action: 'secret.lease',
|
|
51
|
+
resource: name,
|
|
52
|
+
args: { purpose },
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
decision: evaluation.decision,
|
|
56
|
+
reason: evaluation.reason,
|
|
57
|
+
ruleId: evaluation.ruleId,
|
|
58
|
+
capabilityId: evaluation.capabilityId,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
getBroker() {
|
|
62
|
+
return this.broker;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function hashPolicy(input: unknown): string;
|