@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.