@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,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[]>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional execution — worktree START → verify → COMMIT | DISCARD.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { execa } from 'execa';
|
|
8
|
+
import { hashPolicy } from './policy-hash.js';
|
|
9
|
+
import { assertWritesInScope } from './scope-enforcement.js';
|
|
10
|
+
const DEFAULT_BUDGETS = {
|
|
11
|
+
maxFiles: 20,
|
|
12
|
+
maxRetries: 3,
|
|
13
|
+
maxDurationMs: 30 * 60 * 1000,
|
|
14
|
+
};
|
|
15
|
+
export class TransactionRunner {
|
|
16
|
+
cwd;
|
|
17
|
+
record;
|
|
18
|
+
constructor(cwd, opts) {
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
const budgets = { ...DEFAULT_BUDGETS, ...opts.budgets };
|
|
21
|
+
this.record = {
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
agentId: opts.agentId,
|
|
24
|
+
scope: opts.scope,
|
|
25
|
+
status: 'START',
|
|
26
|
+
budgets,
|
|
27
|
+
filesChanged: [],
|
|
28
|
+
capabilitiesIssued: [],
|
|
29
|
+
decisions: [],
|
|
30
|
+
startedAt: new Date().toISOString(),
|
|
31
|
+
policyHash: opts.policyHash ?? hashPolicy({ scope: opts.scope, budgets }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
static fromRecord(cwd, record) {
|
|
35
|
+
const runner = new TransactionRunner(cwd, {
|
|
36
|
+
agentId: record.agentId,
|
|
37
|
+
scope: record.scope,
|
|
38
|
+
budgets: record.budgets,
|
|
39
|
+
policyHash: record.policyHash,
|
|
40
|
+
});
|
|
41
|
+
runner.record = { ...record, decisions: [...record.decisions], filesChanged: [...record.filesChanged] };
|
|
42
|
+
return runner;
|
|
43
|
+
}
|
|
44
|
+
getRecord() {
|
|
45
|
+
return { ...this.record, decisions: [...this.record.decisions], filesChanged: [...this.record.filesChanged] };
|
|
46
|
+
}
|
|
47
|
+
setStatus(status) {
|
|
48
|
+
this.record.status = status;
|
|
49
|
+
}
|
|
50
|
+
addDecision(ev) {
|
|
51
|
+
this.record.decisions.push(ev);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create an ephemeral git worktree for isolated writes.
|
|
55
|
+
*/
|
|
56
|
+
async start() {
|
|
57
|
+
const worktreesRoot = path.join(this.cwd, '.rigour', 'worktrees');
|
|
58
|
+
await fs.ensureDir(worktreesRoot);
|
|
59
|
+
const worktreePath = path.join(worktreesRoot, this.record.id);
|
|
60
|
+
const branch = `rigour-tx-${this.record.id.slice(0, 8)}`;
|
|
61
|
+
try {
|
|
62
|
+
await execa('git', ['rev-parse', '--is-inside-work-tree'], { cwd: this.cwd });
|
|
63
|
+
await execa('git', ['worktree', 'add', '-b', branch, worktreePath, 'HEAD'], {
|
|
64
|
+
cwd: this.cwd,
|
|
65
|
+
shell: false,
|
|
66
|
+
});
|
|
67
|
+
this.record.worktreePath = worktreePath;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Fallback: copy-less sandbox dir (no git) — still tracks commit/discard via marker
|
|
71
|
+
await fs.ensureDir(worktreePath);
|
|
72
|
+
this.record.worktreePath = worktreePath;
|
|
73
|
+
await fs.writeJson(path.join(worktreePath, '.rigour-tx.json'), {
|
|
74
|
+
id: this.record.id,
|
|
75
|
+
base: this.cwd,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
this.setStatus('RUNNING');
|
|
79
|
+
await this.persist();
|
|
80
|
+
return this.getRecord();
|
|
81
|
+
}
|
|
82
|
+
async noteFileChange(relPath) {
|
|
83
|
+
const scopeEv = await assertWritesInScope(this.cwd, [relPath], this.record.agentId);
|
|
84
|
+
this.addDecision(scopeEv);
|
|
85
|
+
if (scopeEv.decision !== 'allow') {
|
|
86
|
+
return scopeEv;
|
|
87
|
+
}
|
|
88
|
+
if (!this.record.filesChanged.includes(relPath)) {
|
|
89
|
+
this.record.filesChanged.push(relPath);
|
|
90
|
+
}
|
|
91
|
+
if (this.record.filesChanged.length > this.record.budgets.maxFiles) {
|
|
92
|
+
const deny = {
|
|
93
|
+
decision: 'deny',
|
|
94
|
+
reason: `File budget exceeded (${this.record.budgets.maxFiles})`,
|
|
95
|
+
ruleId: 'budget.files',
|
|
96
|
+
policyHash: this.record.policyHash,
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
this.addDecision(deny);
|
|
100
|
+
return deny;
|
|
101
|
+
}
|
|
102
|
+
const elapsed = Date.now() - Date.parse(this.record.startedAt);
|
|
103
|
+
if (elapsed > this.record.budgets.maxDurationMs) {
|
|
104
|
+
const deny = {
|
|
105
|
+
decision: 'deny',
|
|
106
|
+
reason: 'Time budget exceeded',
|
|
107
|
+
ruleId: 'budget.time',
|
|
108
|
+
policyHash: this.record.policyHash,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
this.addDecision(deny);
|
|
112
|
+
return deny;
|
|
113
|
+
}
|
|
114
|
+
await this.persist();
|
|
115
|
+
return scopeEv;
|
|
116
|
+
}
|
|
117
|
+
async syncWorktreeChanges() {
|
|
118
|
+
const root = this.record.worktreePath || this.cwd;
|
|
119
|
+
const files = await listChangedFiles(root);
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
const ev = await this.noteFileChange(f);
|
|
122
|
+
if (ev.decision !== 'allow') {
|
|
123
|
+
throw Object.assign(new Error(ev.reason), { evaluation: ev, code: 'FIREWALL_DENY' });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...this.record.filesChanged];
|
|
127
|
+
}
|
|
128
|
+
getWorktreePath() {
|
|
129
|
+
return this.record.worktreePath;
|
|
130
|
+
}
|
|
131
|
+
async verify(runGates) {
|
|
132
|
+
this.setStatus('VERIFYING');
|
|
133
|
+
const gateResults = await runGates();
|
|
134
|
+
await this.persist();
|
|
135
|
+
return { ok: gateResults.status === 'PASS', gateResults };
|
|
136
|
+
}
|
|
137
|
+
async commit() {
|
|
138
|
+
this.setStatus('COMMIT');
|
|
139
|
+
this.record.finishedAt = new Date().toISOString();
|
|
140
|
+
// Worktree branch is left for operator merge; attestation binds treeDigest/commitSha
|
|
141
|
+
await this.persist();
|
|
142
|
+
return this.getRecord();
|
|
143
|
+
}
|
|
144
|
+
async discard() {
|
|
145
|
+
this.setStatus('DISCARD');
|
|
146
|
+
this.record.finishedAt = new Date().toISOString();
|
|
147
|
+
const wt = this.record.worktreePath;
|
|
148
|
+
if (wt && await fs.pathExists(wt)) {
|
|
149
|
+
try {
|
|
150
|
+
await execa('git', ['worktree', 'remove', '--force', wt], { cwd: this.cwd, shell: false });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
await fs.remove(wt);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const branch = `rigour-tx-${this.record.id.slice(0, 8)}`;
|
|
157
|
+
await execa('git', ['branch', '-D', branch], { cwd: this.cwd, shell: false, reject: false });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await this.persist();
|
|
164
|
+
return this.getRecord();
|
|
165
|
+
}
|
|
166
|
+
async persist() {
|
|
167
|
+
const dir = path.join(this.cwd, '.rigour', 'transactions');
|
|
168
|
+
await fs.ensureDir(dir);
|
|
169
|
+
await fs.writeJson(path.join(dir, `${this.record.id}.json`), this.record, { spaces: 2 });
|
|
170
|
+
await fs.writeJson(path.join(this.cwd, '.rigour', 'transaction-current.json'), this.record, { spaces: 2 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export async function listChangedFiles(dir) {
|
|
174
|
+
try {
|
|
175
|
+
const { stdout: diffOut } = await execa('git', ['diff', '--name-only', 'HEAD'], {
|
|
176
|
+
cwd: dir,
|
|
177
|
+
shell: false,
|
|
178
|
+
});
|
|
179
|
+
const { stdout: untrackedOut } = await execa('git', ['ls-files', '--others', '--exclude-standard'], { cwd: dir, shell: false });
|
|
180
|
+
const files = [
|
|
181
|
+
...diffOut.split('\n'),
|
|
182
|
+
...untrackedOut.split('\n'),
|
|
183
|
+
].map(s => s.trim()).filter(Boolean);
|
|
184
|
+
return [...new Set(files)];
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function loadCurrentTransaction(cwd) {
|
|
191
|
+
const p = path.join(cwd, '.rigour', 'transaction-current.json');
|
|
192
|
+
if (!(await fs.pathExists(p)))
|
|
193
|
+
return null;
|
|
194
|
+
try {
|
|
195
|
+
return await fs.readJson(p);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function listTransactions(cwd) {
|
|
202
|
+
const dir = path.join(cwd, '.rigour', 'transactions');
|
|
203
|
+
if (!(await fs.pathExists(dir)))
|
|
204
|
+
return [];
|
|
205
|
+
const files = (await fs.readdir(dir)).filter(f => f.endsWith('.json'));
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
try {
|
|
209
|
+
out.push(await fs.readJson(path.join(dir, f)));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// skip
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return out.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
216
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed / argv-allowlisted command runner.
|
|
3
|
+
* Replaces unrestricted shell:true for agent-facing execution.
|
|
4
|
+
*/
|
|
5
|
+
import type { PolicyEvaluation, TypedCommand } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Conservative allowlist — no general interpreters (node/python/npx) that accept -e/-c.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_ALLOWED_BINS: Set<string>;
|
|
10
|
+
export declare function parseCommandLine(command: string): TypedCommand | null;
|
|
11
|
+
export declare function evaluateTypedCommand(command: string, allowedBins?: Set<string>): PolicyEvaluation;
|
|
12
|
+
export declare function runTypedCommand(command: string, cwd: string, allowedBins?: Set<string>): Promise<{
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
evaluation: PolicyEvaluation;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed / argv-allowlisted command runner.
|
|
3
|
+
* Replaces unrestricted shell:true for agent-facing execution.
|
|
4
|
+
*/
|
|
5
|
+
import { execa } from 'execa';
|
|
6
|
+
import { hashPolicy } from './policy-hash.js';
|
|
7
|
+
/**
|
|
8
|
+
* Conservative allowlist — no general interpreters (node/python/npx) that accept -e/-c.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_ALLOWED_BINS = new Set([
|
|
11
|
+
'npm',
|
|
12
|
+
'pnpm',
|
|
13
|
+
'yarn',
|
|
14
|
+
'tsc',
|
|
15
|
+
'vitest',
|
|
16
|
+
'jest',
|
|
17
|
+
'pytest',
|
|
18
|
+
'go',
|
|
19
|
+
'cargo',
|
|
20
|
+
'git',
|
|
21
|
+
'rigour',
|
|
22
|
+
'echo',
|
|
23
|
+
'cat',
|
|
24
|
+
'ls',
|
|
25
|
+
'pwd',
|
|
26
|
+
'true',
|
|
27
|
+
'false',
|
|
28
|
+
]);
|
|
29
|
+
const DENY_ARG_PATTERNS = [
|
|
30
|
+
/rm\s+-rf/i,
|
|
31
|
+
/--force/i,
|
|
32
|
+
/push\s+--force/i,
|
|
33
|
+
/force-with-lease/i,
|
|
34
|
+
/curl.*(-d|--data)/i,
|
|
35
|
+
/wget\s/i,
|
|
36
|
+
/(^|\s)-e(\s|=|$)/,
|
|
37
|
+
/(^|\s)--eval(\s|=|$)/,
|
|
38
|
+
/\bnpm\s+exec\b/i,
|
|
39
|
+
/\bnpx\b/i,
|
|
40
|
+
/\byarn\s+dlx\b/i,
|
|
41
|
+
/\bpnpm\s+dlx\b/i,
|
|
42
|
+
];
|
|
43
|
+
const GIT_DENY_SUBCOMMANDS = new Set(['push', 'reset', 'clean', 'rebase', 'filter-branch']);
|
|
44
|
+
export function parseCommandLine(command) {
|
|
45
|
+
const trimmed = command.trim();
|
|
46
|
+
if (!trimmed)
|
|
47
|
+
return null;
|
|
48
|
+
if (/[;|&`$<>]/.test(trimmed) || trimmed.includes('\n')) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const parts = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
|
|
52
|
+
if (!parts || parts.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const unquote = (s) => {
|
|
55
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
56
|
+
return s.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
return s;
|
|
59
|
+
};
|
|
60
|
+
const [bin, ...args] = parts.map(unquote);
|
|
61
|
+
return { bin, args };
|
|
62
|
+
}
|
|
63
|
+
export function evaluateTypedCommand(command, allowedBins = DEFAULT_ALLOWED_BINS) {
|
|
64
|
+
const policyHash = hashPolicy({ allowedBins: [...allowedBins].sort() });
|
|
65
|
+
const parsed = parseCommandLine(command);
|
|
66
|
+
if (!parsed) {
|
|
67
|
+
return {
|
|
68
|
+
decision: 'deny',
|
|
69
|
+
reason: 'Command contains shell metacharacters or could not be parsed as argv',
|
|
70
|
+
ruleId: 'shell.no-meta',
|
|
71
|
+
policyHash,
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const binBase = parsed.bin.split(/[/\\]/).pop() || parsed.bin;
|
|
76
|
+
if (!allowedBins.has(binBase) && !allowedBins.has(parsed.bin)) {
|
|
77
|
+
return {
|
|
78
|
+
decision: 'deny',
|
|
79
|
+
reason: `Binary "${binBase}" is not on the allowlist`,
|
|
80
|
+
ruleId: 'shell.allowlist',
|
|
81
|
+
policyHash,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const full = `${parsed.bin} ${parsed.args.join(' ')}`;
|
|
86
|
+
for (const re of DENY_ARG_PATTERNS) {
|
|
87
|
+
if (re.test(full)) {
|
|
88
|
+
return {
|
|
89
|
+
decision: 'deny',
|
|
90
|
+
reason: `Command matched deny pattern: ${re}`,
|
|
91
|
+
ruleId: 'shell.deny-pattern',
|
|
92
|
+
policyHash,
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (binBase === 'git') {
|
|
98
|
+
const sub = parsed.args.find(a => !a.startsWith('-'));
|
|
99
|
+
if (sub && GIT_DENY_SUBCOMMANDS.has(sub)) {
|
|
100
|
+
return {
|
|
101
|
+
decision: 'deny',
|
|
102
|
+
reason: `git ${sub} requires explicit capability (destructive git mutation)`,
|
|
103
|
+
ruleId: 'git.destructive',
|
|
104
|
+
policyHash,
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
decision: 'allow',
|
|
111
|
+
reason: `Allowlisted binary ${binBase}`,
|
|
112
|
+
ruleId: 'shell.allow',
|
|
113
|
+
policyHash,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function runTypedCommand(command, cwd, allowedBins) {
|
|
118
|
+
const evaluation = evaluateTypedCommand(command, allowedBins);
|
|
119
|
+
if (evaluation.decision !== 'allow') {
|
|
120
|
+
throw Object.assign(new Error(evaluation.reason), { evaluation, code: 'FIREWALL_DENY' });
|
|
121
|
+
}
|
|
122
|
+
const parsed = parseCommandLine(command);
|
|
123
|
+
const { stdout, stderr } = await execa(parsed.bin, parsed.args, {
|
|
124
|
+
cwd,
|
|
125
|
+
shell: false,
|
|
126
|
+
reject: false,
|
|
127
|
+
});
|
|
128
|
+
return { stdout: stdout ?? '', stderr: stderr ?? '', evaluation };
|
|
129
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Transaction Firewall — shared types.
|
|
3
|
+
* Deterministic allow/deny decisions; no LLM on the hot path.
|
|
4
|
+
*/
|
|
5
|
+
export type FirewallDecision = 'allow' | 'deny' | 'timeout-deny' | 'scope-violation';
|
|
6
|
+
export type CapabilityAction = 'filesystem.write' | 'filesystem.read' | 'shell.exec' | 'mcp.call' | 'git.mutate' | 'secret.lease' | 'network.egress' | 'deploy';
|
|
7
|
+
export interface CapabilityGrant {
|
|
8
|
+
id: string;
|
|
9
|
+
action: CapabilityAction;
|
|
10
|
+
resource: string;
|
|
11
|
+
constraints?: Record<string, unknown>;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
agentId?: string;
|
|
14
|
+
taskId?: string;
|
|
15
|
+
policyHash: string;
|
|
16
|
+
used: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface PolicyEvaluation {
|
|
19
|
+
decision: FirewallDecision;
|
|
20
|
+
reason: string;
|
|
21
|
+
ruleId?: string;
|
|
22
|
+
capabilityId?: string;
|
|
23
|
+
policyHash: string;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
}
|
|
26
|
+
export interface TypedCommand {
|
|
27
|
+
bin: string;
|
|
28
|
+
args: string[];
|
|
29
|
+
cwd?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface TransactionBudgets {
|
|
32
|
+
maxFiles: number;
|
|
33
|
+
maxRetries: number;
|
|
34
|
+
maxDurationMs: number;
|
|
35
|
+
maxCostUsd?: number;
|
|
36
|
+
}
|
|
37
|
+
export type TransactionStatus = 'START' | 'RUNNING' | 'VERIFYING' | 'COMMIT' | 'DISCARD';
|
|
38
|
+
export interface TransactionRecord {
|
|
39
|
+
id: string;
|
|
40
|
+
agentId?: string;
|
|
41
|
+
scope: string[];
|
|
42
|
+
status: TransactionStatus;
|
|
43
|
+
worktreePath?: string;
|
|
44
|
+
budgets: TransactionBudgets;
|
|
45
|
+
filesChanged: string[];
|
|
46
|
+
capabilitiesIssued: string[];
|
|
47
|
+
decisions: PolicyEvaluation[];
|
|
48
|
+
startedAt: string;
|
|
49
|
+
finishedAt?: string;
|
|
50
|
+
policyHash: string;
|
|
51
|
+
}
|
|
52
|
+
export interface AttestationBundle {
|
|
53
|
+
version: 1;
|
|
54
|
+
transactionId: string;
|
|
55
|
+
agentId?: string;
|
|
56
|
+
userId?: string;
|
|
57
|
+
scope: string[];
|
|
58
|
+
policyHash: string;
|
|
59
|
+
capabilities: string[];
|
|
60
|
+
filesUsed: string[];
|
|
61
|
+
toolsUsed: string[];
|
|
62
|
+
gateResults: {
|
|
63
|
+
status: string;
|
|
64
|
+
score?: number;
|
|
65
|
+
failedGates: string[];
|
|
66
|
+
};
|
|
67
|
+
overrides: string[];
|
|
68
|
+
artifactDigest: string;
|
|
69
|
+
/** git commit SHA at attestation time */
|
|
70
|
+
commitSha?: string;
|
|
71
|
+
/** git tree object id (HEAD^{tree}) */
|
|
72
|
+
treeDigest?: string;
|
|
73
|
+
signedAt: string;
|
|
74
|
+
signature: string;
|
|
75
|
+
}
|
|
76
|
+
export interface AdversarialCase {
|
|
77
|
+
id: string;
|
|
78
|
+
description: string;
|
|
79
|
+
proposedAction: {
|
|
80
|
+
action: CapabilityAction;
|
|
81
|
+
resource: string;
|
|
82
|
+
args?: Record<string, unknown>;
|
|
83
|
+
command?: string;
|
|
84
|
+
};
|
|
85
|
+
expectedDecision: FirewallDecision;
|
|
86
|
+
agentScope?: string[];
|
|
87
|
+
allowlist?: string[];
|
|
88
|
+
}
|
|
89
|
+
export interface AdversarialCaseResult {
|
|
90
|
+
caseId: string;
|
|
91
|
+
expected: FirewallDecision;
|
|
92
|
+
actual: FirewallDecision;
|
|
93
|
+
passed: boolean;
|
|
94
|
+
reason: string;
|
|
95
|
+
suggestedRule?: string;
|
|
96
|
+
}
|