@bookedsolid/rea 0.1.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/LICENSE +21 -0
- package/README.md +339 -0
- package/SECURITY.md +104 -0
- package/THREAT_MODEL.md +245 -0
- package/agents/accessibility-engineer.md +101 -0
- package/agents/backend-engineer.md +126 -0
- package/agents/code-reviewer.md +144 -0
- package/agents/codex-adversarial.md +107 -0
- package/agents/frontend-specialist.md +84 -0
- package/agents/qa-engineer.md +138 -0
- package/agents/rea-orchestrator.md +101 -0
- package/agents/security-engineer.md +108 -0
- package/agents/technical-writer.md +140 -0
- package/agents/typescript-specialist.md +111 -0
- package/commands/codex-review.md +104 -0
- package/commands/freeze.md +81 -0
- package/commands/halt-check.md +120 -0
- package/commands/rea.md +52 -0
- package/commands/review.md +79 -0
- package/dist/cli/check.d.ts +1 -0
- package/dist/cli/check.js +66 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +93 -0
- package/dist/cli/freeze.d.ts +8 -0
- package/dist/cli/freeze.js +61 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +65 -0
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +237 -0
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.js +19 -0
- package/dist/cli/utils.d.ts +23 -0
- package/dist/cli/utils.js +51 -0
- package/dist/config/tier-map.d.ts +11 -0
- package/dist/config/tier-map.js +108 -0
- package/dist/config/types.d.ts +24 -0
- package/dist/config/types.js +1 -0
- package/dist/gateway/circuit-breaker.d.ts +43 -0
- package/dist/gateway/circuit-breaker.js +86 -0
- package/dist/gateway/middleware/audit-types.d.ts +16 -0
- package/dist/gateway/middleware/audit-types.js +1 -0
- package/dist/gateway/middleware/audit.d.ts +12 -0
- package/dist/gateway/middleware/audit.js +98 -0
- package/dist/gateway/middleware/blocked-paths.d.ts +12 -0
- package/dist/gateway/middleware/blocked-paths.js +117 -0
- package/dist/gateway/middleware/chain.d.ts +28 -0
- package/dist/gateway/middleware/chain.js +40 -0
- package/dist/gateway/middleware/circuit-breaker.d.ts +11 -0
- package/dist/gateway/middleware/circuit-breaker.js +43 -0
- package/dist/gateway/middleware/injection.d.ts +22 -0
- package/dist/gateway/middleware/injection.js +128 -0
- package/dist/gateway/middleware/kill-switch.d.ts +10 -0
- package/dist/gateway/middleware/kill-switch.js +58 -0
- package/dist/gateway/middleware/policy.d.ts +12 -0
- package/dist/gateway/middleware/policy.js +70 -0
- package/dist/gateway/middleware/rate-limit.d.ts +12 -0
- package/dist/gateway/middleware/rate-limit.js +31 -0
- package/dist/gateway/middleware/redact.d.ts +16 -0
- package/dist/gateway/middleware/redact.js +128 -0
- package/dist/gateway/middleware/result-size-cap.d.ts +13 -0
- package/dist/gateway/middleware/result-size-cap.js +48 -0
- package/dist/gateway/middleware/session.d.ts +10 -0
- package/dist/gateway/middleware/session.js +18 -0
- package/dist/gateway/middleware/tier.d.ts +6 -0
- package/dist/gateway/middleware/tier.js +10 -0
- package/dist/gateway/rate-limiter.d.ts +36 -0
- package/dist/gateway/rate-limiter.js +75 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/policy/loader.d.ts +80 -0
- package/dist/policy/loader.js +146 -0
- package/dist/policy/types.d.ts +34 -0
- package/dist/policy/types.js +19 -0
- package/hooks/_lib/common.sh +105 -0
- package/hooks/_lib/halt-check.sh +39 -0
- package/hooks/_lib/policy-read.sh +79 -0
- package/hooks/architecture-review-gate.sh +84 -0
- package/hooks/attribution-advisory.sh +126 -0
- package/hooks/blocked-paths-enforcer.sh +176 -0
- package/hooks/changeset-security-gate.sh +143 -0
- package/hooks/commit-review-gate.sh +166 -0
- package/hooks/dangerous-bash-interceptor.sh +362 -0
- package/hooks/dependency-audit-gate.sh +118 -0
- package/hooks/env-file-protection.sh +110 -0
- package/hooks/pr-issue-link-gate.sh +65 -0
- package/hooks/push-review-gate.sh +120 -0
- package/hooks/secret-scanner.sh +229 -0
- package/hooks/security-disclosure-gate.sh +146 -0
- package/hooks/settings-protection.sh +147 -0
- package/package.json +93 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Tier } from '../policy/types.js';
|
|
2
|
+
/** Numeric severity for tier comparison — higher = more dangerous. */
|
|
3
|
+
const TIER_SEVERITY = {
|
|
4
|
+
[Tier.Read]: 0,
|
|
5
|
+
[Tier.Write]: 1,
|
|
6
|
+
[Tier.Destructive]: 2,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Static tier classifications for known tool patterns.
|
|
10
|
+
* Tools not in this map default to Tier.Write (safe default).
|
|
11
|
+
*/
|
|
12
|
+
const STATIC_TIER_MAP = {
|
|
13
|
+
// Read-tier tools (safe, no side effects)
|
|
14
|
+
get_messages: Tier.Read,
|
|
15
|
+
get_channel: Tier.Read,
|
|
16
|
+
get_guild: Tier.Read,
|
|
17
|
+
get_member: Tier.Read,
|
|
18
|
+
get_webhook: Tier.Read,
|
|
19
|
+
list_channels: Tier.Read,
|
|
20
|
+
list_guilds: Tier.Read,
|
|
21
|
+
list_members: Tier.Read,
|
|
22
|
+
list_roles: Tier.Read,
|
|
23
|
+
list_threads: Tier.Read,
|
|
24
|
+
list_webhooks: Tier.Read,
|
|
25
|
+
list_projects: Tier.Read,
|
|
26
|
+
search_messages: Tier.Read,
|
|
27
|
+
query_audit_log: Tier.Read,
|
|
28
|
+
health_check: Tier.Read,
|
|
29
|
+
// Write-tier tools (create or modify)
|
|
30
|
+
send_message: Tier.Write,
|
|
31
|
+
send_embed: Tier.Write,
|
|
32
|
+
edit_message: Tier.Write,
|
|
33
|
+
add_reaction: Tier.Write,
|
|
34
|
+
create_thread: Tier.Write,
|
|
35
|
+
create_channel: Tier.Write,
|
|
36
|
+
create_role: Tier.Write,
|
|
37
|
+
create_invite: Tier.Write,
|
|
38
|
+
create_webhook: Tier.Write,
|
|
39
|
+
execute_webhook: Tier.Write,
|
|
40
|
+
edit_channel: Tier.Write,
|
|
41
|
+
edit_role: Tier.Write,
|
|
42
|
+
edit_webhook: Tier.Write,
|
|
43
|
+
set_slowmode: Tier.Write,
|
|
44
|
+
set_permissions: Tier.Write,
|
|
45
|
+
assign_role: Tier.Write,
|
|
46
|
+
move_channel: Tier.Write,
|
|
47
|
+
archive_thread: Tier.Write,
|
|
48
|
+
timeout_member: Tier.Write,
|
|
49
|
+
// Destructive-tier tools (irreversible or high-impact)
|
|
50
|
+
delete_message: Tier.Destructive,
|
|
51
|
+
delete_channel: Tier.Destructive,
|
|
52
|
+
delete_role: Tier.Destructive,
|
|
53
|
+
delete_webhook: Tier.Destructive,
|
|
54
|
+
purge_messages: Tier.Destructive,
|
|
55
|
+
ban_member: Tier.Destructive,
|
|
56
|
+
unban_member: Tier.Destructive,
|
|
57
|
+
kick_member: Tier.Destructive,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Derive the base tier for a tool using the static map and naming conventions.
|
|
61
|
+
* This is the "floor" — overrides cannot go below this.
|
|
62
|
+
*/
|
|
63
|
+
function deriveBaseTier(baseName) {
|
|
64
|
+
// Check static map first
|
|
65
|
+
if (STATIC_TIER_MAP[baseName]) {
|
|
66
|
+
return STATIC_TIER_MAP[baseName];
|
|
67
|
+
}
|
|
68
|
+
// Convention-based classification for tools not in the static map.
|
|
69
|
+
// This allows non-Discord downstream servers to get sensible defaults.
|
|
70
|
+
if (/^(get_|list_|search_|query_|read_|fetch_|check_|health_|describe_|show_|count_)/.test(baseName)) {
|
|
71
|
+
return Tier.Read;
|
|
72
|
+
}
|
|
73
|
+
if (/^(delete_|drop_|purge_|remove_|destroy_|ban_|kick_|revoke_|truncate_)/.test(baseName)) {
|
|
74
|
+
return Tier.Destructive;
|
|
75
|
+
}
|
|
76
|
+
// Default: Write (fail-safe — requires at least L1)
|
|
77
|
+
return Tier.Write;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Classify a tool by its tier. Checks gateway config overrides first,
|
|
81
|
+
* then static map, then naming conventions, then defaults to Write.
|
|
82
|
+
*/
|
|
83
|
+
export function classifyTool(toolName, serverName, gatewayConfig) {
|
|
84
|
+
// Strip server prefix for base lookup (e.g., "discord-ops__send_message" -> "send_message")
|
|
85
|
+
const baseName = toolName.includes('__') ? toolName.split('__').pop() : toolName;
|
|
86
|
+
const baseTier = deriveBaseTier(baseName);
|
|
87
|
+
// Check per-server overrides in gateway config
|
|
88
|
+
const serverConfig = gatewayConfig?.servers[serverName];
|
|
89
|
+
const override = serverConfig?.tool_overrides?.[toolName];
|
|
90
|
+
if (override?.tier) {
|
|
91
|
+
const overrideTier = override.tier;
|
|
92
|
+
// SECURITY: Prevent tier downgrades — overrides cannot lower a tool below its base tier.
|
|
93
|
+
if (TIER_SEVERITY[overrideTier] < TIER_SEVERITY[baseTier]) {
|
|
94
|
+
console.error(`[rea] WARNING: tool_override for "${toolName}" attempted to downgrade tier from ${baseTier} to ${overrideTier} — ignoring override`);
|
|
95
|
+
return baseTier;
|
|
96
|
+
}
|
|
97
|
+
return overrideTier;
|
|
98
|
+
}
|
|
99
|
+
return baseTier;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check if a tool is explicitly blocked in gateway config.
|
|
103
|
+
*/
|
|
104
|
+
export function isToolBlocked(toolName, serverName, gatewayConfig) {
|
|
105
|
+
const serverConfig = gatewayConfig?.servers[serverName];
|
|
106
|
+
const override = serverConfig?.tool_overrides?.[toolName];
|
|
107
|
+
return override?.blocked === true;
|
|
108
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Tier } from '../policy/types.js';
|
|
2
|
+
export interface ToolOverride {
|
|
3
|
+
tier?: Tier;
|
|
4
|
+
blocked?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface DownstreamServer {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
tool_overrides?: Record<string, ToolOverride>;
|
|
11
|
+
/** Max concurrent in-flight calls to this server (0 = unlimited) */
|
|
12
|
+
max_concurrent_calls?: number;
|
|
13
|
+
/** Max calls per minute to this server (0 = unlimited) */
|
|
14
|
+
calls_per_minute?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface GatewayOptions {
|
|
17
|
+
/** Cap on tool result size in KB. Results exceeding this are truncated. Default: 512 */
|
|
18
|
+
max_result_size_kb?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface GatewayConfig {
|
|
21
|
+
version: string;
|
|
22
|
+
servers: Record<string, DownstreamServer>;
|
|
23
|
+
gateway?: GatewayOptions;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
2
|
+
export interface CircuitBreakerOptions {
|
|
3
|
+
/** Consecutive failures before opening the circuit. Default: 5 */
|
|
4
|
+
failureThreshold?: number;
|
|
5
|
+
/** Milliseconds to wait in open state before moving to half-open. Default: 30_000 */
|
|
6
|
+
cooldownMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CircuitStatus {
|
|
9
|
+
state: CircuitState;
|
|
10
|
+
serverName: string;
|
|
11
|
+
retryAt?: string;
|
|
12
|
+
}
|
|
13
|
+
interface CircuitEntry {
|
|
14
|
+
state: CircuitState;
|
|
15
|
+
consecutiveFailures: number;
|
|
16
|
+
openedAt: number | null;
|
|
17
|
+
failureThreshold: number;
|
|
18
|
+
cooldownMs: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Per-server circuit breaker.
|
|
22
|
+
*
|
|
23
|
+
* State machine:
|
|
24
|
+
* closed → open after N consecutive failures
|
|
25
|
+
* open → half-open after cooldown period
|
|
26
|
+
* half-open → closed on next success
|
|
27
|
+
* half-open → open on next failure
|
|
28
|
+
*/
|
|
29
|
+
export declare class CircuitBreaker {
|
|
30
|
+
private circuits;
|
|
31
|
+
private defaultOptions;
|
|
32
|
+
constructor(defaults?: CircuitBreakerOptions);
|
|
33
|
+
private getOrCreate;
|
|
34
|
+
/**
|
|
35
|
+
* Returns null if the call may proceed, or a CircuitStatus if the circuit is open.
|
|
36
|
+
* Side effect: transitions open → half-open if cooldown has elapsed.
|
|
37
|
+
*/
|
|
38
|
+
isAllowed(serverName: string): CircuitStatus | null;
|
|
39
|
+
recordSuccess(serverName: string): void;
|
|
40
|
+
recordFailure(serverName: string): void;
|
|
41
|
+
getCircuit(serverName: string): CircuitEntry | undefined;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* State machine:
|
|
5
|
+
* closed → open after N consecutive failures
|
|
6
|
+
* open → half-open after cooldown period
|
|
7
|
+
* half-open → closed on next success
|
|
8
|
+
* half-open → open on next failure
|
|
9
|
+
*/
|
|
10
|
+
export class CircuitBreaker {
|
|
11
|
+
circuits = new Map();
|
|
12
|
+
defaultOptions;
|
|
13
|
+
constructor(defaults = {}) {
|
|
14
|
+
this.defaultOptions = {
|
|
15
|
+
failureThreshold: defaults.failureThreshold ?? 5,
|
|
16
|
+
cooldownMs: defaults.cooldownMs ?? 30_000,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
getOrCreate(serverName) {
|
|
20
|
+
let entry = this.circuits.get(serverName);
|
|
21
|
+
if (!entry) {
|
|
22
|
+
entry = {
|
|
23
|
+
state: 'closed',
|
|
24
|
+
consecutiveFailures: 0,
|
|
25
|
+
openedAt: null,
|
|
26
|
+
failureThreshold: this.defaultOptions.failureThreshold,
|
|
27
|
+
cooldownMs: this.defaultOptions.cooldownMs,
|
|
28
|
+
};
|
|
29
|
+
this.circuits.set(serverName, entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Returns null if the call may proceed, or a CircuitStatus if the circuit is open.
|
|
35
|
+
* Side effect: transitions open → half-open if cooldown has elapsed.
|
|
36
|
+
*/
|
|
37
|
+
isAllowed(serverName) {
|
|
38
|
+
const entry = this.getOrCreate(serverName);
|
|
39
|
+
if (entry.state === 'closed')
|
|
40
|
+
return null;
|
|
41
|
+
if (entry.state === 'open') {
|
|
42
|
+
const elapsed = Date.now() - (entry.openedAt ?? 0);
|
|
43
|
+
if (elapsed >= entry.cooldownMs) {
|
|
44
|
+
entry.state = 'half-open';
|
|
45
|
+
entry.consecutiveFailures = 0;
|
|
46
|
+
console.error(`[rea] circuit-breaker: "${serverName}" transitioned open → half-open (probing recovery)`);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const retryAt = new Date((entry.openedAt ?? 0) + entry.cooldownMs).toISOString();
|
|
50
|
+
return {
|
|
51
|
+
state: 'open',
|
|
52
|
+
serverName,
|
|
53
|
+
retryAt,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
recordSuccess(serverName) {
|
|
59
|
+
const entry = this.getOrCreate(serverName);
|
|
60
|
+
if (entry.state === 'half-open') {
|
|
61
|
+
entry.state = 'closed';
|
|
62
|
+
entry.consecutiveFailures = 0;
|
|
63
|
+
entry.openedAt = null;
|
|
64
|
+
console.error(`[rea] circuit-breaker: "${serverName}" recovered — circuit closed`);
|
|
65
|
+
}
|
|
66
|
+
else if (entry.state === 'closed') {
|
|
67
|
+
entry.consecutiveFailures = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
recordFailure(serverName) {
|
|
71
|
+
const entry = this.getOrCreate(serverName);
|
|
72
|
+
if (entry.state === 'open')
|
|
73
|
+
return;
|
|
74
|
+
entry.consecutiveFailures++;
|
|
75
|
+
const shouldOpen = entry.state === 'half-open' || entry.consecutiveFailures >= entry.failureThreshold;
|
|
76
|
+
if (shouldOpen) {
|
|
77
|
+
entry.state = 'open';
|
|
78
|
+
entry.openedAt = Date.now();
|
|
79
|
+
const retryAt = new Date(entry.openedAt + entry.cooldownMs).toISOString();
|
|
80
|
+
console.error(`[rea] circuit-breaker: "${serverName}" OPENED after ${entry.consecutiveFailures} failure(s) — will retry at ${retryAt}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
getCircuit(serverName) {
|
|
84
|
+
return this.circuits.get(serverName);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Tier, InvocationStatus } from '../../policy/types.js';
|
|
2
|
+
export interface AuditRecord {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
session_id: string;
|
|
5
|
+
tool_name: string;
|
|
6
|
+
server_name: string;
|
|
7
|
+
tier: Tier;
|
|
8
|
+
status: InvocationStatus;
|
|
9
|
+
autonomy_level: string;
|
|
10
|
+
duration_ms: number;
|
|
11
|
+
account_name?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
redacted_fields?: string[];
|
|
14
|
+
hash: string;
|
|
15
|
+
prev_hash: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Policy } from '../../policy/types.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* Post-execution middleware: appends a hash-chained JSONL audit record.
|
|
5
|
+
*
|
|
6
|
+
* SECURITY: Each audit middleware instance maintains its own hash chain.
|
|
7
|
+
* SECURITY: Audit write failures are logged to stderr but do NOT crash the gateway.
|
|
8
|
+
* SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
|
|
9
|
+
* SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
|
|
10
|
+
* PERFORMANCE: All fs operations are async to avoid blocking the event loop.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createAuditMiddleware(baseDir: string, policy?: Policy): Middleware;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { Tier, InvocationStatus } from '../../policy/types.js';
|
|
5
|
+
function computeHash(record) {
|
|
6
|
+
const payload = JSON.stringify(record);
|
|
7
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Post-execution middleware: appends a hash-chained JSONL audit record.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY: Each audit middleware instance maintains its own hash chain.
|
|
13
|
+
* SECURITY: Audit write failures are logged to stderr but do NOT crash the gateway.
|
|
14
|
+
* SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
|
|
15
|
+
* SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
|
|
16
|
+
* PERFORMANCE: All fs operations are async to avoid blocking the event loop.
|
|
17
|
+
*/
|
|
18
|
+
export function createAuditMiddleware(baseDir, policy) {
|
|
19
|
+
// REA writes to a single .rea/audit.jsonl file (not dated per-day files).
|
|
20
|
+
const reaDir = path.join(baseDir, '.rea');
|
|
21
|
+
const auditFile = path.join(reaDir, 'audit.jsonl');
|
|
22
|
+
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
23
|
+
let dirEnsured = false;
|
|
24
|
+
// SECURITY: Use a write queue to serialize audit writes, ensuring the hash chain is linear.
|
|
25
|
+
let writeQueue = Promise.resolve();
|
|
26
|
+
return async (ctx, next) => {
|
|
27
|
+
let nextError;
|
|
28
|
+
try {
|
|
29
|
+
await next();
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
// Capture the error but still write the audit record
|
|
33
|
+
nextError = err instanceof Error ? err : new Error(String(err));
|
|
34
|
+
ctx.status = InvocationStatus.Error;
|
|
35
|
+
ctx.error = nextError.message;
|
|
36
|
+
}
|
|
37
|
+
// Build audit record — always runs, even after exceptions.
|
|
38
|
+
// SECURITY: autonomy_level from ctx.metadata reflects the hot-reloaded policy (set by policy
|
|
39
|
+
// middleware inside next()). Falls back to the startup policy if metadata was not set (e.g.,
|
|
40
|
+
// kill-switch denied before policy middleware ran).
|
|
41
|
+
const duration_ms = Date.now() - ctx.start_time;
|
|
42
|
+
const autonomyLevel = ctx.metadata.autonomy_level ?? policy?.autonomy_level ?? 'unknown';
|
|
43
|
+
// Serialize audit writes via a queue to maintain hash chain linearity under concurrency.
|
|
44
|
+
// Each write awaits the previous one before computing its hash, ensuring a linear chain.
|
|
45
|
+
const writePromise = writeQueue.then(async () => {
|
|
46
|
+
try {
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const recordBase = {
|
|
49
|
+
timestamp: now,
|
|
50
|
+
session_id: ctx.session_id,
|
|
51
|
+
tool_name: ctx.tool_name,
|
|
52
|
+
server_name: ctx.server_name,
|
|
53
|
+
tier: ctx.tier ?? Tier.Write,
|
|
54
|
+
status: ctx.status,
|
|
55
|
+
autonomy_level: autonomyLevel,
|
|
56
|
+
duration_ms,
|
|
57
|
+
prev_hash: prevHash,
|
|
58
|
+
};
|
|
59
|
+
if (ctx.error) {
|
|
60
|
+
recordBase.error = ctx.error;
|
|
61
|
+
}
|
|
62
|
+
if (ctx.redacted_fields?.length) {
|
|
63
|
+
recordBase.redacted_fields = ctx.redacted_fields;
|
|
64
|
+
}
|
|
65
|
+
const hash = computeHash(recordBase);
|
|
66
|
+
const record = { ...recordBase, hash };
|
|
67
|
+
prevHash = hash;
|
|
68
|
+
const line = JSON.stringify(record) + '\n';
|
|
69
|
+
// Ensure .rea dir exists (cached, with retry on failure)
|
|
70
|
+
if (!dirEnsured) {
|
|
71
|
+
await fs.mkdir(reaDir, { recursive: true });
|
|
72
|
+
dirEnsured = true;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await fs.appendFile(auditFile, line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Directory may have been deleted externally — retry once with mkdir
|
|
79
|
+
dirEnsured = false;
|
|
80
|
+
await fs.mkdir(reaDir, { recursive: true });
|
|
81
|
+
dirEnsured = true;
|
|
82
|
+
await fs.appendFile(auditFile, line);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (auditErr) {
|
|
86
|
+
// SECURITY: Never crash the gateway on audit failure — log to stderr
|
|
87
|
+
dirEnsured = false;
|
|
88
|
+
console.error('[rea] AUDIT WRITE FAILED:', auditErr instanceof Error ? auditErr.message : auditErr);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
writeQueue = writePromise;
|
|
92
|
+
await writePromise;
|
|
93
|
+
// Re-throw the original error if next() failed
|
|
94
|
+
if (nextError) {
|
|
95
|
+
throw nextError;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Policy } from '../../policy/types.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pre-execution middleware: denies tool invocations whose arguments
|
|
5
|
+
* reference paths that are in the policy's blocked_paths list.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: Inspects all string values in arguments (including nested objects/arrays).
|
|
8
|
+
* SECURITY: Always blocks .rea/ regardless of policy configuration.
|
|
9
|
+
* SECURITY: Normalizes URL-encoded characters, path separators, and case before comparison.
|
|
10
|
+
* SECURITY: Re-reads blocked_paths from policy.yaml when baseDir is provided (hot-reload).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createBlockedPathsMiddleware(initialPolicy: Policy, baseDir?: string): Middleware;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
3
|
+
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
4
|
+
/**
|
|
5
|
+
* Pre-execution middleware: denies tool invocations whose arguments
|
|
6
|
+
* reference paths that are in the policy's blocked_paths list.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: Inspects all string values in arguments (including nested objects/arrays).
|
|
9
|
+
* SECURITY: Always blocks .rea/ regardless of policy configuration.
|
|
10
|
+
* SECURITY: Normalizes URL-encoded characters, path separators, and case before comparison.
|
|
11
|
+
* SECURITY: Re-reads blocked_paths from policy.yaml when baseDir is provided (hot-reload).
|
|
12
|
+
*/
|
|
13
|
+
export function createBlockedPathsMiddleware(initialPolicy, baseDir) {
|
|
14
|
+
return async (ctx, next) => {
|
|
15
|
+
// Hot-reload blocked_paths from policy.yaml if baseDir is available
|
|
16
|
+
let blockedPaths = initialPolicy.blocked_paths;
|
|
17
|
+
if (baseDir) {
|
|
18
|
+
try {
|
|
19
|
+
const policy = await loadPolicyAsync(baseDir);
|
|
20
|
+
blockedPaths = policy.blocked_paths;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Fall back to initial policy's blocked_paths on read failure
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Always protect .rea/ — it's the trust root of the system.
|
|
27
|
+
const paths = [...new Set([...blockedPaths, '.rea/'])];
|
|
28
|
+
// Recursively extract all string values from arguments
|
|
29
|
+
const stringValues = extractStringValues(ctx.arguments);
|
|
30
|
+
for (const [key, value] of stringValues) {
|
|
31
|
+
for (const blocked of paths) {
|
|
32
|
+
if (containsBlockedPath(value, blocked)) {
|
|
33
|
+
ctx.status = InvocationStatus.Denied;
|
|
34
|
+
ctx.error = `Argument "${key}" references blocked path "${blocked}". Tool: ${ctx.tool_name}`;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Recursively extract all string values from an object, with their key paths.
|
|
44
|
+
* Handles nested objects and arrays.
|
|
45
|
+
*/
|
|
46
|
+
function extractStringValues(obj, prefix = '', seen = new WeakSet()) {
|
|
47
|
+
const results = [];
|
|
48
|
+
if (obj === null || obj === undefined)
|
|
49
|
+
return results;
|
|
50
|
+
if (typeof obj === 'string') {
|
|
51
|
+
results.push([prefix || 'value', obj]);
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
if (typeof obj !== 'object')
|
|
55
|
+
return results;
|
|
56
|
+
// Circular reference guard
|
|
57
|
+
const objRef = obj;
|
|
58
|
+
if (seen.has(objRef))
|
|
59
|
+
return results;
|
|
60
|
+
seen.add(objRef);
|
|
61
|
+
if (Array.isArray(obj)) {
|
|
62
|
+
for (let i = 0; i < obj.length; i++) {
|
|
63
|
+
results.push(...extractStringValues(obj[i], `${prefix}[${i}]`, seen));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
68
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
69
|
+
results.push(...extractStringValues(value, fullKey, seen));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a string value references a blocked path.
|
|
76
|
+
*
|
|
77
|
+
* SECURITY: Decodes URL-encoded characters (%2F, %2f, etc.)
|
|
78
|
+
* SECURITY: Normalizes path separators and resolves . and .. segments
|
|
79
|
+
* SECURITY: Performs case-insensitive comparison for cross-platform safety
|
|
80
|
+
*/
|
|
81
|
+
function containsBlockedPath(value, blockedPath) {
|
|
82
|
+
// Normalize the value: decode URL encoding, normalize slashes and path segments
|
|
83
|
+
const normalized = normalizePath(value);
|
|
84
|
+
const normalizedBlocked = blockedPath.replace(/\\/g, '/').toLowerCase();
|
|
85
|
+
// Direct containment check (case-insensitive)
|
|
86
|
+
if (normalized.includes(normalizedBlocked))
|
|
87
|
+
return true;
|
|
88
|
+
// Check without leading dot/slash for relative path variants
|
|
89
|
+
const stripped = normalizedBlocked.replace(/^\.?\/?/, '');
|
|
90
|
+
if (stripped && normalized.includes(stripped))
|
|
91
|
+
return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a path string for blocked-path comparison.
|
|
96
|
+
*
|
|
97
|
+
* 1. Decode URL-encoded characters (handles %2F, %2f, %2E, etc.)
|
|
98
|
+
* 2. Normalize backslashes to forward slashes
|
|
99
|
+
* 3. Normalize path segments (resolve . and ..)
|
|
100
|
+
* 4. Lowercase for case-insensitive comparison
|
|
101
|
+
*/
|
|
102
|
+
function normalizePath(value) {
|
|
103
|
+
let decoded = value;
|
|
104
|
+
// Decode URL-encoded characters (try/catch for malformed sequences)
|
|
105
|
+
try {
|
|
106
|
+
decoded = decodeURIComponent(value);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// If decoding fails, use the original value — may contain partial encoding
|
|
110
|
+
}
|
|
111
|
+
// Normalize backslashes to forward slashes
|
|
112
|
+
decoded = decoded.replace(/\\/g, '/');
|
|
113
|
+
// Use path.normalize to resolve . and .. segments, then re-normalize slashes
|
|
114
|
+
decoded = path.normalize(decoded).replace(/\\/g, '/');
|
|
115
|
+
// Lowercase for case-insensitive comparison
|
|
116
|
+
return decoded.toLowerCase();
|
|
117
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { InvocationStatus, type Tier } from '../../policy/types.js';
|
|
2
|
+
export interface InvocationContext {
|
|
3
|
+
tool_name: string;
|
|
4
|
+
server_name: string;
|
|
5
|
+
arguments: Record<string, unknown>;
|
|
6
|
+
session_id: string;
|
|
7
|
+
tier?: Tier;
|
|
8
|
+
status: InvocationStatus;
|
|
9
|
+
error?: string;
|
|
10
|
+
result?: unknown;
|
|
11
|
+
start_time: number;
|
|
12
|
+
redacted_fields?: string[];
|
|
13
|
+
metadata: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export type NextFn = () => Promise<void>;
|
|
16
|
+
export type Middleware = (ctx: InvocationContext, next: NextFn) => Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Execute a middleware chain in onion (koa-style) order.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY: Once status is set to Denied, it is locked for the remainder
|
|
21
|
+
* of the chain. No middleware can revert a denial.
|
|
22
|
+
*/
|
|
23
|
+
export declare function executeChain(middlewares: Middleware[], ctx: InvocationContext): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Type-safe factory for building named middleware chains.
|
|
26
|
+
* Returns a function that executes the chain with a fresh context.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createMiddlewareChain(middlewares: Middleware[]): (ctx: InvocationContext) => Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Execute a middleware chain in onion (koa-style) order.
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: Once status is set to Denied, it is locked for the remainder
|
|
6
|
+
* of the chain. No middleware can revert a denial.
|
|
7
|
+
*/
|
|
8
|
+
export function executeChain(middlewares, ctx) {
|
|
9
|
+
let index = -1;
|
|
10
|
+
let deniedOnce = false;
|
|
11
|
+
let savedError;
|
|
12
|
+
function dispatch(i) {
|
|
13
|
+
if (i <= index) {
|
|
14
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
15
|
+
}
|
|
16
|
+
index = i;
|
|
17
|
+
const mw = middlewares[i];
|
|
18
|
+
if (!mw) {
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}
|
|
21
|
+
return Promise.resolve(mw(ctx, () => dispatch(i + 1))).then(() => {
|
|
22
|
+
if (ctx.status === InvocationStatus.Denied && !deniedOnce) {
|
|
23
|
+
deniedOnce = true;
|
|
24
|
+
savedError = ctx.error;
|
|
25
|
+
}
|
|
26
|
+
if (deniedOnce && ctx.status !== InvocationStatus.Denied) {
|
|
27
|
+
ctx.status = InvocationStatus.Denied;
|
|
28
|
+
ctx.error = savedError ?? 'Denial status was tampered with — re-locked';
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return dispatch(0);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Type-safe factory for building named middleware chains.
|
|
36
|
+
* Returns a function that executes the chain with a fresh context.
|
|
37
|
+
*/
|
|
38
|
+
export function createMiddlewareChain(middlewares) {
|
|
39
|
+
return (ctx) => executeChain(middlewares, ctx);
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CircuitBreaker } from '../circuit-breaker.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse + PostToolUse middleware: wraps downstream calls with a circuit breaker.
|
|
5
|
+
*
|
|
6
|
+
* On entry: if the circuit is open, denies the call immediately and returns the
|
|
7
|
+
* structured error without hitting the downstream server.
|
|
8
|
+
*
|
|
9
|
+
* On exit: records success or failure so the breaker can track state transitions.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createCircuitBreakerMiddleware(breaker: CircuitBreaker): Middleware;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse + PostToolUse middleware: wraps downstream calls with a circuit breaker.
|
|
4
|
+
*
|
|
5
|
+
* On entry: if the circuit is open, denies the call immediately and returns the
|
|
6
|
+
* structured error without hitting the downstream server.
|
|
7
|
+
*
|
|
8
|
+
* On exit: records success or failure so the breaker can track state transitions.
|
|
9
|
+
*/
|
|
10
|
+
export function createCircuitBreakerMiddleware(breaker) {
|
|
11
|
+
return async (ctx, next) => {
|
|
12
|
+
const status = breaker.isAllowed(ctx.server_name);
|
|
13
|
+
if (status !== null) {
|
|
14
|
+
// Circuit is open — fail fast
|
|
15
|
+
ctx.status = InvocationStatus.Denied;
|
|
16
|
+
ctx.error =
|
|
17
|
+
`Circuit breaker open for server "${status.serverName}": downstream is unavailable. ` +
|
|
18
|
+
`State: ${status.state}. Will retry at ${status.retryAt ?? 'unknown'}.`;
|
|
19
|
+
ctx.metadata.circuit_breaker = {
|
|
20
|
+
state: status.state,
|
|
21
|
+
serverName: status.serverName,
|
|
22
|
+
retryAt: status.retryAt,
|
|
23
|
+
};
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
await next();
|
|
28
|
+
// A Denied status from the middleware chain (policy, rate-limit, etc.) is not a
|
|
29
|
+
// downstream failure — only Error status means the server itself failed.
|
|
30
|
+
if (ctx.status === InvocationStatus.Error) {
|
|
31
|
+
breaker.recordFailure(ctx.server_name);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
breaker.recordSuccess(ctx.server_name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// Unhandled exceptions from inner middlewares also count as failures
|
|
39
|
+
breaker.recordFailure(ctx.server_name);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|