@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +339 -0
  3. package/SECURITY.md +104 -0
  4. package/THREAT_MODEL.md +245 -0
  5. package/agents/accessibility-engineer.md +101 -0
  6. package/agents/backend-engineer.md +126 -0
  7. package/agents/code-reviewer.md +144 -0
  8. package/agents/codex-adversarial.md +107 -0
  9. package/agents/frontend-specialist.md +84 -0
  10. package/agents/qa-engineer.md +138 -0
  11. package/agents/rea-orchestrator.md +101 -0
  12. package/agents/security-engineer.md +108 -0
  13. package/agents/technical-writer.md +140 -0
  14. package/agents/typescript-specialist.md +111 -0
  15. package/commands/codex-review.md +104 -0
  16. package/commands/freeze.md +81 -0
  17. package/commands/halt-check.md +120 -0
  18. package/commands/rea.md +52 -0
  19. package/commands/review.md +79 -0
  20. package/dist/cli/check.d.ts +1 -0
  21. package/dist/cli/check.js +66 -0
  22. package/dist/cli/doctor.d.ts +1 -0
  23. package/dist/cli/doctor.js +93 -0
  24. package/dist/cli/freeze.d.ts +8 -0
  25. package/dist/cli/freeze.js +61 -0
  26. package/dist/cli/index.d.ts +2 -0
  27. package/dist/cli/index.js +65 -0
  28. package/dist/cli/init.d.ts +6 -0
  29. package/dist/cli/init.js +237 -0
  30. package/dist/cli/serve.d.ts +1 -0
  31. package/dist/cli/serve.js +19 -0
  32. package/dist/cli/utils.d.ts +23 -0
  33. package/dist/cli/utils.js +51 -0
  34. package/dist/config/tier-map.d.ts +11 -0
  35. package/dist/config/tier-map.js +108 -0
  36. package/dist/config/types.d.ts +24 -0
  37. package/dist/config/types.js +1 -0
  38. package/dist/gateway/circuit-breaker.d.ts +43 -0
  39. package/dist/gateway/circuit-breaker.js +86 -0
  40. package/dist/gateway/middleware/audit-types.d.ts +16 -0
  41. package/dist/gateway/middleware/audit-types.js +1 -0
  42. package/dist/gateway/middleware/audit.d.ts +12 -0
  43. package/dist/gateway/middleware/audit.js +98 -0
  44. package/dist/gateway/middleware/blocked-paths.d.ts +12 -0
  45. package/dist/gateway/middleware/blocked-paths.js +117 -0
  46. package/dist/gateway/middleware/chain.d.ts +28 -0
  47. package/dist/gateway/middleware/chain.js +40 -0
  48. package/dist/gateway/middleware/circuit-breaker.d.ts +11 -0
  49. package/dist/gateway/middleware/circuit-breaker.js +43 -0
  50. package/dist/gateway/middleware/injection.d.ts +22 -0
  51. package/dist/gateway/middleware/injection.js +128 -0
  52. package/dist/gateway/middleware/kill-switch.d.ts +10 -0
  53. package/dist/gateway/middleware/kill-switch.js +58 -0
  54. package/dist/gateway/middleware/policy.d.ts +12 -0
  55. package/dist/gateway/middleware/policy.js +70 -0
  56. package/dist/gateway/middleware/rate-limit.d.ts +12 -0
  57. package/dist/gateway/middleware/rate-limit.js +31 -0
  58. package/dist/gateway/middleware/redact.d.ts +16 -0
  59. package/dist/gateway/middleware/redact.js +128 -0
  60. package/dist/gateway/middleware/result-size-cap.d.ts +13 -0
  61. package/dist/gateway/middleware/result-size-cap.js +48 -0
  62. package/dist/gateway/middleware/session.d.ts +10 -0
  63. package/dist/gateway/middleware/session.js +18 -0
  64. package/dist/gateway/middleware/tier.d.ts +6 -0
  65. package/dist/gateway/middleware/tier.js +10 -0
  66. package/dist/gateway/rate-limiter.d.ts +36 -0
  67. package/dist/gateway/rate-limiter.js +75 -0
  68. package/dist/index.d.ts +3 -0
  69. package/dist/index.js +2 -0
  70. package/dist/policy/loader.d.ts +80 -0
  71. package/dist/policy/loader.js +146 -0
  72. package/dist/policy/types.d.ts +34 -0
  73. package/dist/policy/types.js +19 -0
  74. package/hooks/_lib/common.sh +105 -0
  75. package/hooks/_lib/halt-check.sh +39 -0
  76. package/hooks/_lib/policy-read.sh +79 -0
  77. package/hooks/architecture-review-gate.sh +84 -0
  78. package/hooks/attribution-advisory.sh +126 -0
  79. package/hooks/blocked-paths-enforcer.sh +176 -0
  80. package/hooks/changeset-security-gate.sh +143 -0
  81. package/hooks/commit-review-gate.sh +166 -0
  82. package/hooks/dangerous-bash-interceptor.sh +362 -0
  83. package/hooks/dependency-audit-gate.sh +118 -0
  84. package/hooks/env-file-protection.sh +110 -0
  85. package/hooks/pr-issue-link-gate.sh +65 -0
  86. package/hooks/push-review-gate.sh +120 -0
  87. package/hooks/secret-scanner.sh +229 -0
  88. package/hooks/security-disclosure-gate.sh +146 -0
  89. package/hooks/settings-protection.sh +147 -0
  90. package/package.json +93 -0
