@bookedsolid/rea 0.21.1 → 0.23.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/README.md +15 -0
- package/THREAT_MODEL.md +582 -0
- package/dist/audit/append.js +1 -1
- package/dist/cli/doctor.js +11 -12
- package/dist/cli/hook.d.ts +37 -3
- package/dist/cli/hook.js +167 -5
- package/dist/cli/init.js +14 -26
- package/dist/cli/install/canonical.js +18 -3
- package/dist/cli/install/commit-msg.js +1 -2
- package/dist/cli/install/copy.js +4 -13
- package/dist/cli/install/fs-safe.js +5 -16
- package/dist/cli/install/gitignore.js +1 -5
- package/dist/cli/install/pre-push.js +3 -8
- package/dist/cli/install/settings-merge.js +79 -16
- package/dist/cli/upgrade.js +14 -10
- package/dist/gateway/downstream.js +1 -2
- package/dist/gateway/live-state.js +3 -1
- package/dist/gateway/log.js +1 -3
- package/dist/gateway/middleware/audit.js +1 -1
- package/dist/gateway/middleware/injection.js +3 -9
- package/dist/gateway/middleware/policy.js +3 -1
- package/dist/gateway/middleware/redact.js +1 -1
- package/dist/gateway/observability/codex-telemetry.js +1 -2
- package/dist/gateway/reviewers/claude-self.js +10 -6
- package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
- package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
- package/dist/hooks/bash-scanner/index.d.ts +41 -0
- package/dist/hooks/bash-scanner/index.js +62 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
- package/dist/hooks/bash-scanner/parser.d.ts +42 -0
- package/dist/hooks/bash-scanner/parser.js +92 -0
- package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
- package/dist/hooks/bash-scanner/protected-scan.js +815 -0
- package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
- package/dist/hooks/bash-scanner/verdict.js +49 -0
- package/dist/hooks/bash-scanner/walker.d.ts +165 -0
- package/dist/hooks/bash-scanner/walker.js +7954 -0
- package/dist/hooks/push-gate/base.js +2 -6
- package/dist/hooks/push-gate/codex-runner.js +3 -1
- package/dist/hooks/push-gate/index.js +9 -10
- package/dist/policy/loader.js +4 -1
- package/dist/registry/tofu-gate.js +2 -2
- package/hooks/_lib/cmd-segments.sh +32 -7
- package/hooks/_lib/interpreter-scanner.sh +71 -0
- package/hooks/_lib/path-normalize.sh +30 -3
- package/hooks/blocked-paths-bash-gate.sh +141 -277
- package/hooks/protected-paths-bash-gate.sh +230 -326
- package/package.json +3 -2
- package/profiles/bst-internal-no-codex.yaml +1 -1
- package/profiles/bst-internal.yaml +1 -1
- package/profiles/client-engagement.yaml +1 -1
- package/profiles/lit-wc.yaml +1 -1
- package/profiles/minimal.yaml +1 -1
- package/profiles/open-source-no-codex.yaml +1 -1
- package/profiles/open-source.yaml +1 -1
- package/scripts/postinstall.mjs +1 -2
- package/scripts/run-vitest.mjs +117 -0
|
@@ -1,348 +1,252 @@
|
|
|
1
|
-
#!/bin/bash
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
2
|
# PreToolUse hook: protected-paths-bash-gate.sh
|
|
3
|
-
# Fires BEFORE every Bash tool call.
|
|
4
|
-
# Refuses Bash commands that write to PROTECTED_PATTERNS via shell
|
|
5
|
-
# redirection or write-flag utilities — the kill-switch and policy
|
|
6
|
-
# files MUST be unreachable via any tool surface, including Bash.
|
|
7
3
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# entirely:
|
|
4
|
+
# 0.23.0+ — thin shim. Forwards stdin (Claude Code's tool_input JSON)
|
|
5
|
+
# to `rea hook scan-bash --mode protected`, parses the verdict, exits
|
|
6
|
+
# 0/2 accordingly.
|
|
12
7
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
8
|
+
# Pre-0.23.0 this hook was a 536-line bash regex pipeline. The rewrite
|
|
9
|
+
# moved every detection rule into a parser-backed AST walker at
|
|
10
|
+
# `src/hooks/bash-scanner/`. helix-023 and discord-ops Round 13 closed
|
|
11
|
+
# 9 bypass classes that lived in the old segmenter; the new scanner
|
|
12
|
+
# closes them definitionally — there is no segmenter to bypass.
|
|
18
13
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
14
|
+
# Failure mode: if the rea CLI cannot be located in a SANDBOXED tier
|
|
15
|
+
# (consumer's `node_modules/@bookedsolid/rea/dist/cli/index.js` or the
|
|
16
|
+
# rea repo's own `dist/cli/index.js`), we REFUSE the command. NEVER
|
|
17
|
+
# ALLOW on uncertainty. Operators need `@bookedsolid/rea` installed for
|
|
18
|
+
# the gate to work; `rea doctor` flags missing CLI as a P0.
|
|
19
|
+
#
|
|
20
|
+
# Defense in depth: the shim does NOT trust the CLI exit code alone.
|
|
21
|
+
# After capturing stdout we re-parse the verdict with `node -e` to
|
|
22
|
+
# confirm:
|
|
23
|
+
# 1. it is valid JSON,
|
|
24
|
+
# 2. the top-level shape has `.verdict == "allow"|"block"`,
|
|
25
|
+
# 3. the verdict matches the exit code (allow→0, block→2).
|
|
26
|
+
# Any disagreement → exit 2.
|
|
27
|
+
#
|
|
28
|
+
# Codex round 4 Finding 2: dropped tier 1 (PATH `command -v rea`) and
|
|
29
|
+
# tier 2 (`node_modules/.bin/rea` symlink). Both are workspace-attacker
|
|
30
|
+
# controllable: an attacker who can write a file at
|
|
31
|
+
# `node_modules/.bin/rea` (or set PATH to a directory they own) can
|
|
32
|
+
# stage a fake `rea` binary that exits 0 with `{"verdict":"allow"}` and
|
|
33
|
+
# subvert the gate.
|
|
34
|
+
#
|
|
35
|
+
# Trust-boundary HONESTY (codex round 5 F5): the shim's structural
|
|
36
|
+
# defense is the realpath sandbox (round 4 #2 + round 5 F2). It defeats:
|
|
37
|
+
# - PATH-attacker hijack via fake `rea` binary
|
|
38
|
+
# - node_modules/.bin/rea symlink-bin hijack
|
|
39
|
+
# - node_modules/@bookedsolid/rea -> /tmp/sym-attacker symlink-out
|
|
40
|
+
# - intra-project hijack without a matching package.json
|
|
41
|
+
# It does NOT defeat an attacker who writes a forged dist/cli/index.js
|
|
42
|
+
# AND a matching package.json directly into node_modules/. At that level
|
|
43
|
+
# the attacker has already compromised the package install pipeline (npm
|
|
44
|
+
# registry, lockfile, dependency confusion) and any dependency the agent
|
|
45
|
+
# imports is also forgeable — hook-tier defense is past. The trust
|
|
46
|
+
# boundary is package-tier integrity (npm provenance + manifest
|
|
47
|
+
# verification), not the bash gate. See THREAT_MODEL §8.3 + docs/
|
|
48
|
+
# architecture/bash-scanner.md for the full rationale.
|
|
49
|
+
#
|
|
50
|
+
# Tier defense: realpath the resolved CLI before exec. Two complementary
|
|
51
|
+
# checks:
|
|
52
|
+
# PRIMARY (codex round 5 F2): realpath(cli) MUST live INSIDE
|
|
53
|
+
# realpath(CLAUDE_PROJECT_DIR). Catches symlink-out-of-project attacks
|
|
54
|
+
# where the attacker writes `node_modules/@bookedsolid/rea` as a
|
|
55
|
+
# symlink to a tree under `/tmp/sym-attacker` containing a forged
|
|
56
|
+
# `package.json` with name `@bookedsolid/rea` and a forged
|
|
57
|
+
# `dist/cli/index.js` that exits 0 with `{"verdict":"allow"}`. Pre-fix
|
|
58
|
+
# the secondary check (package.json walk-up) was the ONLY guard, and
|
|
59
|
+
# the attacker satisfies it by placing a forged package.json in their
|
|
60
|
+
# own tree.
|
|
61
|
+
# SECONDARY: walk up from the resolved CLI looking for an ancestor
|
|
62
|
+
# `package.json` whose `name` is `@bookedsolid/rea`. This guards
|
|
63
|
+
# against intra-project symlinks where the realpath stays inside
|
|
64
|
+
# the project (e.g. accidentally pointing dist/ at node_modules/).
|
|
65
|
+
#
|
|
66
|
+
# Codex round 2 R2-3 (preserved): REA_NODE_CLI env-var honoring REMOVED.
|
|
67
|
+
# Test harnesses must set CLAUDE_PROJECT_DIR to a directory whose
|
|
68
|
+
# `dist/cli/index.js` (or `node_modules/@bookedsolid/rea/...`) holds
|
|
69
|
+
# the trusted CLI build. The shim NEVER reads REA_NODE_CLI.
|
|
21
70
|
#
|
|
22
71
|
# Exit codes:
|
|
23
|
-
# 0 =
|
|
24
|
-
# 2 =
|
|
72
|
+
# 0 = allow (verdict.verdict == "allow")
|
|
73
|
+
# 2 = block (verdict.verdict == "block", or any failure path)
|
|
25
74
|
|
|
26
75
|
set -uo pipefail
|
|
27
76
|
|
|
28
|
-
|
|
29
|
-
source "$(dirname "$0")/_lib/protected-paths.sh"
|
|
30
|
-
# shellcheck source=_lib/path-normalize.sh
|
|
31
|
-
source "$(dirname "$0")/_lib/path-normalize.sh"
|
|
32
|
-
# shellcheck source=_lib/cmd-segments.sh
|
|
33
|
-
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
77
|
+
proj="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
34
78
|
|
|
35
|
-
|
|
79
|
+
# Resolve the rea CLI through a fixed 2-tier sandboxed order.
|
|
80
|
+
# 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
|
|
81
|
+
# direct dist execution — the published artifact)
|
|
82
|
+
# 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
|
|
83
|
+
# dogfood install, where `rea` is the package itself)
|
|
84
|
+
#
|
|
85
|
+
# We build an `argv` array rather than a string so paths containing
|
|
86
|
+
# whitespace round-trip safely.
|
|
87
|
+
REA_ARGV=()
|
|
88
|
+
RESOLVED_CLI_PATH=""
|
|
89
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
90
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
91
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
92
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
93
|
+
# rea repo dogfood: the project IS @bookedsolid/rea.
|
|
94
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
95
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
96
|
+
fi
|
|
36
97
|
|
|
37
|
-
if
|
|
38
|
-
printf '
|
|
98
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
99
|
+
printf 'rea: CLI not found at sandboxed tiers (node_modules/@bookedsolid/rea/dist or dist/).\n' >&2
|
|
100
|
+
printf 'Install @bookedsolid/rea via npm/pnpm and run `rea doctor`.\n' >&2
|
|
101
|
+
printf 'Refusing the Bash command on uncertainty.\n' >&2
|
|
39
102
|
exit 2
|
|
40
103
|
fi
|
|
41
104
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
# Codex round 4 Finding 2 tier defense: realpath the resolved CLI and
|
|
106
|
+
# verify it lives inside the sandboxed dirs. Catches symlink games.
|
|
107
|
+
# We require Node for the verifier anyway (below) — use it here too.
|
|
108
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
109
|
+
printf 'rea: node not on PATH (required to realpath verify scan-bash CLI). Refusing.\n' >&2
|
|
110
|
+
exit 2
|
|
111
|
+
fi
|
|
112
|
+
sandbox_check=$(node -e '
|
|
113
|
+
const fs = require("fs");
|
|
114
|
+
const path = require("path");
|
|
115
|
+
const cli = process.argv[1];
|
|
116
|
+
const projDir = process.argv[2];
|
|
117
|
+
let real;
|
|
118
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
119
|
+
process.stdout.write("bad:realpath:" + (e && e.message ? e.message : String(e)));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
// Codex round 5 F2 (P0) — PRIMARY check: realpath(cli) must live
|
|
123
|
+
// INSIDE realpath(CLAUDE_PROJECT_DIR). Pre-fix an attacker who
|
|
124
|
+
// writes a symlink at node_modules/@bookedsolid/rea pointing to
|
|
125
|
+
// /tmp/sym-attacker (containing a forged package.json + dist/cli/index.js
|
|
126
|
+
// that exits 0 with verdict:"allow") completely defeated the
|
|
127
|
+
// sandbox — the package.json walk-up was satisfied by the forged
|
|
128
|
+
// file in the attacker tree. Containing realCli to realProj closes
|
|
129
|
+
// every symlink-out attack class because the attacker no longer
|
|
130
|
+
// controls a path inside the project tree.
|
|
131
|
+
let realProj;
|
|
132
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
133
|
+
process.stdout.write("bad:realpath-proj:" + (e && e.message ? e.message : String(e)));
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const projWithSep = realProj.endsWith(path.sep) ? realProj : realProj + path.sep;
|
|
137
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
138
|
+
process.stdout.write("bad:cli-escapes-project:" + real + ":proj=" + realProj);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Codex round 4 Finding 2 (now SECONDARY) — shape + ancestor pkg.json.
|
|
142
|
+
//
|
|
143
|
+
// Acceptance: the resolved CLI must end in `.../dist/cli/index.js`
|
|
144
|
+
// and have an ancestor `package.json` whose `name` is `@bookedsolid/rea`.
|
|
145
|
+
// This guards against intra-project hijack where an attacker writes
|
|
146
|
+
// a symlink at node_modules/@bookedsolid/rea pointing to a sibling
|
|
147
|
+
// tree INSIDE the project (e.g. ./scratch/) — the PRIMARY check
|
|
148
|
+
// accepts it (still inside project root) but the package.json walk-up
|
|
149
|
+
// refuses unless that tree contains the canonical package metadata.
|
|
150
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
151
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
152
|
+
process.stdout.write("bad:cli-shape:" + real);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
// Walk up looking for package.json with the protected name.
|
|
156
|
+
let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
|
|
157
|
+
let found = false;
|
|
158
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
159
|
+
const pj = path.join(cur, "package.json");
|
|
160
|
+
if (fs.existsSync(pj)) {
|
|
161
|
+
try {
|
|
162
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
163
|
+
if (data && data.name === "@bookedsolid/rea") {
|
|
164
|
+
found = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// Continue walking up.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
cur = path.dirname(cur);
|
|
172
|
+
}
|
|
173
|
+
if (!found) {
|
|
174
|
+
process.stdout.write("bad:no-rea-pkg:" + real);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
process.stdout.write("ok");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
' "$RESOLVED_CLI_PATH" "$proj" 2>&1)
|
|
180
|
+
sandbox_status=$?
|
|
181
|
+
if [ "$sandbox_status" -ne 0 ] || [ "$sandbox_check" != "ok" ]; then
|
|
182
|
+
printf 'rea: scan-bash CLI realpath escapes sandbox (%s). Refusing.\n' "$sandbox_check" >&2
|
|
49
183
|
exit 2
|
|
50
184
|
fi
|
|
51
185
|
|
|
52
|
-
|
|
53
|
-
|
|
186
|
+
# Capture stdin once and forward it to the CLI.
|
|
187
|
+
payload=$(cat)
|
|
188
|
+
if [ -z "$payload" ]; then
|
|
54
189
|
exit 0
|
|
55
190
|
fi
|
|
56
191
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# hook process — sentinel + caller-side handling is the only
|
|
107
|
-
# cross-shell-portable way.
|
|
108
|
-
if [[ "$t" != "$REA_ROOT" && "$t" != "$REA_ROOT"/* ]]; then
|
|
109
|
-
printf '__rea_outside_root__:%s' "$t"
|
|
110
|
-
return 0
|
|
111
|
-
fi
|
|
112
|
-
;;
|
|
113
|
-
esac
|
|
114
|
-
# Hand off to shared normalize_path (strips $REA_ROOT, URL-decodes,
|
|
115
|
-
# translates `\` → `/`, strips leading `./`).
|
|
116
|
-
t=$(normalize_path "$t")
|
|
117
|
-
# Lowercase for case-insensitive matching (helix-015 P1 fix #2 —
|
|
118
|
-
# macOS APFS allows `.ClAuDe/settings.json` to land on the same
|
|
119
|
-
# file as `.claude/settings.json`, so the matcher must compare
|
|
120
|
-
# lowercased forms).
|
|
121
|
-
printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
# Refuse and exit 2 with a uniform error message.
|
|
125
|
-
_refuse() {
|
|
126
|
-
local pattern="$1" target="$2" segment="$3"
|
|
127
|
-
{
|
|
128
|
-
printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
|
|
129
|
-
printf '\n'
|
|
130
|
-
printf ' Pattern matched: %s\n' "$pattern"
|
|
131
|
-
printf ' Resolved target: %s\n' "$target"
|
|
132
|
-
printf ' Segment: %s\n' "$segment"
|
|
133
|
-
printf '\n'
|
|
134
|
-
printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
|
|
135
|
-
printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
|
|
136
|
-
printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
|
|
137
|
-
} >&2
|
|
138
|
-
exit 2
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
# Inspect one segment for redirect / write patterns and refuse if the
|
|
142
|
-
# target matches any protected pattern.
|
|
143
|
-
_check_segment() {
|
|
144
|
-
local _raw="$1" segment="$2"
|
|
145
|
-
[[ -z "$segment" ]] && return 0
|
|
146
|
-
|
|
147
|
-
local target_token=""
|
|
148
|
-
local detected_form=""
|
|
149
|
-
|
|
150
|
-
# bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
|
|
151
|
-
# confuse some bash versions on macOS. Use named variables for each
|
|
152
|
-
# pattern so the literal stays in a string context only.
|
|
153
|
-
# 0.16.0 codex P1 fix (helix-015 #3): widened redirect regex. Pre-fix
|
|
154
|
-
# only matched `>`, `>>`, `2>`, `2>>`, `&>`. Missed:
|
|
155
|
-
# - `1>` / `1>>` (explicit stdout fd)
|
|
156
|
-
# - `>|` (noclobber-override redirect)
|
|
157
|
-
# - `[0-9]+>` / `[0-9]+>>` (any fd prefix — `9>file`, `42>>file`)
|
|
158
|
-
# All of these write to the target and bypassed the gate. The new
|
|
159
|
-
# pattern accepts: optional fd-prefix, then `>` or `>>` or `>|`, with
|
|
160
|
-
# optional `&` for stderr-merge variants.
|
|
161
|
-
local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
|
|
162
|
-
local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
|
|
163
|
-
local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
|
|
164
|
-
local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
|
|
165
|
-
# 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
|
|
166
|
-
# for tee/truncate flag-skipping with a token-walk approach that
|
|
167
|
-
# works across BSD bash 3.2 and GNU bash 4+. Walks every token after
|
|
168
|
-
# the command, skips flags (single-dash short, double-dash long with
|
|
169
|
-
# optional =value), returns the first non-flag token as the target.
|
|
170
|
-
|
|
171
|
-
if [[ "$segment" =~ $re_redirect ]]; then
|
|
172
|
-
target_token="${BASH_REMATCH[3]}"
|
|
173
|
-
detected_form="redirect ${BASH_REMATCH[2]}"
|
|
174
|
-
elif [[ "$segment" =~ $re_cpmv ]]; then
|
|
175
|
-
target_token="${BASH_REMATCH[3]}"
|
|
176
|
-
detected_form="${BASH_REMATCH[2]}"
|
|
177
|
-
elif [[ "$segment" =~ $re_sed ]]; then
|
|
178
|
-
target_token="${BASH_REMATCH[3]}"
|
|
179
|
-
detected_form="sed -i"
|
|
180
|
-
elif [[ "$segment" =~ $re_dd ]]; then
|
|
181
|
-
target_token="${BASH_REMATCH[2]}"
|
|
182
|
-
detected_form="dd of="
|
|
183
|
-
else
|
|
184
|
-
# tee / truncate / install / ln — token-walk for cross-bash safety.
|
|
185
|
-
# Read tokens, find the command, then return the first non-flag arg.
|
|
186
|
-
local prev_word="" found_cmd=""
|
|
187
|
-
local _seg_for_walk="$segment"
|
|
188
|
-
# Strip leading whitespace.
|
|
189
|
-
_seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
|
|
190
|
-
# shellcheck disable=SC2086
|
|
191
|
-
set -- $_seg_for_walk
|
|
192
|
-
while [ "$#" -gt 0 ]; do
|
|
193
|
-
local tok="$1"
|
|
194
|
-
shift
|
|
195
|
-
if [[ -z "$found_cmd" ]]; then
|
|
196
|
-
case "$tok" in
|
|
197
|
-
tee|truncate|install|ln)
|
|
198
|
-
found_cmd="$tok"
|
|
199
|
-
;;
|
|
200
|
-
esac
|
|
201
|
-
prev_word="$tok"
|
|
202
|
-
continue
|
|
203
|
-
fi
|
|
204
|
-
# We're inside the command's argv. Skip flags.
|
|
205
|
-
case "$tok" in
|
|
206
|
-
--) continue ;;
|
|
207
|
-
--*=*) continue ;;
|
|
208
|
-
--*)
|
|
209
|
-
# Long flag — may take a value as the NEXT token (we don't
|
|
210
|
-
# know which long options take values). For safety, skip
|
|
211
|
-
# only known no-value long flags; otherwise consume the
|
|
212
|
-
# next token too if it looks like a value.
|
|
213
|
-
case "$tok" in
|
|
214
|
-
--append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
|
|
215
|
-
*) shift 2>/dev/null || true; continue ;;
|
|
216
|
-
esac
|
|
217
|
-
;;
|
|
218
|
-
-*)
|
|
219
|
-
# Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
|
|
220
|
-
# SIZE is its arg. We're conservative: skip the next token if
|
|
221
|
-
# the flag cluster's last char is one of the size-bearing
|
|
222
|
-
# flags (truncate -s, install -m, ln -t).
|
|
223
|
-
case "$tok" in
|
|
224
|
-
-s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
|
|
225
|
-
esac
|
|
226
|
-
continue
|
|
227
|
-
;;
|
|
228
|
-
*)
|
|
229
|
-
# First non-flag token — this is the target (or, for cp/mv-
|
|
230
|
-
# like commands, the first source; the cpmv detector above
|
|
231
|
-
# handles those separately). We treat ALL non-flag args as
|
|
232
|
-
# potential targets and check each — that catches
|
|
233
|
-
# `tee a b c` where any of a/b/c could be a protected file.
|
|
234
|
-
target_token="$tok"
|
|
235
|
-
detected_form="$found_cmd"
|
|
236
|
-
# Check this token immediately; if not protected, keep
|
|
237
|
-
# walking — there may be more positional args.
|
|
238
|
-
local _t
|
|
239
|
-
_t=$(_normalize_target "$target_token")
|
|
240
|
-
# 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling (logical).
|
|
241
|
-
if [[ "$_t" == __rea_outside_root__:* ]]; then
|
|
242
|
-
local resolved="${_t#__rea_outside_root__:}"
|
|
243
|
-
{
|
|
244
|
-
printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
|
|
245
|
-
printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
|
|
246
|
-
} >&2
|
|
247
|
-
exit 2
|
|
248
|
-
fi
|
|
249
|
-
# 0.20.1 helix-021 #1: resolve intermediate symlinks via
|
|
250
|
-
# `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
|
|
251
|
-
# `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
|
|
252
|
-
# had a logical form of `.husky/pre-push.d/linkdir/pre-push`
|
|
253
|
-
# that didn't match any protected pattern; the resolved form
|
|
254
|
-
# is `.husky/pre-push` which DOES match. Refuse on either.
|
|
255
|
-
local _t_resolved
|
|
256
|
-
_t_resolved=$(rea_resolved_relative_form "$target_token")
|
|
257
|
-
if [[ "$_t_resolved" == __rea_outside_root__:* ]]; then
|
|
258
|
-
local resolved="${_t_resolved#__rea_outside_root__:}"
|
|
259
|
-
{
|
|
260
|
-
printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
|
|
261
|
-
printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
|
|
262
|
-
} >&2
|
|
263
|
-
exit 2
|
|
264
|
-
fi
|
|
265
|
-
if rea_path_is_protected "$_t" \
|
|
266
|
-
|| ([[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved"); then
|
|
267
|
-
local matched=""
|
|
268
|
-
local pattern_lc
|
|
269
|
-
local hit_form="$_t"
|
|
270
|
-
if [[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved" \
|
|
271
|
-
&& ! rea_path_is_protected "$_t"; then
|
|
272
|
-
hit_form="$_t_resolved"
|
|
273
|
-
fi
|
|
274
|
-
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
275
|
-
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
276
|
-
if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
|
|
277
|
-
if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
|
|
278
|
-
done
|
|
279
|
-
_refuse "$matched" "$hit_form" "$segment"
|
|
280
|
-
fi
|
|
281
|
-
# Reset target_token so the post-loop check doesn't double-check.
|
|
282
|
-
target_token=""
|
|
283
|
-
;;
|
|
284
|
-
esac
|
|
285
|
-
done
|
|
286
|
-
fi
|
|
287
|
-
|
|
288
|
-
if [[ -z "$target_token" ]]; then
|
|
289
|
-
return 0
|
|
290
|
-
fi
|
|
291
|
-
|
|
292
|
-
local target
|
|
293
|
-
target=$(_normalize_target "$target_token")
|
|
294
|
-
# 0.16.0 codex P2-3 fix: outside-REA_ROOT sentinel from _normalize_target.
|
|
295
|
-
if [[ "$target" == __rea_outside_root__:* ]]; then
|
|
296
|
-
local resolved="${target#__rea_outside_root__:}"
|
|
297
|
-
{
|
|
298
|
-
printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
|
|
299
|
-
printf '\n'
|
|
300
|
-
printf ' Logical: %s\n' "$target_token"
|
|
301
|
-
printf ' Resolved: %s\n' "$resolved"
|
|
302
|
-
printf ' Segment: %s\n' "$segment"
|
|
303
|
-
printf '\n'
|
|
304
|
-
printf ' Rule: bash redirects whose target resolves outside REA_ROOT\n'
|
|
305
|
-
printf ' are refused. Use a project-relative path without `..`\n'
|
|
306
|
-
printf ' segments.\n'
|
|
307
|
-
} >&2
|
|
308
|
-
exit 2
|
|
309
|
-
fi
|
|
310
|
-
# 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
|
|
311
|
-
# block in the multi-target loop above for the rationale.
|
|
312
|
-
local target_resolved
|
|
313
|
-
target_resolved=$(rea_resolved_relative_form "$target_token")
|
|
314
|
-
if [[ "$target_resolved" == __rea_outside_root__:* ]]; then
|
|
315
|
-
local resolved="${target_resolved#__rea_outside_root__:}"
|
|
316
|
-
{
|
|
317
|
-
printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
|
|
318
|
-
printf '\n'
|
|
319
|
-
printf ' Logical: %s\n' "$target_token"
|
|
320
|
-
printf ' Resolved: %s\n' "$resolved"
|
|
321
|
-
printf ' Segment: %s\n' "$segment"
|
|
322
|
-
} >&2
|
|
192
|
+
# Run the scanner.
|
|
193
|
+
verdict=$(printf '%s' "$payload" | "${REA_ARGV[@]}" hook scan-bash --mode protected)
|
|
194
|
+
status=$?
|
|
195
|
+
|
|
196
|
+
# Defense in depth — verify the verdict JSON matches the exit code.
|
|
197
|
+
verifier='try {
|
|
198
|
+
const raw = require("fs").readFileSync(0, "utf8");
|
|
199
|
+
if (raw.trim().length === 0) { process.stdout.write("bad:empty"); process.exit(1); }
|
|
200
|
+
const v = JSON.parse(raw);
|
|
201
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
202
|
+
process.stdout.write("bad:non-object"); process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (v.verdict !== "allow" && v.verdict !== "block") {
|
|
205
|
+
process.stdout.write("bad:verdict-shape:" + String(v.verdict)); process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
process.stdout.write("ok:" + v.verdict); process.exit(0);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
process.stdout.write("bad:" + (e && e.message ? e.message : String(e))); process.exit(1);
|
|
210
|
+
}'
|
|
211
|
+
|
|
212
|
+
verdict_check=$(printf '%s' "$verdict" | node -e "$verifier" 2>&1)
|
|
213
|
+
verdict_check_status=$?
|
|
214
|
+
|
|
215
|
+
case "$status" in
|
|
216
|
+
0)
|
|
217
|
+
if [ "$verdict_check_status" -ne 0 ]; then
|
|
218
|
+
printf 'rea: scan-bash exited 0 but verdict JSON is malformed (%s). Refusing on uncertainty.\n' "$verdict_check" >&2
|
|
219
|
+
exit 2
|
|
220
|
+
fi
|
|
221
|
+
if [ "$verdict_check" != "ok:allow" ]; then
|
|
222
|
+
printf 'rea: scan-bash exit 0 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
|
|
223
|
+
exit 2
|
|
224
|
+
fi
|
|
225
|
+
exit 0
|
|
226
|
+
;;
|
|
227
|
+
2)
|
|
228
|
+
# Block path — the CLI has already emitted the operator-facing
|
|
229
|
+
# reason on stderr. We additionally verify the JSON shape so a
|
|
230
|
+
# forged `/bin/true` (which would never reach here, but be defensive)
|
|
231
|
+
# cannot bypass.
|
|
232
|
+
if [ "$verdict_check_status" -ne 0 ]; then
|
|
233
|
+
# Malformed stdout under exit 2 is unusual but harmless — the
|
|
234
|
+
# block path is still honored.
|
|
235
|
+
exit 2
|
|
236
|
+
fi
|
|
237
|
+
if [ "$verdict_check" != "ok:block" ]; then
|
|
238
|
+
printf 'rea: scan-bash exit 2 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
|
|
239
|
+
exit 2
|
|
240
|
+
fi
|
|
323
241
|
exit 2
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
local hit_form="$target"
|
|
332
|
-
if [[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved" \
|
|
333
|
-
&& ! rea_path_is_protected "$target"; then
|
|
334
|
-
hit_form="$target_resolved"
|
|
242
|
+
;;
|
|
243
|
+
*)
|
|
244
|
+
# Unexpected exit code — treat as block on uncertainty. The CLI
|
|
245
|
+
# writes its own diagnostic; we add an explicit refusal.
|
|
246
|
+
printf 'rea: scan-bash exited %d (expected 0/2). Refusing on uncertainty.\n' "$status" >&2
|
|
247
|
+
if [ -n "$verdict" ]; then
|
|
248
|
+
printf 'rea: scan-bash stdout was: %s\n' "$verdict" >&2
|
|
335
249
|
fi
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
|
|
340
|
-
done
|
|
341
|
-
_refuse "$matched" "$hit_form" "$segment"
|
|
342
|
-
fi
|
|
343
|
-
return 0
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
for_each_segment "$CMD" _check_segment
|
|
347
|
-
|
|
348
|
-
exit 0
|
|
250
|
+
exit 2
|
|
251
|
+
;;
|
|
252
|
+
esac
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@clack/prompts": "^1.2.0",
|
|
72
72
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
73
73
|
"commander": "^14.0.3",
|
|
74
|
+
"mvdan-sh": "0.10.1",
|
|
74
75
|
"proper-lockfile": "^4.1.2",
|
|
75
76
|
"safe-regex": "^2.1.1",
|
|
76
77
|
"yaml": "^2.7.0",
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
"lint:regex": "node scripts/lint-safe-regex.mjs",
|
|
98
99
|
"format": "prettier --write .",
|
|
99
100
|
"format:check": "prettier --check .",
|
|
100
|
-
"test": "pnpm run test:dogfood && pnpm run test:bash-syntax &&
|
|
101
|
+
"test": "pnpm run build && pnpm run test:dogfood && pnpm run test:bash-syntax && node scripts/run-vitest.mjs",
|
|
101
102
|
"test:watch": "vitest",
|
|
102
103
|
"test:coverage": "vitest run --coverage",
|
|
103
104
|
"test:dogfood": "node tools/check-dogfood-drift.mjs",
|
|
@@ -31,7 +31,7 @@ blocked_paths:
|
|
|
31
31
|
- .github/workflows/release.yml
|
|
32
32
|
- SECURITY.md
|
|
33
33
|
- THREAT_MODEL.md
|
|
34
|
-
notification_channel:
|
|
34
|
+
notification_channel: ''
|
|
35
35
|
# G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
|
|
36
36
|
# literal injection match at write/destructive tier denies (does not merely
|
|
37
37
|
# warn). External profiles inherit the schema default `false`.
|
|
@@ -14,7 +14,7 @@ blocked_paths:
|
|
|
14
14
|
- .github/workflows/release.yml
|
|
15
15
|
- SECURITY.md
|
|
16
16
|
- THREAT_MODEL.md
|
|
17
|
-
notification_channel:
|
|
17
|
+
notification_channel: ''
|
|
18
18
|
# G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
|
|
19
19
|
# literal injection match at write/destructive tier denies (does not merely
|
|
20
20
|
# warn). External profiles (open-source, client-engagement, minimal, lit-wc)
|
package/profiles/lit-wc.yaml
CHANGED
package/profiles/minimal.yaml
CHANGED