Skip to content

Commit 98542da

Browse files
Spenquatchclaude
andauthored
fix: recurse into sub-expressions in is_safe_expect_expr (0.2.1) (#2)
* Recurse into expect expression validation and add regression tests * Recurse into expect expressions to reject nested unsafe and blocks * chore: clarify expect validation scope, add regression tests, bump to 0.2.1 - Update comment and error message in validate_local_test_expects to accurately describe what is and isn't blocked: syntax-level injection vectors (block, unsafe, closure, control-flow) are rejected; call expressions to side-effectful functions are not blocked since .unit.spec files are treated as trusted input (same as body.rust) - Add 4 regression tests for Field, Index, Unary, and Cast recursive arms to ensure sub-expression recursion is covered - Track unbounded recursion finding in TODOS.md (no depth cap in is_safe_expect_expr; low-urgency given trusted input model) - Bump version 0.2.0 → 0.2.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a67156b commit 98542da

5 files changed

Lines changed: 283 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.2.1 - 2026-04-03
4+
5+
### Security
6+
7+
- **`is_safe_expect_expr` now recurses into sub-expressions** — Previously, the expression whitelist in `spec validate` only inspected the top-level AST node. A call like `f({ unsafe { ... } })` would pass because the outer `Call` was whitelisted without checking its arguments. All Arms (Binary, Call, MethodCall, Field, Index, Unary, Paren, Cast) now recurse into every sub-expression; `unsafe`, block, closure, and control-flow forms are rejected wherever they appear in the tree. Error message updated from "simple expression" framing to "block, unsafe, closure" framing to accurately describe what is and isn't blocked.
8+
9+
### Testing
10+
11+
- Added 4 regression tests covering recursion through Field, Index, Unary, and Cast arms.
12+
313
## 0.2.0 - 2026-04-02
414

515
### Added

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ resolver = "3"
33
members = ["spec-core", "spec-cli"]
44

55
[workspace.package]
6-
version = "0.2.0"
6+
version = "0.2.1"
77
edition = "2024"
88
rust-version = "1.89.0"
99
authors = ["Spec Authors"]

TODOS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,17 @@
5151
### Deferred from autoplan retrospective (2026-04-03)
5252

5353
#### Security / Correctness (High Priority)
54-
- [ ] **Fix `is_safe_expect_expr` to recurse into sub-expressions**Current implementation checks top-level expression variant but not children. `f({ unsafe { ... } })` passes because outer `Call` returns true without inspecting args. Fix: recurse into `Binary`, `Call`, `MethodCall`, `Field`, `Index`, `Unary`, `Cast` sub-expressions. Add tests: `expect_with_unsafe_block_in_call_arg_is_rejected`, `expect_with_block_in_binary_operand_is_rejected`. (validator.rs:148)
54+
- [x] **Fix `is_safe_expect_expr` to recurse into sub-expressions**Completed fix/change-is_safe_expect_expr. `f({ unsafe { ... } })` now rejected. Added regression tests: `expect_with_unsafe_block_in_call_arg_is_rejected`, `expect_with_block_in_binary_operand_is_rejected`, `expect_with_unsafe_block_in_method_call_arg_is_rejected`. (validator.rs:148)
5555

5656
#### Architecture Fixes (Medium Priority)
5757
- [ ] **Consolidate path-containment logic**`clean_output_dir` uses `normalized_absolute_path` (lexical) and `ensure_output_marker` uses `canonicalize` (symlink-following). Divergent logic = future maintenance hazard. Extract single `safe_output_path(path) -> Result<PathBuf>` utility. (generator.rs, commands.rs)
5858
- [ ] **Add `local_tests[].id` uniqueness validation** — Duplicate ids within a unit → duplicate `fn test_{id}()` → compile error. Validate uniqueness in `validate_local_test_expects`. (validator.rs)
5959
- [ ] **Document `pub use generated::*` as required consuming-crate convention** — Internal deps generate `use crate::X` which only works if consuming crate re-exports generated modules at root. Document in README/DECISIONS.md. Not a code change.
6060

61+
#### Architecture (Medium Priority)
62+
- [ ] **Defense-in-depth: validate local_tests[].expect at the sink**`generate_code` (generator.rs:19) is a public library function that embeds `local_test.expect` verbatim with no validation. The CLI path always validates first via the loader, but a direct library API caller constructing a `ResolvedSpec` manually bypasses all expression validation. Consider: (a) validate at the `generate_code` sink, (b) use a newtype wrapper for validated expect strings, or (c) emit the generated assert!() from the validated syn::Expr AST instead of the raw string. Codex outside-voice finding, fix/change-is_safe_expect_expr review.
63+
- [ ] **Add recursion depth cap to `is_safe_expect_expr`** — The recursive AST walk has no depth limit. Deeply nested input like `((((x))))` or `!!!!!x` (100+ levels) could stack overflow during validation. Add a `depth: usize` parameter and return `false` above a threshold (~128). Low-urgency since `.unit.spec` files are trusted input, but the fix is trivial. Codex adversarial finding, fix/change-is_safe_expect_expr review.
64+
6165
#### M3 Prerequisites (Design Spikes, Before Build)
6266
- [ ] **Define ICP: solo engineer vs. team coordination tool** — Changes M3 priority order completely. One paragraph, before M3 scoping.
6367
- [ ] **Force binary decision: commit generated output vs ephemeral** — Current hybrid (gitignored but required to compile) is unstable for real adopters. Decide and document in DECISIONS.md.

spec-core/src/validator.rs

Lines changed: 265 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,17 @@ fn validate_local_test_expects(spec: &LoadedSpec) -> Result<()> {
130130
path: path.clone(),
131131
}
132132
})?;
133-
// Only allow expression kinds that are safe to embed verbatim into assert!().
134-
// Reject scope-creating forms (Block, Unsafe, Closure, If, Match, Loop, etc.)
135-
// that could execute arbitrary code. A config lever for trusted workspaces is
136-
// deferred to M3. See TODOS.md.
133+
// Reject syntax-level injection vectors: block expressions, unsafe blocks,
134+
// closures, and control-flow forms (If, Match, Loop, etc.) that introduce
135+
// statements or scope inside assert!(). Note: call expressions to
136+
// side-effectful functions (std::fs, std::process::Command, etc.) are not
137+
// blocked — .unit.spec files are treated as trusted input, the same as
138+
// body.rust. A config lever and defense-in-depth options are deferred to
139+
// M3. See TODOS.md.
137140
if !is_safe_expect_expr(&expr) {
138141
return Err(SpecError::LocalTestExpectNotExpr {
139142
id: test.id.clone(),
140-
message: "expect must be a simple expression (binary, call, path, or literal); block, unsafe, closure, and control-flow expressions are not allowed".to_string(),
143+
message: "expect must use only operators, function calls, and value access; block, unsafe, closure, and control-flow forms are not allowed".to_string(),
141144
path: path.clone(),
142145
});
143146
}
@@ -147,14 +150,17 @@ fn validate_local_test_expects(spec: &LoadedSpec) -> Result<()> {
147150

148151
fn is_safe_expect_expr(expr: &syn::Expr) -> bool {
149152
match expr {
150-
syn::Expr::Binary(_)
151-
| syn::Expr::Call(_)
152-
| syn::Expr::MethodCall(_)
153-
| syn::Expr::Path(_)
154-
| syn::Expr::Lit(_)
155-
| syn::Expr::Field(_)
156-
| syn::Expr::Index(_)
157-
| syn::Expr::Unary(_) => true,
153+
syn::Expr::Binary(b) => is_safe_expect_expr(&b.left) && is_safe_expect_expr(&b.right),
154+
syn::Expr::Call(c) => {
155+
is_safe_expect_expr(&c.func) && c.args.iter().all(is_safe_expect_expr)
156+
}
157+
syn::Expr::MethodCall(m) => {
158+
is_safe_expect_expr(&m.receiver) && m.args.iter().all(is_safe_expect_expr)
159+
}
160+
syn::Expr::Field(f) => is_safe_expect_expr(&f.base),
161+
syn::Expr::Index(i) => is_safe_expect_expr(&i.expr) && is_safe_expect_expr(&i.index),
162+
syn::Expr::Unary(u) => is_safe_expect_expr(&u.expr),
163+
syn::Expr::Path(_) | syn::Expr::Lit(_) => true,
158164
syn::Expr::Paren(inner) => is_safe_expect_expr(&inner.expr),
159165
syn::Expr::Cast(c) => is_safe_expect_expr(&c.expr),
160166
// Block, Unsafe, Closure, If, Match, Loop, ForLoop, While, Async, etc. → rejected
@@ -779,8 +785,253 @@ pub fn apply_discount(subtotal: Decimal, rate: Decimal) -> Decimal {
779785
};
780786
let err = validate_semantic(&spec).unwrap_err().to_string();
781787
assert!(
782-
err.contains("simple expression"),
788+
err.contains("block, unsafe, closure"),
783789
"expected block expression to be rejected: {err}"
784790
);
785791
}
792+
793+
#[test]
794+
fn expect_with_unsafe_block_in_call_arg_is_rejected() {
795+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
796+
let spec = LoadedSpec {
797+
source: SpecSource {
798+
file_path: "test.unit.spec".to_string(),
799+
id: "pricing/apply_discount".to_string(),
800+
},
801+
spec: SpecStruct {
802+
id: "pricing/apply_discount".to_string(),
803+
kind: "function".to_string(),
804+
intent: Intent {
805+
why: "Apply a discount.".to_string(),
806+
},
807+
contract: None,
808+
deps: vec![],
809+
imports: vec![],
810+
body: Body {
811+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
812+
},
813+
local_tests: vec![LocalTest {
814+
id: "unsafe_in_call_arg".to_string(),
815+
expect: "f(unsafe { true })".to_string(),
816+
}],
817+
links: None,
818+
},
819+
};
820+
821+
let err = validate_semantic(&spec).unwrap_err().to_string();
822+
assert!(
823+
err.contains("block, unsafe, closure"),
824+
"expected unsafe block in call arg to be rejected: {err}"
825+
);
826+
}
827+
828+
#[test]
829+
fn expect_with_block_in_binary_operand_is_rejected() {
830+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
831+
let spec = LoadedSpec {
832+
source: SpecSource {
833+
file_path: "test.unit.spec".to_string(),
834+
id: "pricing/apply_discount".to_string(),
835+
},
836+
spec: SpecStruct {
837+
id: "pricing/apply_discount".to_string(),
838+
kind: "function".to_string(),
839+
intent: Intent {
840+
why: "Apply a discount.".to_string(),
841+
},
842+
contract: None,
843+
deps: vec![],
844+
imports: vec![],
845+
body: Body {
846+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
847+
},
848+
local_tests: vec![LocalTest {
849+
id: "block_in_binary_operand".to_string(),
850+
expect: "true && { false }".to_string(),
851+
}],
852+
links: None,
853+
},
854+
};
855+
856+
let err = validate_semantic(&spec).unwrap_err().to_string();
857+
assert!(
858+
err.contains("block, unsafe, closure"),
859+
"expected block expression in binary operand to be rejected: {err}"
860+
);
861+
}
862+
863+
#[test]
864+
fn expect_with_unsafe_block_in_method_call_arg_is_rejected() {
865+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
866+
let spec = LoadedSpec {
867+
source: SpecSource {
868+
file_path: "test.unit.spec".to_string(),
869+
id: "pricing/apply_discount".to_string(),
870+
},
871+
spec: SpecStruct {
872+
id: "pricing/apply_discount".to_string(),
873+
kind: "function".to_string(),
874+
intent: Intent {
875+
why: "Apply a discount.".to_string(),
876+
},
877+
contract: None,
878+
deps: vec![],
879+
imports: vec![],
880+
body: Body {
881+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
882+
},
883+
local_tests: vec![LocalTest {
884+
id: "unsafe_in_method_arg".to_string(),
885+
expect: "foo.bar(unsafe { true })".to_string(),
886+
}],
887+
links: None,
888+
},
889+
};
890+
891+
let err = validate_semantic(&spec).unwrap_err().to_string();
892+
assert!(
893+
err.contains("block, unsafe, closure"),
894+
"expected unsafe block in method call arg to be rejected: {err}"
895+
);
896+
}
897+
898+
#[test]
899+
fn expect_with_unsafe_block_in_field_base_is_rejected() {
900+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
901+
let spec = LoadedSpec {
902+
source: SpecSource {
903+
file_path: "test.unit.spec".to_string(),
904+
id: "pricing/apply_discount".to_string(),
905+
},
906+
spec: SpecStruct {
907+
id: "pricing/apply_discount".to_string(),
908+
kind: "function".to_string(),
909+
intent: Intent {
910+
why: "Apply a discount.".to_string(),
911+
},
912+
contract: None,
913+
deps: vec![],
914+
imports: vec![],
915+
body: Body {
916+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
917+
},
918+
local_tests: vec![LocalTest {
919+
id: "unsafe_in_field_base".to_string(),
920+
expect: "(unsafe { foo }).field".to_string(),
921+
}],
922+
links: None,
923+
},
924+
};
925+
926+
let err = validate_semantic(&spec).unwrap_err().to_string();
927+
assert!(
928+
err.contains("block, unsafe, closure"),
929+
"expected unsafe block in field base to be rejected: {err}"
930+
);
931+
}
932+
933+
#[test]
934+
fn expect_with_unsafe_block_in_index_is_rejected() {
935+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
936+
let spec = LoadedSpec {
937+
source: SpecSource {
938+
file_path: "test.unit.spec".to_string(),
939+
id: "pricing/apply_discount".to_string(),
940+
},
941+
spec: SpecStruct {
942+
id: "pricing/apply_discount".to_string(),
943+
kind: "function".to_string(),
944+
intent: Intent {
945+
why: "Apply a discount.".to_string(),
946+
},
947+
contract: None,
948+
deps: vec![],
949+
imports: vec![],
950+
body: Body {
951+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
952+
},
953+
local_tests: vec![LocalTest {
954+
id: "unsafe_in_index".to_string(),
955+
expect: "arr[unsafe { 0 }]".to_string(),
956+
}],
957+
links: None,
958+
},
959+
};
960+
961+
let err = validate_semantic(&spec).unwrap_err().to_string();
962+
assert!(
963+
err.contains("block, unsafe, closure"),
964+
"expected unsafe block in index to be rejected: {err}"
965+
);
966+
}
967+
968+
#[test]
969+
fn expect_with_unsafe_block_in_unary_is_rejected() {
970+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
971+
let spec = LoadedSpec {
972+
source: SpecSource {
973+
file_path: "test.unit.spec".to_string(),
974+
id: "pricing/apply_discount".to_string(),
975+
},
976+
spec: SpecStruct {
977+
id: "pricing/apply_discount".to_string(),
978+
kind: "function".to_string(),
979+
intent: Intent {
980+
why: "Apply a discount.".to_string(),
981+
},
982+
contract: None,
983+
deps: vec![],
984+
imports: vec![],
985+
body: Body {
986+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
987+
},
988+
local_tests: vec![LocalTest {
989+
id: "unsafe_in_unary".to_string(),
990+
expect: "!(unsafe { true })".to_string(),
991+
}],
992+
links: None,
993+
},
994+
};
995+
996+
let err = validate_semantic(&spec).unwrap_err().to_string();
997+
assert!(
998+
err.contains("block, unsafe, closure"),
999+
"expected unsafe block in unary operand to be rejected: {err}"
1000+
);
1001+
}
1002+
1003+
#[test]
1004+
fn expect_with_unsafe_block_in_cast_is_rejected() {
1005+
use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1006+
let spec = LoadedSpec {
1007+
source: SpecSource {
1008+
file_path: "test.unit.spec".to_string(),
1009+
id: "pricing/apply_discount".to_string(),
1010+
},
1011+
spec: SpecStruct {
1012+
id: "pricing/apply_discount".to_string(),
1013+
kind: "function".to_string(),
1014+
intent: Intent {
1015+
why: "Apply a discount.".to_string(),
1016+
},
1017+
contract: None,
1018+
deps: vec![],
1019+
imports: vec![],
1020+
body: Body {
1021+
rust: "pub fn apply_discount() -> bool { true }".to_string(),
1022+
},
1023+
local_tests: vec![LocalTest {
1024+
id: "unsafe_in_cast".to_string(),
1025+
expect: "(unsafe { 0 }) as u64".to_string(),
1026+
}],
1027+
links: None,
1028+
},
1029+
};
1030+
1031+
let err = validate_semantic(&spec).unwrap_err().to_string();
1032+
assert!(
1033+
err.contains("block, unsafe, closure"),
1034+
"expected unsafe block in cast to be rejected: {err}"
1035+
);
1036+
}
7861037
}

0 commit comments

Comments
 (0)