The same helper function, called from main, sees its sandbox grant honoured; called from inside a spec.describe block, the identical grant is denied.
Repro (~35 lines, no external deps)
import std.spec
import std.audit
import std.list
import std.os
fn add_perm(t: ptr, c: string, p: string) {
list.add(t, c)
list.add(t, p)
}
fn run_sandboxed(perms: ptr, code: fn) {
sandbox_push(perms)
sandbox_install()
call(code, perms)
sandbox_uninstall()
sandbox_pop()
}
fn check(label: string) {
audit.clear()
perms = list.new()
add_perm(perms, "env", "HOME") // grant env:HOME
w = | _ctx: ptr | { os_getenv("HOME") }
run_sandboxed(perms, w)
c, r, a = audit.entry(0)
println(" ${label}: ${r} allowed=${a}")
}
main() {
fw = spec.init()
check("outside describe")
spec.describe(fw, "d") {
check("inside describe, not in it")
spec.it("t") callback {
check("inside it")
}
}
spec.run_summary(fw)
}
outside describe: HOME allowed=1 <- grant honoured
inside describe, not in it: HOME allowed=0 <- same grant, denied
inside it: HOME allowed=0
Measured on main @ 984236e, Linux x86_64.
What is and isn't the trigger
Bisected down from a failing test suite:
| shape |
result |
helper called from main |
allowed ✅ |
helper called from a plain fn |
allowed ✅ |
inside one plain trailing block (outer() callback { … }) |
allowed ✅ |
| inside two nested plain trailing blocks |
allowed ✅ |
after spec.init() but outside describe |
allowed ✅ |
inside spec.describe(fw, "d") { … } |
denied ❌ |
inside spec.it(...) callback { … } within a describe |
denied ❌ |
So it is not trailing blocks as such, not closure nesting depth, and not spec.init. It is specifically the describe block — and it inherits it by being nested inside one. Note the third row shows the failure occurs in the describe body directly, before any it runs.
One structural difference worth pointing a debugger at: describe is declared -> ptr and returns a suite handle, so its trailing block is a value-producing closure, unlike the -> void wrappers in the passing rows. It also runs println and a ref_set on the framework's depth cell before the body executes.
Why it matters beyond tests
The failure mode is the dangerous direction: a grant that is silently ignored. Code that runs correctly at top level starts denying its own permitted operations when moved inside a describe, with no diagnostic — the audit trail records allowed=0 and the program takes its denial path.
That makes std.spec unable to test sandboxed behaviour at all, which is how this was found: writing the co-located std.audit suite for #1584. Any future suite covering std.audit, containment, or a sandboxed integration will hit the same wall.
Workaround
None found that keeps the assertions inside describe. A suite can drive the sandbox from main and assert on the recorded trail afterwards, but that gives up per-case isolation and the it reporting.
I have parked the std.audit suite rather than write it against behaviour I know to be wrong — std.audit is currently the only std module with no coverage anywhere, and this is why.
Environment
main @ 984236e, ae 0.567.0, Linux x86_64, gcc
- Not yet checked on macOS or Windows
🤖 Generated with Claude Code
The same helper function, called from
main, sees its sandbox grant honoured; called from inside aspec.describeblock, the identical grant is denied.Repro (~35 lines, no external deps)
Measured on
main@ 984236e, Linux x86_64.What is and isn't the trigger
Bisected down from a failing test suite:
mainfnouter() callback { … })spec.init()but outsidedescribespec.describe(fw, "d") { … }spec.it(...) callback { … }within a describeSo it is not trailing blocks as such, not closure nesting depth, and not
spec.init. It is specifically thedescribeblock — anditinherits it by being nested inside one. Note the third row shows the failure occurs in thedescribebody directly, before anyitruns.One structural difference worth pointing a debugger at:
describeis declared-> ptrand returns a suite handle, so its trailing block is a value-producing closure, unlike the-> voidwrappers in the passing rows. It also runsprintlnand aref_seton the framework's depth cell before the body executes.Why it matters beyond tests
The failure mode is the dangerous direction: a grant that is silently ignored. Code that runs correctly at top level starts denying its own permitted operations when moved inside a
describe, with no diagnostic — the audit trail recordsallowed=0and the program takes its denial path.That makes
std.specunable to test sandboxed behaviour at all, which is how this was found: writing the co-locatedstd.auditsuite for #1584. Any future suite coveringstd.audit, containment, or a sandboxed integration will hit the same wall.Workaround
None found that keeps the assertions inside
describe. A suite can drive the sandbox frommainand assert on the recorded trail afterwards, but that gives up per-case isolation and theitreporting.I have parked the
std.auditsuite rather than write it against behaviour I know to be wrong —std.auditis currently the onlystdmodule with no coverage anywhere, and this is why.Environment
main@ 984236e, ae 0.567.0, Linux x86_64, gcc🤖 Generated with Claude Code