@bookedsolid/rea 0.23.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/THREAT_MODEL.md
CHANGED
|
@@ -891,9 +891,180 @@ The scanner does NOT trust:
|
|
|
891
891
|
Round-12 fix: early-return ALLOW from detectUnzip when any
|
|
892
892
|
read-only flag (or cluster char) present. Class W (round-12
|
|
893
893
|
closures — 173 positives + 18 negatives) pins the closure.
|
|
894
|
+
- **cwd-relative-write kill-switch defeat** (helix-024 F1 — P1
|
|
895
|
+
closed in 0.23.1). `cd .rea && echo > HALT`,
|
|
896
|
+
`cd .husky && echo > pre-push`, `(cd .rea && echo > HALT)`,
|
|
897
|
+
`pushd .rea && echo > HALT`, `echo x | (cd .rea && tee HALT)`,
|
|
898
|
+
`p=.rea; cd $p && echo > HALT` all defeated 0.23.0. Pre-fix the
|
|
899
|
+
walker emitted only the relative redirect target (`HALT`); the
|
|
900
|
+
scanner normalized `HALT` against REA_ROOT and got `HALT`,
|
|
901
|
+
which doesn't match `.rea/HALT`. The cd was structurally
|
|
902
|
+
invisible — the walker source explicitly documented this limit.
|
|
903
|
+
Closure: new `detectCwdChangeIntoProtected` post-walker pass
|
|
904
|
+
scans the AST a second time for `cd`/`pushd` CallExprs and
|
|
905
|
+
emits a synthetic `cwd_protected_unresolvable` (literal target
|
|
906
|
+
— scanner runs the protected-prefix test with
|
|
907
|
+
`forceDirSemantics: true`) or `cwd_dynamic_with_writes_unresolvable`
|
|
908
|
+
(dynamic target — refuse on uncertainty) under a four-rule
|
|
909
|
+
refined predicate (round-14 codex P1 over-correction fix,
|
|
910
|
+
tightened in round-15 closure below):
|
|
911
|
+
(1) writes must be in-scope of the cd (sequential successors
|
|
912
|
+
in the same StmtList, or BinaryCmd.Y subtree of cd's BinaryCmd.X,
|
|
913
|
+
or nested compound stmts of those — NOT unrelated parallel
|
|
914
|
+
stmts); (2) writes must be bare-relative path-shape (absolute /
|
|
915
|
+
tilde / outside-root sentinels are unaffected by cwd); (3)
|
|
916
|
+
dynamic cd with a known-safe source is treated as ALLOW. The
|
|
917
|
+
known-safe set is intentionally narrow: NO env-var name
|
|
918
|
+
qualifies (round-15 P1 closure — `$HOME`/`$PWD`/`$OLDPWD` are
|
|
919
|
+
rebindable via inline assignment-prefix or parent-shell export,
|
|
920
|
+
and `$OLDPWD` tracks any previous cd including into protected
|
|
921
|
+
dirs); the only ParamExp source that is known-safe is a for-iter
|
|
922
|
+
variable bound to all-literal-non-protected Items. Known-safe
|
|
923
|
+
cmdsubst sources are `$(pwd)` and `$(git rev-parse <flag>)`
|
|
924
|
+
with flag in `{--show-toplevel, --show-cdup,
|
|
925
|
+
--show-superproject-working-tree}` — flags that resolve to
|
|
926
|
+
absolute paths or paths stepping OUT of cwd. `$(git rev-parse
|
|
927
|
+
--show-prefix)` is NOT known-safe (round-15 P1 closure — it
|
|
928
|
+
returns the cwd-relative path INSIDE the toplevel, so when the
|
|
929
|
+
agent is already in `.rea/` it returns `.rea/`). (4) dynamic cd
|
|
930
|
+
without bare-relative writes in scope emits nothing. Caught:
|
|
931
|
+
literal protected cd + bare-relative writes in any nested scope,
|
|
932
|
+
dynamic cd with unknown / env-var / show-prefix source +
|
|
933
|
+
bare-relative write in scope. Accepted false-negatives
|
|
934
|
+
(out of scope for hotfix, tracked for 0.24.0):
|
|
935
|
+
`cd $(echo .rea)` cmdsubst-resolved literals,
|
|
936
|
+
`alias evil="..."; evil` alias-then-invoke, for-iter loops
|
|
937
|
+
whose Items list is a cmdsubst.
|
|
938
|
+
- **doubly/N-nested eval bypass** (helix-024 F2 — P1 closed in
|
|
939
|
+
0.23.1). `eval "eval \"echo > .rea/HALT\""` defeated 0.23.0.
|
|
940
|
+
Pre-fix `detectEval` re-parsed exactly one level. The outer DQ-
|
|
941
|
+
significant escapes (`\"`) survived as literal backslash-quote
|
|
942
|
+
pairs into the joined inner string, corrupting the second-
|
|
943
|
+
level redirect target (`.rea/HALT\"`) so it didn't match the
|
|
944
|
+
protected list. Same shape as the helix-022 #3 nested-bash-c
|
|
945
|
+
DQ-escape bypass — `unshellEscape` collapses one level of DQ-
|
|
946
|
+
escapes before re-parse. Closure: two-part fix. (1)
|
|
947
|
+
unshellEscape the inner string before re-parsing in detectEval
|
|
948
|
+
(parity with detectNestedShell). (2) New `EVAL_DEPTH_CAP = 8`
|
|
949
|
+
counter mirroring NESTED_SHELL_DEPTH_CAP; past the cap the
|
|
950
|
+
walker emits a synthetic dynamic detection. Eval and bash -c
|
|
951
|
+
use SEPARATE counters so a heavy mix doesn't exhaust one
|
|
952
|
+
budget through the other. Single-level `eval "echo > .rea/
|
|
953
|
+
HALT"` continues to BLOCK (no regression).
|
|
954
|
+
- **symlink-alias-write kill-switch defeat** (helix-024 F3 — P1
|
|
955
|
+
closed in 0.23.1). `ln -sf .rea/HALT /tmp/_x && echo y > /tmp/
|
|
956
|
+
_x` exited 0 in 0.23.0. The static walker doesn't track
|
|
957
|
+
filesystem state across statements; the first statement stages
|
|
958
|
+
a symlink whose target is protected, the second writes through
|
|
959
|
+
the link. Neither token in isolation references a protected
|
|
960
|
+
write target — `ln`'s SRC was just a positional, DEST was
|
|
961
|
+
unprotected. Closure: detectLn emits a synthetic
|
|
962
|
+
`ln_to_protected_unresolvable` detection on every SRC positional
|
|
963
|
+
in both `ln SRC DEST` and `ln -t DEST_DIR SRC1 ...` forms. The
|
|
964
|
+
detection carries `isDestructive: true` so protected-ancestry
|
|
965
|
+
matches bare-dir SRCs (`ln -s .rea /tmp/_x` aliases the .rea
|
|
966
|
+
directory). Dynamic SRC emits the dynamic form. Class X
|
|
967
|
+
(helix-024 closures — 234 positives + 16 negatives) pins all
|
|
968
|
+
three closures. Related class deferred to 0.24.0:
|
|
969
|
+
alias-then-invoke (`alias evil="echo > .rea/HALT"; evil`)
|
|
970
|
+
requires modeling shell aliases at AST time.
|
|
971
|
+
- **env-var rebind + `--show-prefix` cwd-eval bypass**
|
|
972
|
+
(helix-024 round-15 P1 — closed in 0.23.1). Round-14's
|
|
973
|
+
`detectCwdChangeIntoProtected` refined predicate ALLOWed dynamic
|
|
974
|
+
cd whose source was an env-var ParamExp from
|
|
975
|
+
`{HOME, PWD, OLDPWD}` or a cmdsubst from `git rev-parse
|
|
976
|
+
--show-prefix`. Both classes are attacker-controllable. (1) Env
|
|
977
|
+
vars rebind via inline assignment-prefix on the same simple
|
|
978
|
+
command (`HOME=.rea cd "$HOME" && echo > HALT`) and via
|
|
979
|
+
parent-shell exports across commands (`export HOME=.rea; cd
|
|
980
|
+
"$HOME" && echo > HALT`); `OLDPWD` automatically tracks any
|
|
981
|
+
previous cd, so a prior cd into a protected dir poisons later
|
|
982
|
+
`cd "$OLDPWD"`. (2) `git rev-parse --show-prefix` returns the
|
|
983
|
+
cwd-relative path INSIDE the toplevel; when the agent's cwd is
|
|
984
|
+
already `.rea/`, `cd "$(git rev-parse --show-prefix)" && echo
|
|
985
|
+
> HALT` lands inside `.rea/` with a `HALT` write target that
|
|
986
|
+
resolves against `.rea/HALT`. Closure: empty `KNOWN_SAFE_VARS`
|
|
987
|
+
in `isParamExpKnownSafe` (no env-var name is statically safe;
|
|
988
|
+
the for-iter carve-out remains because Items literals are
|
|
989
|
+
statically checked); drop `--show-prefix` from the
|
|
990
|
+
`isCmdSubstKnownSafe` FLAGS allow-list (the remaining flags
|
|
991
|
+
`--show-toplevel`, `--show-cdup`,
|
|
992
|
+
`--show-superproject-working-tree` resolve to absolute paths or
|
|
993
|
+
paths stepping OUT of cwd — never INTO it). Class X corpus
|
|
994
|
+
rehomes: 3 fixtures moved from R14_ALLOW to R14_BLOCK
|
|
995
|
+
(`cd "$HOME"` / `cd "$OLDPWD"` / `pushd "$HOME"` with bare
|
|
996
|
+
writes), 4 new BLOCK fixtures pin the round-15 PoCs
|
|
997
|
+
(`HOME=.rea cd "$HOME"`, `PWD=.rea cd "$PWD"`,
|
|
998
|
+
`cd "$(git rev-parse --show-prefix)"`,
|
|
999
|
+
`export HOME=.rea; cd "$HOME"`). Single-level eval, ln-source-
|
|
1000
|
+
protected, and the literal `cd .rea` path remain unchanged. As
|
|
1001
|
+
a side improvement under round-15 P3, `.github/workflows/` is
|
|
1002
|
+
added to the historical default protected list so consumers
|
|
1003
|
+
without an explicit `policy.blocked_paths` entry still refuse
|
|
1004
|
+
Bash-tier writes to CI workflows; the path is intentionally NOT
|
|
1005
|
+
a kill-switch invariant — operators may relax it via
|
|
1006
|
+
`policy.protected_paths_relax`. Round-16 closure (helix-024
|
|
1007
|
+
hotfix continued, sibling threat class to round-15 F1) extends
|
|
1008
|
+
the refuse-on-uncertainty path to bare `cd` (defaults cwd to
|
|
1009
|
+
`$HOME`), `cd -L` / `cd -P` (flag-only, also default to
|
|
1010
|
+
`$HOME`), `cd -` (reverts to `$OLDPWD`), and `popd` (reverts
|
|
1011
|
+
to dir-stack head): all four forms emit no positional after
|
|
1012
|
+
flag-skip and previously fell through with no detection — they
|
|
1013
|
+
now run the same in-scope bare-relative-write check as the
|
|
1014
|
+
dynamic-target branch and emit
|
|
1015
|
+
`cwd_dynamic_with_writes_unresolvable` if a bare-relative write
|
|
1016
|
+
is in scope. 5 new R16_BLOCK fixtures + 4 R16-shape negatives
|
|
1017
|
+
added to Class X corpus.
|
|
1018
|
+
Round-17 closure (helix-024 hotfix continued, P1 + P2 + P3 +
|
|
1019
|
+
P3-doc — control-flow walker gap, NOT a predicate weakness): the
|
|
1020
|
+
round-14/15/16 walker visited a conditional's Cond and Body as
|
|
1021
|
+
separate scopes via `walkScopeForCwd`. A `cd` inside the Cond
|
|
1022
|
+
therefore had a single-command scope with no successors, never
|
|
1023
|
+
collected the body's writes as downstream, and never emitted —
|
|
1024
|
+
even though bash semantics keep the cwd change in the current
|
|
1025
|
+
shell so it persists into the Body when the cond is truthy AND
|
|
1026
|
+
past the conditional into post-stmt siblings. Closure: thread an
|
|
1027
|
+
`extraDownstream` parameter through `walkScopeForCwd` →
|
|
1028
|
+
`classifyCdInStmt` → `collectCdSitesInStmt` /
|
|
1029
|
+
`collectCdSitesInBinaryX`. When `descendCmdScopes` enters an
|
|
1030
|
+
IfClause/WhileClause/UntilClause, the Cond walk receives `[...
|
|
1031
|
+
body, ...post-stmt-siblings]` as carriers; the Body walk receives
|
|
1032
|
+
`[...post-stmt-siblings]`. Subshell stays cwd-isolated (forks a
|
|
1033
|
+
child shell) so its inner walk does NOT inherit parent siblings.
|
|
1034
|
+
The same closure adds explicit `TimeClause` / `CoprocClause`
|
|
1035
|
+
cases to `descendCmdScopes` (descend into the wrapped Stmt with
|
|
1036
|
+
carriers) and a TimeClause/CoprocClause unwrap in
|
|
1037
|
+
`collectCdSitesInBinaryX` so `time cd .rea && echo > HALT`
|
|
1038
|
+
reaches the cd site. `pushd` no-positional / `pushd -N` /
|
|
1039
|
+
`pushd +N` already BLOCK incidentally via the round-16 fallback
|
|
1040
|
+
(runtime-determined dir-stack manipulation refused on uncertainty),
|
|
1041
|
+
but R17 P3 pins the verdict with three explicit fixtures so a
|
|
1042
|
+
future predicate relaxation cannot silently re-open the bypass.
|
|
1043
|
+
12 new R17_BLOCK fixtures + 3 R17_ALLOW negatives added to Class
|
|
1044
|
+
X corpus, including the pragmatic-bound ALLOW for `pushd && cat
|
|
1045
|
+
README.md` (no bare-relative WRITE in scope), `if cd /tmp; then
|
|
1046
|
+
echo > log; fi` (literal non-protected cd target — protected-
|
|
1047
|
+
prefix test ALLOWS), and `if cd .rea; then cat HALT; fi` (read-
|
|
1048
|
+
only body — predicate requires a WRITE).
|
|
894
1049
|
|
|
895
1050
|
### 8.3 Bypass classes still possible
|
|
896
1051
|
|
|
1052
|
+
- **`mvdan-sh@0.10.1` deprecation advisory** (helix-024 F4 — P2
|
|
1053
|
+
acknowledged residual, surfaced 2026-05-04). The 0.23.0 upgrade
|
|
1054
|
+
introduced `mvdan-sh@0.10.1` as a transitive runtime dependency
|
|
1055
|
+
at the security boundary. The package is the JavaScript port of
|
|
1056
|
+
mvdan's Go shell parser and is upstream-deprecated per
|
|
1057
|
+
https://github.com/mvdan/sh/issues/1145 (Go-original is
|
|
1058
|
+
actively maintained; the JS port is on hold). The deprecation
|
|
1059
|
+
is a code-freeze, not a removal. Mitigations already in place:
|
|
1060
|
+
(1) integrity hash pinned in pnpm-lock.yaml, (2) the project
|
|
1061
|
+
fails closed on parser anomalies (parse errors → refuse on
|
|
1062
|
+
uncertainty), (3) Class O exhaustiveness contract pins the
|
|
1063
|
+
walker against any latent field-gap. A future mvdan-sh
|
|
1064
|
+
migration / replacement is out-of-scope for the helix-024
|
|
1065
|
+
hotfix; tracked for 0.24.0 evaluation. Listed as still-possible
|
|
1066
|
+
rather than structurally-impossible because the security model
|
|
1067
|
+
binds rea to a deprecated parser at the AST boundary.
|
|
897
1068
|
- **`@bookedsolid/rea` package-tier supply-chain compromise** (codex
|
|
898
1069
|
round 5 F5 — P1/P3 acknowledged residual). The bash-tier shim's
|
|
899
1070
|
CLI-resolution sandbox check (codex round 4 #2 + round 5 F2)
|
|
@@ -41,6 +41,15 @@ import { allowVerdict, blockVerdict } from './verdict.js';
|
|
|
41
41
|
* Hardcoded historical default — when policy.protected_writes is not
|
|
42
42
|
* set this is the protected list. Mirrors REA_PROTECTED_PATTERNS_FULL
|
|
43
43
|
* in `hooks/_lib/protected-paths.sh`.
|
|
44
|
+
*
|
|
45
|
+
* Round-15 P3: `.github/workflows/` added so consumers without an
|
|
46
|
+
* explicit `policy.blocked_paths` entry still refuse Bash-tier writes
|
|
47
|
+
* to CI workflows. CLAUDE.md describes `.github/workflows/` as a
|
|
48
|
+
* sensitive path requiring CODEOWNERS approval; the default protected
|
|
49
|
+
* list now matches. Intentionally NOT a kill-switch invariant —
|
|
50
|
+
* consumers may legitimately relax workflow protection via
|
|
51
|
+
* `protected_paths_relax: ['.github/workflows/']` when they have no
|
|
52
|
+
* CI safety story to protect.
|
|
44
53
|
*/
|
|
45
54
|
const HISTORICAL_DEFAULT_PROTECTED_PATTERNS = [
|
|
46
55
|
'.claude/settings.json',
|
|
@@ -50,6 +59,7 @@ const HISTORICAL_DEFAULT_PROTECTED_PATTERNS = [
|
|
|
50
59
|
'.rea/HALT',
|
|
51
60
|
'.rea/last-review.cache.json',
|
|
52
61
|
'.rea/last-review.json',
|
|
62
|
+
'.github/workflows/',
|
|
53
63
|
];
|
|
54
64
|
/**
|
|
55
65
|
* Kill-switch invariants — never relaxable. These represent the
|
|
@@ -698,6 +708,49 @@ export function scanForProtectedViolations(ctx, detections) {
|
|
|
698
708
|
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
699
709
|
});
|
|
700
710
|
}
|
|
711
|
+
// helix-024 F1: cd-into-dynamic-directory + writes-elsewhere.
|
|
712
|
+
// Walker emits when the cd/pushd target is $VAR / $(cmd) and
|
|
713
|
+
// the AST contains writes. We can't statically determine
|
|
714
|
+
// whether the dynamic target is protected; refuse on uncertainty.
|
|
715
|
+
if (d.form === 'cwd_dynamic_with_writes_unresolvable') {
|
|
716
|
+
return blockVerdict({
|
|
717
|
+
reason: [
|
|
718
|
+
'PROTECTED PATH (bash): cd/pushd target is dynamic and the command contains writes.',
|
|
719
|
+
'',
|
|
720
|
+
' rea refuses on uncertainty. The cwd may resolve to a protected directory',
|
|
721
|
+
' (.rea/, .husky/, .claude/, .github/workflows/) at runtime, in which case any',
|
|
722
|
+
' subsequent relative-path write would target a protected file.',
|
|
723
|
+
'',
|
|
724
|
+
' Resolve the variable to a literal path before the cd, OR move the writes out',
|
|
725
|
+
' of the cd-affected scope so the scanner can verify each target individually.',
|
|
726
|
+
].join('\n'),
|
|
727
|
+
hitPattern: '(cd dynamic + writes unresolvable)',
|
|
728
|
+
detectedForm: d.form,
|
|
729
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
// helix-024 F3: ln SRC DEST whose SRC is dynamic. The link target
|
|
733
|
+
// is computed at runtime; we can't tell whether the eventual
|
|
734
|
+
// alias points at a protected path. Refuse on uncertainty when
|
|
735
|
+
// SRC is dynamic. (Literal-SRC-protected ln is handled below in
|
|
736
|
+
// the logical-form match path because the walker emits with
|
|
737
|
+
// dynamic=false for literal SRCs.)
|
|
738
|
+
if (d.form === 'ln_to_protected_unresolvable') {
|
|
739
|
+
return blockVerdict({
|
|
740
|
+
reason: [
|
|
741
|
+
'PROTECTED PATH (bash): ln source is dynamic — link may alias a protected path.',
|
|
742
|
+
'',
|
|
743
|
+
' rea refuses on uncertainty. A subsequent write through the link would target',
|
|
744
|
+
' the resolved source path, which the static scanner cannot verify.',
|
|
745
|
+
'',
|
|
746
|
+
' Resolve the variable to a literal path before the ln, OR avoid creating a',
|
|
747
|
+
' link whose source is dynamically computed.',
|
|
748
|
+
].join('\n'),
|
|
749
|
+
hitPattern: '(ln source dynamic unresolvable)',
|
|
750
|
+
detectedForm: d.form,
|
|
751
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
701
754
|
// Codex round 11 F11-4: archive extraction whose member set is
|
|
702
755
|
// unknown at static-analysis time (`tar -xzf foo.tar.gz` with no
|
|
703
756
|
// explicit member list — the archive may contain `.rea/HALT`).
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* fd-prefixed variants), `nested_shell_inner` for unwrapped
|
|
23
23
|
* `bash -c`/`sh -c` payloads.
|
|
24
24
|
*/
|
|
25
|
-
export type DetectedForm = 'redirect' | 'cp_dest' | 'cp_t_flag' | 'mv_dest' | 'mv_t_flag' | 'tee_arg' | 'sed_i' | 'dd_of' | 'truncate_arg' | 'install_dest' | 'ln_dest' | 'awk_inplace' | 'awk_source' | 'ed_target' | 'ex_target' | 'find_exec_inner' | 'find_exec_placeholder_unresolvable' | 'xargs_unresolvable' | 'parallel_stdin_unresolvable' | 'git_filter_branch_inner' | 'git_rebase_exec_inner' | 'git_bisect_run_inner' | 'git_commit_template' | 'git_rm_dest' | 'git_mv_src' | 'archive_extract_dest' | 'archive_extract_unresolvable' | 'archive_member_dest' | 'archive_create_dest' | 'gzip_compress_dest' | 'cmake_e_dest' | 'mkfifo_dest' | 'mknod_dest' | 'node_e_path' | 'python_c_path' | 'ruby_e_path' | 'perl_e_path' | 'php_r_path' | 'process_subst_inner' | 'nested_shell_inner';
|
|
25
|
+
export type DetectedForm = 'redirect' | 'cp_dest' | 'cp_t_flag' | 'mv_dest' | 'mv_t_flag' | 'tee_arg' | 'sed_i' | 'dd_of' | 'truncate_arg' | 'install_dest' | 'ln_dest' | 'awk_inplace' | 'awk_source' | 'ed_target' | 'ex_target' | 'find_exec_inner' | 'find_exec_placeholder_unresolvable' | 'xargs_unresolvable' | 'parallel_stdin_unresolvable' | 'git_filter_branch_inner' | 'git_rebase_exec_inner' | 'git_bisect_run_inner' | 'git_commit_template' | 'git_rm_dest' | 'git_mv_src' | 'archive_extract_dest' | 'archive_extract_unresolvable' | 'archive_member_dest' | 'archive_create_dest' | 'gzip_compress_dest' | 'cmake_e_dest' | 'mkfifo_dest' | 'mknod_dest' | 'node_e_path' | 'python_c_path' | 'ruby_e_path' | 'perl_e_path' | 'php_r_path' | 'process_subst_inner' | 'nested_shell_inner' | 'cwd_protected_unresolvable' | 'cwd_dynamic_with_writes_unresolvable' | 'ln_to_protected_unresolvable';
|
|
26
26
|
/**
|
|
27
27
|
* Source position for a detected write. 1-indexed (matches the parser's
|
|
28
28
|
* convention) so the operator-facing error message reads naturally.
|