@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,22 @@
|
|
|
1
|
+
import type { Middleware } from './chain.js';
|
|
2
|
+
/**
|
|
3
|
+
* Scan a string for known prompt injection phrases.
|
|
4
|
+
* Also decodes base64 tokens and checks the decoded content.
|
|
5
|
+
* Returns an array of matched phrase descriptions, empty if clean.
|
|
6
|
+
*/
|
|
7
|
+
export declare function scanForInjection(input: string): string[];
|
|
8
|
+
export type InjectionAction = 'block' | 'warn';
|
|
9
|
+
/**
|
|
10
|
+
* PostToolUse middleware: scans tool results for prompt injection patterns.
|
|
11
|
+
*
|
|
12
|
+
* Operates on tool output (ctx.result) returned from downstream MCP servers.
|
|
13
|
+
* On detection:
|
|
14
|
+
* - Always logs to audit metadata and emits a warning to stderr.
|
|
15
|
+
* - If action is 'block' (default), sets ctx.status to Denied and blocks the result.
|
|
16
|
+
* - If action is 'warn', allows the result through with a warning only.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY: Checking PostToolUse (after downstream execution, before the result
|
|
19
|
+
* reaches the LLM) is the correct place to catch injection in tool descriptions
|
|
20
|
+
* and resource content coming from potentially untrusted downstream servers.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createInjectionMiddleware(action?: InjectionAction): Middleware;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Known prompt injection phrases (lowercase for case-insensitive matching).
|
|
4
|
+
* These patterns are commonly used to override system instructions in tool
|
|
5
|
+
* descriptions or resource content returned by downstream MCP servers.
|
|
6
|
+
*/
|
|
7
|
+
const INJECTION_PHRASES = [
|
|
8
|
+
'ignore previous instructions',
|
|
9
|
+
'disregard your',
|
|
10
|
+
'your new instructions are',
|
|
11
|
+
'system prompt override',
|
|
12
|
+
'forget all previous',
|
|
13
|
+
// 'you are now' is too broad — fires on "you are now connected", "you are now in /home/foo", etc.
|
|
14
|
+
// The role-reassignment vector is "you are now a [different persona]" — the trailing space+article
|
|
15
|
+
// is what distinguishes injection from ordinary status messages.
|
|
16
|
+
'you are now a ',
|
|
17
|
+
'you are now an ',
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* Decode a base64 string, returning the decoded text or null if decoding fails.
|
|
21
|
+
* Only decodes if the input looks like base64 (64-char alphabet, length divisible by 4 or padded).
|
|
22
|
+
*/
|
|
23
|
+
function tryDecodeBase64(input) {
|
|
24
|
+
// Quick heuristic: must be at least 20 chars and use only base64 chars
|
|
25
|
+
if (input.length < 20)
|
|
26
|
+
return null;
|
|
27
|
+
if (!/^[A-Za-z0-9+/]+=*$/.test(input))
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
return Buffer.from(input, 'base64').toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Scan a string for known prompt injection phrases.
|
|
38
|
+
* Also decodes base64 tokens and checks the decoded content.
|
|
39
|
+
* Returns an array of matched phrase descriptions, empty if clean.
|
|
40
|
+
*/
|
|
41
|
+
export function scanForInjection(input) {
|
|
42
|
+
if (!input || typeof input !== 'string')
|
|
43
|
+
return [];
|
|
44
|
+
const lower = input.toLowerCase();
|
|
45
|
+
const matches = [];
|
|
46
|
+
// Check literal phrases
|
|
47
|
+
for (const phrase of INJECTION_PHRASES) {
|
|
48
|
+
if (lower.includes(phrase)) {
|
|
49
|
+
matches.push(`literal: "${phrase}"`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Check base64-encoded variants — scan word-like tokens that look like base64
|
|
53
|
+
const base64Tokens = input.match(/[A-Za-z0-9+/]{20,}={0,2}/g) ?? [];
|
|
54
|
+
for (const token of base64Tokens) {
|
|
55
|
+
const decoded = tryDecodeBase64(token);
|
|
56
|
+
if (!decoded)
|
|
57
|
+
continue;
|
|
58
|
+
const decodedLower = decoded.toLowerCase();
|
|
59
|
+
for (const phrase of INJECTION_PHRASES) {
|
|
60
|
+
if (decodedLower.includes(phrase)) {
|
|
61
|
+
matches.push(`base64-encoded: "${phrase}"`);
|
|
62
|
+
break; // One report per token is enough
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return matches;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Scan an unknown value recursively, collecting all injection matches.
|
|
70
|
+
* Walks strings, arrays, and plain objects.
|
|
71
|
+
*/
|
|
72
|
+
function scanValue(value, matches) {
|
|
73
|
+
if (typeof value === 'string') {
|
|
74
|
+
matches.push(...scanForInjection(value));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
for (const item of value) {
|
|
79
|
+
scanValue(item, matches);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (value !== null && typeof value === 'object') {
|
|
84
|
+
for (const v of Object.values(value)) {
|
|
85
|
+
scanValue(v, matches);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* PostToolUse middleware: scans tool results for prompt injection patterns.
|
|
91
|
+
*
|
|
92
|
+
* Operates on tool output (ctx.result) returned from downstream MCP servers.
|
|
93
|
+
* On detection:
|
|
94
|
+
* - Always logs to audit metadata and emits a warning to stderr.
|
|
95
|
+
* - If action is 'block' (default), sets ctx.status to Denied and blocks the result.
|
|
96
|
+
* - If action is 'warn', allows the result through with a warning only.
|
|
97
|
+
*
|
|
98
|
+
* SECURITY: Checking PostToolUse (after downstream execution, before the result
|
|
99
|
+
* reaches the LLM) is the correct place to catch injection in tool descriptions
|
|
100
|
+
* and resource content coming from potentially untrusted downstream servers.
|
|
101
|
+
*/
|
|
102
|
+
export function createInjectionMiddleware(action = 'block') {
|
|
103
|
+
return async (ctx, next) => {
|
|
104
|
+
await next();
|
|
105
|
+
// Only scan if we have a result to inspect
|
|
106
|
+
if (ctx.result == null)
|
|
107
|
+
return;
|
|
108
|
+
const matches = [];
|
|
109
|
+
scanValue(ctx.result, matches);
|
|
110
|
+
if (matches.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
// Deduplicate matches
|
|
113
|
+
const unique = [...new Set(matches)];
|
|
114
|
+
// Always log to audit metadata
|
|
115
|
+
ctx.metadata.injection_matches = unique;
|
|
116
|
+
// Always emit warning to stderr
|
|
117
|
+
process.stderr.write(`[rea] INJECTION-GUARD: Prompt injection pattern detected in tool "${ctx.tool_name}" result\n`);
|
|
118
|
+
for (const match of unique) {
|
|
119
|
+
process.stderr.write(` Pattern: ${match}\n`);
|
|
120
|
+
}
|
|
121
|
+
process.stderr.write(` Action: ${action} — review the downstream server "${ctx.server_name}" for compromise.\n`);
|
|
122
|
+
if (action === 'block') {
|
|
123
|
+
ctx.status = InvocationStatus.Denied;
|
|
124
|
+
ctx.error = `Prompt injection detected in tool result (${unique.length} pattern(s) matched). Result blocked.`;
|
|
125
|
+
ctx.result = undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Middleware } from './chain.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks for `.rea/HALT` file. If present, denies the invocation.
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: Validates HALT is a regular file (not directory/symlink to sensitive file).
|
|
6
|
+
* SECURITY: Symlinks must resolve to a target within `.rea/`.
|
|
7
|
+
* SECURITY: Caps read size to prevent oversized error strings.
|
|
8
|
+
* SECURITY: Fails closed on unexpected errors.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createKillSwitchMiddleware(baseDir: string): Middleware;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
5
|
+
const MAX_HALT_READ_BYTES = 1024;
|
|
6
|
+
const REA_DIR = '.rea';
|
|
7
|
+
const HALT_FILE = 'HALT';
|
|
8
|
+
/**
|
|
9
|
+
* Checks for `.rea/HALT` file. If present, denies the invocation.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY: Validates HALT is a regular file (not directory/symlink to sensitive file).
|
|
12
|
+
* SECURITY: Symlinks must resolve to a target within `.rea/`.
|
|
13
|
+
* SECURITY: Caps read size to prevent oversized error strings.
|
|
14
|
+
* SECURITY: Fails closed on unexpected errors.
|
|
15
|
+
*/
|
|
16
|
+
export function createKillSwitchMiddleware(baseDir) {
|
|
17
|
+
return async (ctx, next) => {
|
|
18
|
+
const haltPath = path.join(baseDir, REA_DIR, HALT_FILE);
|
|
19
|
+
try {
|
|
20
|
+
const stat = await fs.stat(haltPath);
|
|
21
|
+
if (!stat.isFile()) {
|
|
22
|
+
ctx.status = InvocationStatus.Denied;
|
|
23
|
+
ctx.error = 'Kill switch active: HALT exists (non-file)';
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const lstat = await fs.lstat(haltPath);
|
|
27
|
+
if (lstat.isSymbolicLink()) {
|
|
28
|
+
const target = await fs.realpath(haltPath);
|
|
29
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
30
|
+
if (!target.startsWith(reaDir)) {
|
|
31
|
+
ctx.status = InvocationStatus.Denied;
|
|
32
|
+
ctx.error = 'Kill switch active: HALT is a symlink outside .rea/';
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const fh = await fs.open(haltPath, fsConstants.O_RDONLY);
|
|
37
|
+
try {
|
|
38
|
+
const buf = Buffer.alloc(MAX_HALT_READ_BYTES);
|
|
39
|
+
const { bytesRead } = await fh.read(buf, 0, MAX_HALT_READ_BYTES, 0);
|
|
40
|
+
const reason = buf.subarray(0, bytesRead).toString('utf8').trim();
|
|
41
|
+
ctx.status = InvocationStatus.Denied;
|
|
42
|
+
ctx.error = `Kill switch active: ${reason}`;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await fh.close();
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') {
|
|
51
|
+
await next();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
ctx.status = InvocationStatus.Denied;
|
|
55
|
+
ctx.error = `Kill switch check failed: ${err.message}`;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Policy } from '../../policy/types.js';
|
|
2
|
+
import type { GatewayConfig } from '../../config/types.js';
|
|
3
|
+
import type { Middleware } from './chain.js';
|
|
4
|
+
/**
|
|
5
|
+
* Checks autonomy level against tool tier, and checks blocked tools.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: Re-reads policy.yaml on every invocation so autonomy level changes
|
|
8
|
+
* take effect immediately without gateway restart.
|
|
9
|
+
* SECURITY: Re-derives tier from tool_name independently — never trusts ctx.tier.
|
|
10
|
+
* SECURITY: Undefined/unknown tier defaults to DENY (fail-closed).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createPolicyMiddleware(initialPolicy: Policy, gatewayConfig?: GatewayConfig, baseDir?: string): Middleware;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AutonomyLevel, InvocationStatus, Tier } from '../../policy/types.js';
|
|
2
|
+
import { classifyTool, isToolBlocked } from '../../config/tier-map.js';
|
|
3
|
+
import { loadPolicyAsync } from '../../policy/loader.js';
|
|
4
|
+
/**
|
|
5
|
+
* Autonomy level tier permissions:
|
|
6
|
+
* - L0: Read only
|
|
7
|
+
* - L1: Read + Write (no destructive)
|
|
8
|
+
* - L2: Read + Write (no destructive)
|
|
9
|
+
* - L3: All tiers allowed
|
|
10
|
+
*/
|
|
11
|
+
const TIER_ALLOWED = {
|
|
12
|
+
[AutonomyLevel.L0]: new Set([Tier.Read]),
|
|
13
|
+
[AutonomyLevel.L1]: new Set([Tier.Read, Tier.Write]),
|
|
14
|
+
[AutonomyLevel.L2]: new Set([Tier.Read, Tier.Write]),
|
|
15
|
+
[AutonomyLevel.L3]: new Set([Tier.Read, Tier.Write, Tier.Destructive]),
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Checks autonomy level against tool tier, and checks blocked tools.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY: Re-reads policy.yaml on every invocation so autonomy level changes
|
|
21
|
+
* take effect immediately without gateway restart.
|
|
22
|
+
* SECURITY: Re-derives tier from tool_name independently — never trusts ctx.tier.
|
|
23
|
+
* SECURITY: Undefined/unknown tier defaults to DENY (fail-closed).
|
|
24
|
+
*/
|
|
25
|
+
export function createPolicyMiddleware(initialPolicy, gatewayConfig, baseDir) {
|
|
26
|
+
// SECURITY: Cache last successfully parsed policy for fallback.
|
|
27
|
+
// This prevents falling back to a potentially more permissive initial policy
|
|
28
|
+
// if the file is corrupted after a stricter policy was loaded.
|
|
29
|
+
let lastGoodPolicy = initialPolicy;
|
|
30
|
+
return async (ctx, next) => {
|
|
31
|
+
// SECURITY: Re-read policy on each invocation for live autonomy changes.
|
|
32
|
+
// Falls back to last successfully parsed policy on read failure.
|
|
33
|
+
let policy = lastGoodPolicy;
|
|
34
|
+
if (baseDir) {
|
|
35
|
+
try {
|
|
36
|
+
policy = await loadPolicyAsync(baseDir);
|
|
37
|
+
lastGoodPolicy = policy; // Cache successful parse
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Fail-safe: use last successfully parsed policy if re-read fails
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Check if tool is explicitly blocked
|
|
44
|
+
if (isToolBlocked(ctx.tool_name, ctx.server_name, gatewayConfig)) {
|
|
45
|
+
ctx.status = InvocationStatus.Denied;
|
|
46
|
+
ctx.error = `Tool "${ctx.tool_name}" is explicitly blocked in gateway config`;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// SECURITY: Re-derive tier from tool_name — do NOT trust ctx.tier from prior middleware.
|
|
50
|
+
// This prevents a rogue middleware from downgrading a destructive tool to read-tier.
|
|
51
|
+
const tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
|
|
52
|
+
ctx.tier = tier; // Overwrite with authoritative classification
|
|
53
|
+
// Validate autonomy level is known
|
|
54
|
+
const allowed = TIER_ALLOWED[policy.autonomy_level];
|
|
55
|
+
if (!allowed) {
|
|
56
|
+
ctx.status = InvocationStatus.Denied;
|
|
57
|
+
ctx.error = `Unknown autonomy level: ${policy.autonomy_level}. Denying by default.`;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Check autonomy level vs tier (fail-closed: deny if tier unknown)
|
|
61
|
+
if (!allowed.has(tier)) {
|
|
62
|
+
ctx.status = InvocationStatus.Denied;
|
|
63
|
+
ctx.error = `Autonomy level ${policy.autonomy_level} does not allow ${tier}-tier tools. Tool: ${ctx.tool_name}`;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Store current autonomy level in metadata for audit middleware
|
|
67
|
+
ctx.metadata.autonomy_level = policy.autonomy_level;
|
|
68
|
+
await next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RateLimiter } from '../rate-limiter.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse + PostToolUse middleware: enforces per-server concurrency and rate limits.
|
|
5
|
+
*
|
|
6
|
+
* On entry: tries to acquire a slot. If over limit, denies the call immediately
|
|
7
|
+
* without ever reaching the downstream server.
|
|
8
|
+
*
|
|
9
|
+
* On exit (finally): releases the concurrency slot so the next queued call can proceed.
|
|
10
|
+
* The slot is released even if the downstream call errors — we track capacity, not success.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createRateLimitMiddleware(limiter: RateLimiter): Middleware;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { InvocationStatus } from '../../policy/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse + PostToolUse middleware: enforces per-server concurrency and rate limits.
|
|
4
|
+
*
|
|
5
|
+
* On entry: tries to acquire a slot. If over limit, denies the call immediately
|
|
6
|
+
* without ever reaching the downstream server.
|
|
7
|
+
*
|
|
8
|
+
* On exit (finally): releases the concurrency slot so the next queued call can proceed.
|
|
9
|
+
* The slot is released even if the downstream call errors — we track capacity, not success.
|
|
10
|
+
*/
|
|
11
|
+
export function createRateLimitMiddleware(limiter) {
|
|
12
|
+
return async (ctx, next) => {
|
|
13
|
+
const err = limiter.tryAcquire(ctx.server_name);
|
|
14
|
+
if (err !== null) {
|
|
15
|
+
ctx.status = InvocationStatus.Denied;
|
|
16
|
+
ctx.error = err.message;
|
|
17
|
+
ctx.metadata.rate_limit_exceeded = {
|
|
18
|
+
type: err.type,
|
|
19
|
+
current: err.current,
|
|
20
|
+
limit: err.limit,
|
|
21
|
+
};
|
|
22
|
+
return; // Do not call next() — gate is closed
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await next();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
limiter.release(ctx.server_name);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Middleware } from './chain.js';
|
|
2
|
+
/**
|
|
3
|
+
* Redact secrets from a string, returning the redacted string and list of redacted field names.
|
|
4
|
+
*/
|
|
5
|
+
export declare function redactSecrets(input: string): {
|
|
6
|
+
output: string;
|
|
7
|
+
redacted: string[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Post-execution middleware: scans tool output for secret patterns and redacts them.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY: For non-string results, redaction operates on individual string values
|
|
13
|
+
* within the object structure rather than JSON.stringify→replace→JSON.parse, which
|
|
14
|
+
* could corrupt the result if a replacement changes JSON structure.
|
|
15
|
+
*/
|
|
16
|
+
export declare const redactMiddleware: Middleware;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patterns that match common secret formats.
|
|
3
|
+
* Each pattern has a name (for audit logging) and a regex.
|
|
4
|
+
*
|
|
5
|
+
* SECURITY: Patterns use case-insensitive flag where applicable.
|
|
6
|
+
* SECURITY: Input is sanitized (null bytes stripped) before matching.
|
|
7
|
+
*/
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
{ name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/gi },
|
|
10
|
+
{
|
|
11
|
+
name: 'AWS Secret Key',
|
|
12
|
+
pattern: /(?:aws_secret_access_key|secret_key)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi,
|
|
13
|
+
},
|
|
14
|
+
{ name: 'GitHub Token', pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/g },
|
|
15
|
+
{
|
|
16
|
+
name: 'Generic API Key',
|
|
17
|
+
pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/gi,
|
|
18
|
+
},
|
|
19
|
+
{ name: 'Bearer Token', pattern: /bearer\s+[A-Za-z0-9\-_.~+/]+=*/gi },
|
|
20
|
+
{ name: 'Private Key', pattern: /-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+)?PRIVATE\s+KEY-----/gi },
|
|
21
|
+
{ name: 'Discord Token', pattern: /[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}/g },
|
|
22
|
+
// Base64-encoded AWS access key (AKIA... in base64 starts with QUTJQ)
|
|
23
|
+
{ name: 'Base64 AWS Key', pattern: /QUtJQ[A-Za-z0-9+/]{17,}={0,2}/g },
|
|
24
|
+
// Anthropic API keys (sk-ant-api03-... and similar)
|
|
25
|
+
{ name: 'Anthropic API Key', pattern: /sk-ant-[a-zA-Z0-9\-_]{32,}/g },
|
|
26
|
+
// OpenAI API keys — project keys (sk-proj-...) and legacy (sk-...)
|
|
27
|
+
{ name: 'OpenAI Project Key', pattern: /sk-proj-[a-zA-Z0-9\-_]{32,}/g },
|
|
28
|
+
{ name: 'OpenAI API Key', pattern: /sk-[a-zA-Z0-9]{32,}/g },
|
|
29
|
+
// Hugging Face access tokens
|
|
30
|
+
{ name: 'Hugging Face Token', pattern: /hf_[a-zA-Z0-9]{32,}/g },
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Strip null bytes and other control characters that could break regex matching.
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeInput(input) {
|
|
36
|
+
return input.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Redact secrets from a string, returning the redacted string and list of redacted field names.
|
|
40
|
+
*/
|
|
41
|
+
export function redactSecrets(input) {
|
|
42
|
+
let output = sanitizeInput(input);
|
|
43
|
+
const redacted = [];
|
|
44
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
45
|
+
// Reset lastIndex for global regexes
|
|
46
|
+
pattern.lastIndex = 0;
|
|
47
|
+
if (pattern.test(output)) {
|
|
48
|
+
pattern.lastIndex = 0;
|
|
49
|
+
output = output.replace(pattern, '[REDACTED]');
|
|
50
|
+
redacted.push(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { output, redacted };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Post-execution middleware: scans tool output for secret patterns and redacts them.
|
|
57
|
+
*
|
|
58
|
+
* SECURITY: For non-string results, redaction operates on individual string values
|
|
59
|
+
* within the object structure rather than JSON.stringify→replace→JSON.parse, which
|
|
60
|
+
* could corrupt the result if a replacement changes JSON structure.
|
|
61
|
+
*/
|
|
62
|
+
export const redactMiddleware = async (ctx, next) => {
|
|
63
|
+
// SECURITY: Pre-execution — scan arguments for secrets before they reach the downstream tool.
|
|
64
|
+
if (ctx.arguments) {
|
|
65
|
+
const argRedacted = [];
|
|
66
|
+
redactDeep(ctx.arguments, argRedacted);
|
|
67
|
+
if (argRedacted.length > 0) {
|
|
68
|
+
ctx.redacted_fields = [...new Set(argRedacted)];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await next();
|
|
72
|
+
if (ctx.result == null)
|
|
73
|
+
return;
|
|
74
|
+
if (typeof ctx.result === 'string') {
|
|
75
|
+
const { output, redacted } = redactSecrets(ctx.result);
|
|
76
|
+
if (redacted.length > 0) {
|
|
77
|
+
ctx.result = output;
|
|
78
|
+
ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...redacted])];
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// For objects, deeply redact all string values in-place
|
|
83
|
+
const allRedacted = [];
|
|
84
|
+
redactDeep(ctx.result, allRedacted);
|
|
85
|
+
if (allRedacted.length > 0) {
|
|
86
|
+
ctx.redacted_fields = [...new Set([...(ctx.redacted_fields ?? []), ...allRedacted])];
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Recursively walk an object/array and redact string values in-place.
|
|
91
|
+
* Uses a WeakSet to guard against circular references.
|
|
92
|
+
*/
|
|
93
|
+
function redactDeep(obj, redacted, seen = new WeakSet()) {
|
|
94
|
+
if (obj == null || typeof obj !== 'object')
|
|
95
|
+
return;
|
|
96
|
+
// Guard against circular references
|
|
97
|
+
if (seen.has(obj))
|
|
98
|
+
return;
|
|
99
|
+
seen.add(obj);
|
|
100
|
+
if (Array.isArray(obj)) {
|
|
101
|
+
for (let i = 0; i < obj.length; i++) {
|
|
102
|
+
if (typeof obj[i] === 'string') {
|
|
103
|
+
const { output, redacted: r } = redactSecrets(obj[i]);
|
|
104
|
+
if (r.length > 0) {
|
|
105
|
+
obj[i] = output;
|
|
106
|
+
redacted.push(...r);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
redactDeep(obj[i], redacted, seen);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const record = obj;
|
|
116
|
+
for (const key of Object.keys(record)) {
|
|
117
|
+
if (typeof record[key] === 'string') {
|
|
118
|
+
const { output, redacted: r } = redactSecrets(record[key]);
|
|
119
|
+
if (r.length > 0) {
|
|
120
|
+
record[key] = output;
|
|
121
|
+
redacted.push(...r);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
redactDeep(record[key], redacted, seen);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GatewayConfig } from '../../config/types.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* PostToolUse middleware: truncates tool results that exceed a configurable size cap.
|
|
5
|
+
*
|
|
6
|
+
* Operates on the serialized form of the result — if the downstream tool returns
|
|
7
|
+
* a large object or binary-encoded string, we measure and truncate the serialized
|
|
8
|
+
* representation. A human-readable notice is appended so the agent knows the
|
|
9
|
+
* result was cut.
|
|
10
|
+
*
|
|
11
|
+
* Default cap: 512 KB. Override via `gateway.max_result_size_kb` in gateway.yaml.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createResultSizeCapMiddleware(gatewayConfig?: GatewayConfig): Middleware;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const DEFAULT_CAP_KB = 512;
|
|
2
|
+
/**
|
|
3
|
+
* Serialize any result to a string for size measurement.
|
|
4
|
+
* If it's already a string, use it directly; otherwise JSON.stringify.
|
|
5
|
+
*/
|
|
6
|
+
function resultToString(value) {
|
|
7
|
+
if (typeof value === 'string')
|
|
8
|
+
return value;
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* PostToolUse middleware: truncates tool results that exceed a configurable size cap.
|
|
18
|
+
*
|
|
19
|
+
* Operates on the serialized form of the result — if the downstream tool returns
|
|
20
|
+
* a large object or binary-encoded string, we measure and truncate the serialized
|
|
21
|
+
* representation. A human-readable notice is appended so the agent knows the
|
|
22
|
+
* result was cut.
|
|
23
|
+
*
|
|
24
|
+
* Default cap: 512 KB. Override via `gateway.max_result_size_kb` in gateway.yaml.
|
|
25
|
+
*/
|
|
26
|
+
export function createResultSizeCapMiddleware(gatewayConfig) {
|
|
27
|
+
const capKb = gatewayConfig?.gateway?.max_result_size_kb ?? DEFAULT_CAP_KB;
|
|
28
|
+
const capBytes = capKb * 1024;
|
|
29
|
+
return async (ctx, next) => {
|
|
30
|
+
await next();
|
|
31
|
+
if (ctx.result == null)
|
|
32
|
+
return;
|
|
33
|
+
const serialized = resultToString(ctx.result);
|
|
34
|
+
const byteLength = Buffer.byteLength(serialized, 'utf8');
|
|
35
|
+
if (byteLength <= capBytes)
|
|
36
|
+
return;
|
|
37
|
+
const removedBytes = byteLength - capBytes;
|
|
38
|
+
const removedKb = Math.ceil(removedBytes / 1024);
|
|
39
|
+
const notice = `\n[TRUNCATED: result exceeded ${capKb}KB limit. ${removedKb}KB removed.]`;
|
|
40
|
+
// Truncate to cap, then append notice. We truncate the serialized string and
|
|
41
|
+
// store it back — the agent sees the notice and knows context was lost.
|
|
42
|
+
const noticeBytes = Buffer.byteLength(notice, 'utf8');
|
|
43
|
+
const keepBytes = capBytes - noticeBytes;
|
|
44
|
+
const truncated = Buffer.from(serialized, 'utf8').subarray(0, keepBytes).toString('utf8') + notice;
|
|
45
|
+
ctx.result = truncated;
|
|
46
|
+
console.error(`[rea] result-size-cap: truncated tool "${ctx.tool_name}" result from ${Math.ceil(byteLength / 1024)}KB to ${capKb}KB`);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Middleware } from './chain.js';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a session middleware instance with its own session ID.
|
|
4
|
+
* Each gateway instance gets its own session — no module-level singletons.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createSessionMiddleware(): Middleware;
|
|
7
|
+
/**
|
|
8
|
+
* Utility to get the session ID from a session middleware instance.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getSessionId(middleware: Middleware): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a session middleware instance with its own session ID.
|
|
3
|
+
* Each gateway instance gets its own session — no module-level singletons.
|
|
4
|
+
*/
|
|
5
|
+
export function createSessionMiddleware() {
|
|
6
|
+
const sessionId = crypto.randomUUID();
|
|
7
|
+
const middleware = Object.assign(async (ctx, next) => {
|
|
8
|
+
ctx.session_id = sessionId;
|
|
9
|
+
await next();
|
|
10
|
+
}, { sessionId });
|
|
11
|
+
return middleware;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Utility to get the session ID from a session middleware instance.
|
|
15
|
+
*/
|
|
16
|
+
export function getSessionId(middleware) {
|
|
17
|
+
return middleware.sessionId;
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GatewayConfig } from '../../config/types.js';
|
|
2
|
+
import type { Middleware } from './chain.js';
|
|
3
|
+
/**
|
|
4
|
+
* Classifies the tool's tier and attaches it to the context.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createTierMiddleware(gatewayConfig?: GatewayConfig): Middleware;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { classifyTool } from '../../config/tier-map.js';
|
|
2
|
+
/**
|
|
3
|
+
* Classifies the tool's tier and attaches it to the context.
|
|
4
|
+
*/
|
|
5
|
+
export function createTierMiddleware(gatewayConfig) {
|
|
6
|
+
return async (ctx, next) => {
|
|
7
|
+
ctx.tier = classifyTool(ctx.tool_name, ctx.server_name, gatewayConfig);
|
|
8
|
+
await next();
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GatewayConfig } from '../config/types.js';
|
|
2
|
+
export interface LimitExceededError {
|
|
3
|
+
type: 'concurrency' | 'rate';
|
|
4
|
+
serverName: string;
|
|
5
|
+
current: number;
|
|
6
|
+
limit: number;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
interface ServerState {
|
|
10
|
+
activeCalls: number;
|
|
11
|
+
callTimestamps: number[];
|
|
12
|
+
maxConcurrent: number;
|
|
13
|
+
callsPerMinute: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* In-memory per-server rate limiter and concurrency cap.
|
|
17
|
+
*
|
|
18
|
+
* Concurrency: tracks active in-flight calls. If at the limit, new calls
|
|
19
|
+
* are rejected immediately with a structured error.
|
|
20
|
+
*
|
|
21
|
+
* Rate: sliding window — calls in the last 60 seconds must not exceed the
|
|
22
|
+
* configured calls_per_minute. 0 means unlimited for either dimension.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RateLimiter {
|
|
25
|
+
private state;
|
|
26
|
+
constructor(gatewayConfig?: GatewayConfig);
|
|
27
|
+
/**
|
|
28
|
+
* Try to acquire a slot for a call to `serverName`.
|
|
29
|
+
* Returns null on success, or a LimitExceededError if rejected.
|
|
30
|
+
*/
|
|
31
|
+
tryAcquire(serverName: string): LimitExceededError | null;
|
|
32
|
+
/** Release a previously acquired concurrency slot. No-op for unknown servers. */
|
|
33
|
+
release(serverName: string): void;
|
|
34
|
+
getState(serverName: string): ServerState | undefined;
|
|
35
|
+
}
|
|
36
|
+
export {};
|