@@ -0,0 +1,75 @@
1
+ const WINDOW_MS = 60_000;
2
+ /**
3
+ * In-memory per-server rate limiter and concurrency cap.
4
+ *
5
+ * Concurrency: tracks active in-flight calls. If at the limit, new calls
6
+ * are rejected immediately with a structured error.
7
+ *
8
+ * Rate: sliding window — calls in the last 60 seconds must not exceed the
9
+ * configured calls_per_minute. 0 means unlimited for either dimension.
10
+ */
11
+ export class RateLimiter {
12
+ state = new Map();
13
+ constructor(gatewayConfig) {
14
+ if (!gatewayConfig)
15
+ return;
16
+ for (const [name, serverCfg] of Object.entries(gatewayConfig.servers)) {
17
+ this.state.set(name, {
18
+ activeCalls: 0,
19
+ callTimestamps: [],
20
+ maxConcurrent: serverCfg.max_concurrent_calls ?? 0,
21
+ callsPerMinute: serverCfg.calls_per_minute ?? 0,
22
+ });
23
+ }
24
+ }
25
+ /**
26
+ * Try to acquire a slot for a call to `serverName`.
27
+ * Returns null on success, or a LimitExceededError if rejected.
28
+ */
29
+ tryAcquire(serverName) {
30
+ let s = this.state.get(serverName);
31
+ if (!s) {
32
+ s = {
33
+ activeCalls: 0,
34
+ callTimestamps: [],
35
+ maxConcurrent: 0,
36
+ callsPerMinute: 0,
37
+ };
38
+ this.state.set(serverName, s);
39
+ }
40
+ const now = Date.now();
41
+ s.callTimestamps = s.callTimestamps.filter((t) => now - t < WINDOW_MS);
42
+ if (s.callsPerMinute > 0 && s.callTimestamps.length >= s.callsPerMinute) {
43
+ return {
44
+ type: 'rate',
45
+ serverName,
46
+ current: s.callTimestamps.length,
47
+ limit: s.callsPerMinute,
48
+ message: `Rate limit exceeded for server "${serverName}": ${s.callTimestamps.length}/${s.callsPerMinute} calls in the last 60s`,
49
+ };
50
+ }
51
+ if (s.maxConcurrent > 0 && s.activeCalls >= s.maxConcurrent) {
52
+ return {
53
+ type: 'concurrency',
54
+ serverName,
55
+ current: s.activeCalls,
56
+ limit: s.maxConcurrent,
57
+ message: `Concurrency limit exceeded for server "${serverName}": ${s.activeCalls}/${s.maxConcurrent} active calls`,
58
+ };
59
+ }
60
+ s.activeCalls++;
61
+ s.callTimestamps.push(now);
62
+ return null;
63
+ }
64
+ /** Release a previously acquired concurrency slot. No-op for unknown servers. */
65
+ release(serverName) {
66
+ const s = this.state.get(serverName);
67
+ if (!s)
68
+ return;
69
+ if (s.activeCalls > 0)
70
+ s.activeCalls--;
71
+ }
72
+ getState(serverName) {
73
+ return this.state.get(serverName);
74
+ }
75
+ }
@@ -0,0 +1,3 @@
1
+ export { loadPolicy } from './policy/loader.js';
2
+ export type { Policy, AutonomyLevel } from './policy/types.js';
3
+ export { createMiddlewareChain } from './gateway/middleware/chain.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { loadPolicy } from './policy/loader.js';
2
+ export { createMiddlewareChain } from './gateway/middleware/chain.js';
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { AutonomyLevel } from './types.js';
3
+ import type { Policy } from './types.js';
4
+ declare const PolicySchema: z.ZodObject<{
5
+ version: z.ZodString;
6
+ profile: z.ZodString;
7
+ installed_by: z.ZodString;
8
+ installed_at: z.ZodString;
9
+ autonomy_level: z.ZodNativeEnum<typeof AutonomyLevel>;
10
+ max_autonomy_level: z.ZodNativeEnum<typeof AutonomyLevel>;
11
+ promotion_requires_human_approval: z.ZodBoolean;
12
+ block_ai_attribution: z.ZodDefault<z.ZodBoolean>;
13
+ blocked_paths: z.ZodArray<z.ZodString, "many">;
14
+ notification_channel: z.ZodDefault<z.ZodString>;
15
+ injection_detection: z.ZodOptional<z.ZodEnum<["block", "warn"]>>;
16
+ context_protection: z.ZodOptional<z.ZodObject<{
17
+ delegate_to_subagent: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
18
+ max_bash_output_lines: z.ZodOptional<z.ZodNumber>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ delegate_to_subagent: string[];
21
+ max_bash_output_lines?: number | undefined;
22
+ }, {
23
+ delegate_to_subagent?: string[] | undefined;
24
+ max_bash_output_lines?: number | undefined;
25
+ }>>;
26
+ }, "strict", z.ZodTypeAny, {
27
+ version: string;
28
+ profile: string;
29
+ installed_by: string;
30
+ installed_at: string;
31
+ autonomy_level: AutonomyLevel;
32
+ max_autonomy_level: AutonomyLevel;
33
+ promotion_requires_human_approval: boolean;
34
+ block_ai_attribution: boolean;
35
+ blocked_paths: string[];
36
+ notification_channel: string;
37
+ injection_detection?: "block" | "warn" | undefined;
38
+ context_protection?: {
39
+ delegate_to_subagent: string[];
40
+ max_bash_output_lines?: number | undefined;
41
+ } | undefined;
42
+ }, {
43
+ version: string;
44
+ profile: string;
45
+ installed_by: string;
46
+ installed_at: string;
47
+ autonomy_level: AutonomyLevel;
48
+ max_autonomy_level: AutonomyLevel;
49
+ promotion_requires_human_approval: boolean;
50
+ blocked_paths: string[];
51
+ block_ai_attribution?: boolean | undefined;
52
+ notification_channel?: string | undefined;
53
+ injection_detection?: "block" | "warn" | undefined;
54
+ context_protection?: {
55
+ delegate_to_subagent?: string[] | undefined;
56
+ max_bash_output_lines?: number | undefined;
57
+ } | undefined;
58
+ }>;
59
+ /**
60
+ * Async policy loader with TTL cache and mtime-based invalidation.
61
+ *
62
+ * TTL is configurable via REA_POLICY_CACHE_TTL_MS.
63
+ *
64
+ * SECURITY: mtime invalidation ensures a tightened policy takes effect on the next call.
65
+ * CONCURRENCY: inflightReads map guarantees at most one disk read per baseDir at a time.
66
+ */
67
+ export declare function loadPolicyAsync(baseDir: string): Promise<Policy>;
68
+ /**
69
+ * Synchronous policy loader — for CLI startup paths that must be sync.
70
+ * Does NOT use the cache — always reads from disk.
71
+ *
72
+ * Prefer loadPolicyAsync for middleware and any async context.
73
+ */
74
+ export declare function loadPolicy(baseDir: string): Policy;
75
+ /**
76
+ * Invalidate the cache for a given baseDir.
77
+ * Exposed for testing — production code relies on TTL and mtime invalidation.
78
+ */
79
+ export declare function invalidatePolicyCache(baseDir?: string): void;
80
+ export { PolicySchema };
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs';
2
+ import fsPromises from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { z } from 'zod';
6
+ import { AutonomyLevel } from './types.js';
7
+ const LEVEL_ORDER = {
8
+ [AutonomyLevel.L0]: 0,
9
+ [AutonomyLevel.L1]: 1,
10
+ [AutonomyLevel.L2]: 2,
11
+ [AutonomyLevel.L3]: 3,
12
+ };
13
+ const ContextProtectionSchema = z.object({
14
+ delegate_to_subagent: z.array(z.string()).default([]),
15
+ max_bash_output_lines: z.number().int().positive().optional(),
16
+ });
17
+ const PolicySchema = z
18
+ .object({
19
+ version: z.string(),
20
+ profile: z.string(),
21
+ installed_by: z.string(),
22
+ installed_at: z.string(),
23
+ autonomy_level: z.nativeEnum(AutonomyLevel),
24
+ max_autonomy_level: z.nativeEnum(AutonomyLevel),
25
+ promotion_requires_human_approval: z.boolean(),
26
+ block_ai_attribution: z.boolean().default(false),
27
+ blocked_paths: z.array(z.string()),
28
+ notification_channel: z.string().default(''),
29
+ injection_detection: z.enum(['block', 'warn']).optional(),
30
+ context_protection: ContextProtectionSchema.optional(),
31
+ })
32
+ .strict();
33
+ const DEFAULT_CACHE_TTL_MS = 30_000;
34
+ const POLICY_DIR = '.rea';
35
+ const POLICY_FILE = 'policy.yaml';
36
+ /**
37
+ * SECURITY: Cache never serves a more permissive policy than disk.
38
+ * mtime invalidation ensures policy tightening takes effect before TTL expires.
39
+ */
40
+ const policyCache = new Map();
41
+ const inflightReads = new Map();
42
+ /**
43
+ * Convert `{ key: undefined }` to omitted keys so Policy satisfies
44
+ * exactOptionalPropertyTypes. Zod defaults produce explicit undefined.
45
+ */
46
+ function stripUndefined(input) {
47
+ const result = {};
48
+ for (const [k, v] of Object.entries(input)) {
49
+ if (v !== undefined)
50
+ result[k] = v;
51
+ }
52
+ return result;
53
+ }
54
+ function applyMaxCeiling(policy) {
55
+ if (LEVEL_ORDER[policy.autonomy_level] > LEVEL_ORDER[policy.max_autonomy_level]) {
56
+ console.error(`[rea] WARNING: autonomy_level ${policy.autonomy_level} exceeds max_autonomy_level ${policy.max_autonomy_level} — clamping to ${policy.max_autonomy_level}`);
57
+ return { ...policy, autonomy_level: policy.max_autonomy_level };
58
+ }
59
+ return policy;
60
+ }
61
+ function parseRawPolicy(raw, policyPath) {
62
+ let parsed;
63
+ try {
64
+ parsed = parseYaml(raw);
65
+ }
66
+ catch (yamlErr) {
67
+ throw new Error(`Failed to parse policy YAML at ${policyPath}: ${yamlErr instanceof Error ? yamlErr.message : yamlErr}`);
68
+ }
69
+ let parsedPolicy;
70
+ try {
71
+ parsedPolicy = PolicySchema.parse(parsed);
72
+ }
73
+ catch (zodErr) {
74
+ throw new Error(`Invalid policy schema at ${policyPath}: ${zodErr instanceof Error ? zodErr.message : zodErr}`);
75
+ }
76
+ return applyMaxCeiling(stripUndefined(parsedPolicy));
77
+ }
78
+ function policyPathFor(baseDir) {
79
+ return path.join(baseDir, POLICY_DIR, POLICY_FILE);
80
+ }
81
+ async function readPolicyFromDisk(baseDir, policyPath, currentMtime) {
82
+ const raw = await fsPromises.readFile(policyPath, 'utf8');
83
+ const policy = parseRawPolicy(raw, policyPath);
84
+ policyCache.set(baseDir, { policy, cachedAt: Date.now(), mtimeMs: currentMtime });
85
+ return policy;
86
+ }
87
+ /**
88
+ * Async policy loader with TTL cache and mtime-based invalidation.
89
+ *
90
+ * TTL is configurable via REA_POLICY_CACHE_TTL_MS.
91
+ *
92
+ * SECURITY: mtime invalidation ensures a tightened policy takes effect on the next call.
93
+ * CONCURRENCY: inflightReads map guarantees at most one disk read per baseDir at a time.
94
+ */
95
+ export async function loadPolicyAsync(baseDir) {
96
+ const policyPath = policyPathFor(baseDir);
97
+ const ttlMs = Number(process.env.REA_POLICY_CACHE_TTL_MS ?? DEFAULT_CACHE_TTL_MS);
98
+ const now = Date.now();
99
+ let currentMtime;
100
+ try {
101
+ const stat = await fsPromises.stat(policyPath);
102
+ currentMtime = stat.mtimeMs;
103
+ }
104
+ catch {
105
+ throw new Error(`Policy file not found: ${policyPath}`);
106
+ }
107
+ const cached = policyCache.get(baseDir);
108
+ if (cached !== undefined && cached.mtimeMs === currentMtime && now - cached.cachedAt < ttlMs) {
109
+ return cached.policy;
110
+ }
111
+ const inflight = inflightReads.get(baseDir);
112
+ if (inflight)
113
+ return inflight;
114
+ const read = readPolicyFromDisk(baseDir, policyPath, currentMtime).finally(() => {
115
+ inflightReads.delete(baseDir);
116
+ });
117
+ inflightReads.set(baseDir, read);
118
+ return read;
119
+ }
120
+ /**
121
+ * Synchronous policy loader — for CLI startup paths that must be sync.
122
+ * Does NOT use the cache — always reads from disk.
123
+ *
124
+ * Prefer loadPolicyAsync for middleware and any async context.
125
+ */
126
+ export function loadPolicy(baseDir) {
127
+ const policyPath = policyPathFor(baseDir);
128
+ if (!fs.existsSync(policyPath)) {
129
+ throw new Error(`Policy file not found: ${policyPath}`);
130
+ }
131
+ const raw = fs.readFileSync(policyPath, 'utf8');
132
+ return parseRawPolicy(raw, policyPath);
133
+ }
134
+ /**
135
+ * Invalidate the cache for a given baseDir.
136
+ * Exposed for testing — production code relies on TTL and mtime invalidation.
137
+ */
138
+ export function invalidatePolicyCache(baseDir) {
139
+ if (baseDir === undefined) {
140
+ policyCache.clear();
141
+ }
142
+ else {
143
+ policyCache.delete(baseDir);
144
+ }
145
+ }
146
+ export { PolicySchema };
@@ -0,0 +1,34 @@
1
+ export declare enum Tier {
2
+ Read = "read",
3
+ Write = "write",
4
+ Destructive = "destructive"
5
+ }
6
+ export declare enum AutonomyLevel {
7
+ L0 = "L0",
8
+ L1 = "L1",
9
+ L2 = "L2",
10
+ L3 = "L3"
11
+ }
12
+ export declare enum InvocationStatus {
13
+ Allowed = "allowed",
14
+ Denied = "denied",
15
+ Error = "error"
16
+ }
17
+ export interface ContextProtection {
18
+ delegate_to_subagent: string[];
19
+ max_bash_output_lines?: number;
20
+ }
21
+ export interface Policy {
22
+ version: string;
23
+ profile: string;
24
+ installed_by: string;
25
+ installed_at: string;
26
+ autonomy_level: AutonomyLevel;
27
+ max_autonomy_level: AutonomyLevel;
28
+ promotion_requires_human_approval: boolean;
29
+ block_ai_attribution: boolean;
30
+ blocked_paths: string[];
31
+ notification_channel: string;
32
+ injection_detection?: 'block' | 'warn';
33
+ context_protection?: ContextProtection;
34
+ }
@@ -0,0 +1,19 @@
1
+ export var Tier;
2
+ (function (Tier) {
3
+ Tier["Read"] = "read";
4
+ Tier["Write"] = "write";
5
+ Tier["Destructive"] = "destructive";
6
+ })(Tier || (Tier = {}));
7
+ export var AutonomyLevel;
8
+ (function (AutonomyLevel) {
9
+ AutonomyLevel["L0"] = "L0";
10
+ AutonomyLevel["L1"] = "L1";
11
+ AutonomyLevel["L2"] = "L2";
12
+ AutonomyLevel["L3"] = "L3";
13
+ })(AutonomyLevel || (AutonomyLevel = {}));
14
+ export var InvocationStatus;
15
+ (function (InvocationStatus) {
16
+ InvocationStatus["Allowed"] = "allowed";
17
+ InvocationStatus["Denied"] = "denied";
18
+ InvocationStatus["Error"] = "error";
19
+ })(InvocationStatus || (InvocationStatus = {}));
@@ -0,0 +1,105 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/common.sh — shared utilities for rea hooks
3
+ # Source via: source "$(dirname "$0")/_lib/common.sh"
4
+
5
+ # Find the .rea/ directory by walking up from CLAUDE_PROJECT_DIR or cwd
6
+ rea_root() {
7
+ local dir="${CLAUDE_PROJECT_DIR:-$(pwd)}"
8
+ while [[ "$dir" != "/" ]]; do
9
+ if [[ -d "$dir/.rea" ]]; then
10
+ printf '%s' "$dir"
11
+ return 0
12
+ fi
13
+ dir=$(dirname "$dir")
14
+ done
15
+ # Fallback to CLAUDE_PROJECT_DIR or cwd
16
+ printf '%s' "${CLAUDE_PROJECT_DIR:-$(pwd)}"
17
+ }
18
+
19
+ # Exit with code 2 if .rea/HALT exists
20
+ check_halt() {
21
+ local root
22
+ root=$(rea_root)
23
+ local halt_file="${root}/.rea/HALT"
24
+ if [ -f "$halt_file" ]; then
25
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
26
+ "$(head -c 1024 "$halt_file" 2>/dev/null || echo 'Reason unknown')" >&2
27
+ exit 2
28
+ fi
29
+ }
30
+
31
+ # Verify jq is available, exit 2 if not
32
+ require_jq() {
33
+ if ! command -v jq >/dev/null 2>&1; then
34
+ printf 'REA ERROR: jq is required but not installed.\n' >&2
35
+ printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
36
+ exit 2
37
+ fi
38
+ }
39
+
40
+ # Build a structured JSON response for hook output
41
+ # Usage: json_output "status" "message" ["decision"]
42
+ # status: "block" | "allow" | "advisory"
43
+ # message: human-readable description
44
+ # decision: optional additionalContext for the agent
45
+ json_output() {
46
+ local status="$1"
47
+ local message="$2"
48
+ local decision="${3:-}"
49
+
50
+ if [[ "$status" == "block" ]]; then
51
+ printf '%s\n' "$message" >&2
52
+ if [[ -n "$decision" ]]; then
53
+ printf '%s\n' "$decision" >&2
54
+ fi
55
+ exit 2
56
+ elif [[ "$status" == "advisory" ]]; then
57
+ printf '%s\n' "$message" >&2
58
+ exit 0
59
+ else
60
+ exit 0
61
+ fi
62
+ }
63
+
64
+ # Exit 0 (skip) if the project's tech_profile does not match the expected type.
65
+ # Usage: check_project_type "lit-wc"
66
+ # Reads tech_profile from .rea/policy.yaml; if absent or mismatched, exits 0.
67
+ check_project_type() {
68
+ local expected_type="$1"
69
+ local root
70
+ root=$(rea_root)
71
+ local policy="${root}/.rea/policy.yaml"
72
+ if [[ ! -f "$policy" ]]; then
73
+ exit 0
74
+ fi
75
+ local actual_type
76
+ actual_type=$(grep -E '^tech_profile:' "$policy" 2>/dev/null | sed 's/^tech_profile:[[:space:]]*//' | tr -d '"' || echo "")
77
+ if [[ -z "$actual_type" || "$actual_type" != "$expected_type" ]]; then
78
+ exit 0
79
+ fi
80
+ }
81
+
82
+ # Score a diff for triage purposes
83
+ # Reads from stdin (expects unified diff output)
84
+ # Returns: "trivial" (<20 lines), "standard" (20-200), "significant" (>200)
85
+ # Also checks for sensitive paths — upgrades to "significant" if found
86
+ triage_score() {
87
+ local diff_input
88
+ diff_input=$(cat)
89
+ local line_count
90
+ line_count=$(printf '%s' "$diff_input" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
91
+
92
+ # Check for sensitive paths
93
+ local sensitive=0
94
+ if printf '%s' "$diff_input" | grep -qE '^\+\+\+ .*(\.rea/|\.claude/|\.env|auth|security|\.github/workflows)'; then
95
+ sensitive=1
96
+ fi
97
+
98
+ if [[ $sensitive -eq 1 ]] || [[ $line_count -gt 200 ]]; then
99
+ printf 'significant'
100
+ elif [[ $line_count -ge 20 ]]; then
101
+ printf 'standard'
102
+ else
103
+ printf 'trivial'
104
+ fi
105
+ }
@@ -0,0 +1,39 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/halt-check.sh — HALT gate helper for rea hooks
3
+ # Source via: source "$(dirname "$0")/_lib/halt-check.sh"
4
+ #
5
+ # Every hook that can block a tool call must gate on .rea/HALT. When the file
6
+ # exists, all agent operations are suspended — the hook must deny the tool
7
+ # call with a clear error that surfaces the file's contents so the operator
8
+ # knows why the system was frozen.
9
+
10
+ set -euo pipefail
11
+
12
+ # Find the .rea/ directory by walking up from CLAUDE_PROJECT_DIR or cwd.
13
+ # Falls back to CLAUDE_PROJECT_DIR or the current working directory when no
14
+ # .rea/ ancestor is found (which is fine — check_halt will simply no-op).
15
+ rea_root() {
16
+ local dir="${CLAUDE_PROJECT_DIR:-$(pwd)}"
17
+ while [[ "$dir" != "/" ]]; do
18
+ if [[ -d "$dir/.rea" ]]; then
19
+ printf '%s' "$dir"
20
+ return 0
21
+ fi
22
+ dir=$(dirname "$dir")
23
+ done
24
+ printf '%s' "${CLAUDE_PROJECT_DIR:-$(pwd)}"
25
+ }
26
+
27
+ # Exit with code 2 (deny) if .rea/HALT exists.
28
+ # Prints the first 1024 bytes of HALT to stderr so the operator sees the
29
+ # reason the system was frozen. Safe to call from any hook.
30
+ check_halt() {
31
+ local root
32
+ root=$(rea_root)
33
+ local halt_file="${root}/.rea/HALT"
34
+ if [ -f "$halt_file" ]; then
35
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
36
+ "$(head -c 1024 "$halt_file" 2>/dev/null || echo 'Reason unknown')" >&2
37
+ exit 2
38
+ fi
39
+ }
@@ -0,0 +1,79 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/policy-read.sh — policy.yaml read helpers for rea hooks
3
+ # Source via: source "$(dirname "$0")/_lib/policy-read.sh"
4
+ #
5
+ # Minimal shell-only parsers for .rea/policy.yaml. Keeps hooks dependency-free
6
+ # (no yq required). Functions assume the caller has already resolved the
7
+ # project root via rea_root() from halt-check.sh, but will re-derive it if
8
+ # REA_ROOT is unset.
9
+
10
+ set -euo pipefail
11
+
12
+ # Resolve the path to .rea/policy.yaml for the current project.
13
+ # Prints an empty string if no policy file is found — callers should treat
14
+ # a missing policy as "default / advisory" rather than an error.
15
+ policy_path() {
16
+ local root="${REA_ROOT:-}"
17
+ if [[ -z "$root" ]]; then
18
+ if command -v rea_root >/dev/null 2>&1; then
19
+ root=$(rea_root)
20
+ else
21
+ root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
22
+ fi
23
+ fi
24
+ local policy="${root}/.rea/policy.yaml"
25
+ if [[ -f "$policy" ]]; then
26
+ printf '%s' "$policy"
27
+ fi
28
+ }
29
+
30
+ # Read a top-level scalar field from policy.yaml.
31
+ # Usage: policy_scalar "autonomy_level"
32
+ # Strips surrounding quotes. Prints empty string when unset or no policy.
33
+ policy_scalar() {
34
+ local key="$1"
35
+ local policy
36
+ policy=$(policy_path)
37
+ [[ -z "$policy" ]] && return 0
38
+ grep -E "^${key}:" "$policy" 2>/dev/null \
39
+ | head -n1 \
40
+ | sed -E "s/^${key}:[[:space:]]*//; s/^[\"']//; s/[\"']$//"
41
+ }
42
+
43
+ # Test whether a boolean-valued top-level field is true.
44
+ # Usage: policy_bool_true "block_ai_attribution" && ...
45
+ # Returns 0 when the value is literal "true", 1 otherwise (including missing).
46
+ policy_bool_true() {
47
+ local key="$1"
48
+ local value
49
+ value=$(policy_scalar "$key")
50
+ [[ "$value" == "true" ]]
51
+ }
52
+
53
+ # Read a list of scalars from a top-level sequence block.
54
+ # Usage: mapfile -t patterns < <(policy_list "delegate_to_subagent")
55
+ # Handles inline "[]" as empty. Stops at the first non-"-" continuation line.
56
+ policy_list() {
57
+ local key="$1"
58
+ local policy
59
+ policy=$(policy_path)
60
+ [[ -z "$policy" ]] && return 0
61
+ local in_block=0
62
+ while IFS= read -r line; do
63
+ if printf '%s' "$line" | grep -qE "^[[:space:]]*${key}:"; then
64
+ if printf '%s' "$line" | grep -qE "${key}:[[:space:]]*\[\]"; then
65
+ return 0
66
+ fi
67
+ in_block=1
68
+ continue
69
+ fi
70
+ if [[ $in_block -eq 1 ]]; then
71
+ if printf '%s' "$line" | grep -qE '^[[:space:]]*-[[:space:]]'; then
72
+ printf '%s' "$line" | sed -E "s/^[[:space:]]*-[[:space:]]*//; s/^[\"']//; s/[\"']$//"
73
+ printf '\n'
74
+ else
75
+ return 0
76
+ fi
77
+ fi
78
+ done < "$policy"
79
+ }
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # PostToolUse hook: architecture-review-gate.sh
3
+ # Fires AFTER every Write or Edit tool call.
4
+ # Lightweight advisory: flags when writing to architecture-sensitive paths.
5
+ # Does NOT block — only returns advisory context.
6
+ #
7
+ # Exit codes:
8
+ # 0 = always (advisory only, never blocks)
9
+
10
+ set -uo pipefail
11
+
12
+ # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
13
+ INPUT=$(cat)
14
+
15
+ # ── 2. Dependency check ──────────────────────────────────────────────────────
16
+ if ! command -v jq >/dev/null 2>&1; then
17
+ exit 0
18
+ fi
19
+
20
+ # ── 3. HALT check ────────────────────────────────────────────────────────────
21
+ REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
22
+ HALT_FILE="${REA_ROOT}/.rea/HALT"
23
+ if [ -f "$HALT_FILE" ]; then
24
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
25
+ "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
26
+ exit 2
27
+ fi
28
+
29
+ # ── 4. Check if enabled ──────────────────────────────────────────────────────
30
+ POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
31
+ if [[ -f "$POLICY_FILE" ]]; then
32
+ if grep -qE 'architecture_advisory:[[:space:]]*false' "$POLICY_FILE" 2>/dev/null; then
33
+ exit 0
34
+ fi
35
+ fi
36
+
37
+ # ── 5. Extract file path ─────────────────────────────────────────────────────
38
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
39
+
40
+ if [[ -z "$FILE_PATH" ]]; then
41
+ exit 0
42
+ fi
43
+
44
+ # Normalize to relative path
45
+ if [[ "$FILE_PATH" == "$REA_ROOT"/* ]]; then
46
+ FILE_PATH="${FILE_PATH#$REA_ROOT/}"
47
+ fi
48
+
49
+ # ── 6. Check architecture-sensitive paths ─────────────────────────────────────
50
+ ARCH_PATTERNS=(
51
+ 'src/types/'
52
+ 'src/gateway/'
53
+ 'src/config/'
54
+ 'src/cli/commands/init/'
55
+ 'hooks/_lib/'
56
+ 'templates/'
57
+ 'profiles/'
58
+ )
59
+
60
+ MATCHED=""
61
+ for pattern in "${ARCH_PATTERNS[@]}"; do
62
+ if [[ "$FILE_PATH" == "$pattern"* ]]; then
63
+ MATCHED="$pattern"
64
+ break
65
+ fi
66
+ done
67
+
68
+ if [[ -z "$MATCHED" ]]; then
69
+ exit 0
70
+ fi
71
+
72
+ # ── 7. Advisory output ───────────────────────────────────────────────────────
73
+ {
74
+ printf 'ARCHITECTURE ADVISORY: Sensitive path modified\n'
75
+ printf '\n'
76
+ printf ' File: %s\n' "$FILE_PATH"
77
+ printf ' Category: %s\n' "$MATCHED"
78
+ printf '\n'
79
+ printf ' This file is in an architecture-sensitive directory.\n'
80
+ printf ' Consider: Does this change maintain backward compatibility?\n'
81
+ printf ' Consider: Should this be reviewed by the principal-engineer agent?\n'
82
+ } >&2
83
+
84
+ exit 0