@rigour-labs/core 5.5.3 → 6.0.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.
- 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 +1 -0
- package/dist/index.js +1 -0
- package/package.json +6 -6
|
@@ -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;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforce agent file ownership against claimed globs.
|
|
3
|
+
* Fail-closed: when scopes exist, writer agentId is required (no union-allow).
|
|
4
|
+
*/
|
|
5
|
+
import type { PolicyEvaluation } from './types.js';
|
|
6
|
+
export interface AgentScopeRecord {
|
|
7
|
+
agentId: string;
|
|
8
|
+
taskScope: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function normalizeRelPath(cwd: string, filePath: string): string;
|
|
11
|
+
export declare function isPathInScope(relPath: string, globs: string[]): boolean;
|
|
12
|
+
export declare function validateAgentClaimedScope(taskScope: string[]): {
|
|
13
|
+
ok: true;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Operator-controlled allowlist: `.rigour/operator-scopes.json`
|
|
20
|
+
* `{ "agents": { "agent-id": ["packages/foo/**"] } }`
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadOperatorScopes(cwd: string): Promise<Record<string, string[]> | null>;
|
|
23
|
+
export declare function isScopeSubset(claimed: string[], allowed: string[]): boolean;
|
|
24
|
+
export declare function evaluateWriteScope(cwd: string, filePath: string, agentScopes: AgentScopeRecord[], agentId?: string): PolicyEvaluation;
|
|
25
|
+
export declare function loadAgentScopesFromDisk(cwd: string): Promise<AgentScopeRecord[]>;
|
|
26
|
+
export declare function assertWritesInScope(cwd: string, files: string[], agentId?: string): Promise<PolicyEvaluation>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforce agent file ownership against claimed globs.
|
|
3
|
+
* Fail-closed: when scopes exist, writer agentId is required (no union-allow).
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import micromatch from 'micromatch';
|
|
7
|
+
import fs from 'fs-extra';
|
|
8
|
+
import { hashPolicy } from './policy-hash.js';
|
|
9
|
+
/** Globs agents may not self-claim without operator authority. */
|
|
10
|
+
const FORBIDDEN_AGENT_SCOPES = new Set(['*', '**', '**/*', '**/**', '/*', '/']);
|
|
11
|
+
const SENSITIVE_SCOPE_PATTERNS = [
|
|
12
|
+
/^\.github(\/|$|\*\*)/i,
|
|
13
|
+
/(^|\/)\.env/i,
|
|
14
|
+
/(^|\/)secrets?(\/|$|\*\*)/i,
|
|
15
|
+
/^\*\*\/\.github/,
|
|
16
|
+
];
|
|
17
|
+
export function normalizeRelPath(cwd, filePath) {
|
|
18
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
19
|
+
return path.relative(cwd, abs).replace(/\\/g, '/');
|
|
20
|
+
}
|
|
21
|
+
export function isPathInScope(relPath, globs) {
|
|
22
|
+
if (!globs || globs.length === 0)
|
|
23
|
+
return false;
|
|
24
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
25
|
+
return micromatch.isMatch(normalized, globs, { dot: true });
|
|
26
|
+
}
|
|
27
|
+
export function validateAgentClaimedScope(taskScope) {
|
|
28
|
+
if (!taskScope.length) {
|
|
29
|
+
return { ok: false, reason: 'taskScope must be a non-empty list of path globs' };
|
|
30
|
+
}
|
|
31
|
+
for (const raw of taskScope) {
|
|
32
|
+
const s = raw.replace(/\\/g, '/').trim();
|
|
33
|
+
if (!s) {
|
|
34
|
+
return { ok: false, reason: 'Empty scope glob is not allowed' };
|
|
35
|
+
}
|
|
36
|
+
if (FORBIDDEN_AGENT_SCOPES.has(s)) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `Scope "${s}" is too broad for agent self-registration (operator authority required)`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (SENSITIVE_SCOPE_PATTERNS.some(re => re.test(s))) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
reason: `Scope "${s}" covers sensitive paths; set operator scopes or RIGOUR_ALLOW_AGENT_SCOPE_AUTHORITY=1`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { ok: true };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Operator-controlled allowlist: `.rigour/operator-scopes.json`
|
|
53
|
+
* `{ "agents": { "agent-id": ["packages/foo/**"] } }`
|
|
54
|
+
*/
|
|
55
|
+
export async function loadOperatorScopes(cwd) {
|
|
56
|
+
const p = path.join(cwd, '.rigour', 'operator-scopes.json');
|
|
57
|
+
if (!(await fs.pathExists(p)))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readJson(p);
|
|
61
|
+
if (raw?.agents && typeof raw.agents === 'object') {
|
|
62
|
+
return raw.agents;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
export function isScopeSubset(claimed, allowed) {
|
|
71
|
+
// Each claimed glob must be matched/covered by an allowed glob (exact or nested under allowed prefix)
|
|
72
|
+
return claimed.every(c => allowed.some(a => c === a || c.startsWith(a.replace(/\*\*$/, '').replace(/\*$/, '')) || micromatch.isMatch(c, a, { dot: true })));
|
|
73
|
+
}
|
|
74
|
+
export function evaluateWriteScope(cwd, filePath, agentScopes, agentId) {
|
|
75
|
+
const policyHash = hashPolicy({ agentScopes, agentId });
|
|
76
|
+
const rel = normalizeRelPath(cwd, filePath);
|
|
77
|
+
const timestamp = new Date().toISOString();
|
|
78
|
+
if (!agentScopes.length) {
|
|
79
|
+
return {
|
|
80
|
+
decision: 'allow',
|
|
81
|
+
reason: 'No agent scopes registered; scope enforcement inactive',
|
|
82
|
+
ruleId: 'scope.inactive',
|
|
83
|
+
policyHash,
|
|
84
|
+
timestamp,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!agentId) {
|
|
88
|
+
return {
|
|
89
|
+
decision: 'scope-violation',
|
|
90
|
+
reason: 'Writer agentId is required when agent scopes are registered (fail-closed; no union-allow)',
|
|
91
|
+
ruleId: 'scope.unbound',
|
|
92
|
+
policyHash,
|
|
93
|
+
timestamp,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const agents = agentScopes.filter(a => a.agentId === agentId);
|
|
97
|
+
if (agents.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
decision: 'scope-violation',
|
|
100
|
+
reason: `Agent "${agentId}" is not registered`,
|
|
101
|
+
ruleId: 'scope.unregistered',
|
|
102
|
+
policyHash,
|
|
103
|
+
timestamp,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const allowed = agents.some(a => isPathInScope(rel, a.taskScope));
|
|
107
|
+
if (!allowed) {
|
|
108
|
+
const scopes = agents.map(a => `${a.agentId}:[${a.taskScope.join(', ')}]`).join('; ');
|
|
109
|
+
return {
|
|
110
|
+
decision: 'scope-violation',
|
|
111
|
+
reason: `Write to "${rel}" is outside assigned scope (${scopes})`,
|
|
112
|
+
ruleId: 'scope.out-of-bounds',
|
|
113
|
+
policyHash,
|
|
114
|
+
timestamp,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
decision: 'allow',
|
|
119
|
+
reason: `Path "${rel}" is within assigned agent scope`,
|
|
120
|
+
ruleId: 'scope.allow',
|
|
121
|
+
policyHash,
|
|
122
|
+
timestamp,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export async function loadAgentScopesFromDisk(cwd) {
|
|
126
|
+
const sessionPath = path.join(cwd, '.rigour', 'agent-session.json');
|
|
127
|
+
if (!(await fs.pathExists(sessionPath)))
|
|
128
|
+
return [];
|
|
129
|
+
try {
|
|
130
|
+
const raw = await fs.readJson(sessionPath);
|
|
131
|
+
const agents = Array.isArray(raw?.agents) ? raw.agents : [];
|
|
132
|
+
return agents.map((a) => ({
|
|
133
|
+
agentId: String(a.agentId),
|
|
134
|
+
taskScope: Array.isArray(a.taskScope) ? a.taskScope.map(String) : [],
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function assertWritesInScope(cwd, files, agentId) {
|
|
142
|
+
const scopes = await loadAgentScopesFromDisk(cwd);
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
const ev = evaluateWriteScope(cwd, file, scopes, agentId);
|
|
145
|
+
if (ev.decision !== 'allow')
|
|
146
|
+
return ev;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
decision: 'allow',
|
|
150
|
+
reason: 'All files within scope',
|
|
151
|
+
ruleId: 'scope.batch-allow',
|
|
152
|
+
policyHash: hashPolicy({ files, agentId }),
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional execution — worktree START → verify → COMMIT | DISCARD.
|
|
3
|
+
*/
|
|
4
|
+
import type { PolicyEvaluation, TransactionBudgets, TransactionRecord } from './types.js';
|
|
5
|
+
export declare class TransactionRunner {
|
|
6
|
+
private readonly cwd;
|
|
7
|
+
private record;
|
|
8
|
+
constructor(cwd: string, opts: {
|
|
9
|
+
agentId?: string;
|
|
10
|
+
scope: string[];
|
|
11
|
+
budgets?: Partial<TransactionBudgets>;
|
|
12
|
+
policyHash?: string;
|
|
13
|
+
});
|
|
14
|
+
static fromRecord(cwd: string, record: TransactionRecord): TransactionRunner;
|
|
15
|
+
getRecord(): TransactionRecord;
|
|
16
|
+
private setStatus;
|
|
17
|
+
addDecision(ev: PolicyEvaluation): void;
|
|
18
|
+
/**
|
|
19
|
+
* Create an ephemeral git worktree for isolated writes.
|
|
20
|
+
*/
|
|
21
|
+
start(): Promise<TransactionRecord>;
|
|
22
|
+
noteFileChange(relPath: string): Promise<PolicyEvaluation>;
|
|
23
|
+
syncWorktreeChanges(): Promise<string[]>;
|
|
24
|
+
getWorktreePath(): string | undefined;
|
|
25
|
+
verify(runGates: () => Promise<{
|
|
26
|
+
status: string;
|
|
27
|
+
failedGates: string[];
|
|
28
|
+
score?: number;
|
|
29
|
+
}>): Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
gateResults: {
|
|
32
|
+
status: string;
|
|
33
|
+
failedGates: string[];
|
|
34
|
+
score?: number;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
commit(): Promise<TransactionRecord>;
|
|
38
|
+
discard(): Promise<TransactionRecord>;
|
|
39
|
+
private persist;
|
|
40
|
+
}
|
|
41
|
+
export declare function listChangedFiles(dir: string): Promise<string[]>;
|
|
42
|
+
export declare function loadCurrentTransaction(cwd: string): Promise<TransactionRecord | null>;
|
|
43
|
+
export declare function listTransactions(cwd: string): Promise<TransactionRecord[]>;
|