Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ jobs:
# binary and reports UNMEASURABLE rather than passing without it.
ninja -C build naab-lang libnaab naab-verify-audit naab-gov -j$(nproc)

# naab_unit_tests (GoogleTest) was the same kind of unbuilt target: five
# of its nineteen files had stopped compiling before anyone ran it.
# Exclusions are in tests/unit/known_failures.txt, each tied to
# docs/unit-test-findings.md; the runner also fails if an excluded test
# starts passing, so the list cannot outlive its reasons.
- name: Unit tests
run: |
ninja -C build naab_unit_tests -j$(nproc)
bash tests/unit/run_unit_tests.sh

- name: Run tests
run: bash run-all-tests.sh

Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ bash run-all-tests.sh # from the repo root

# Security leak check — 874 checks, 0 failures
bash tests/security/test_error_msg_leaks.sh

# GoogleTest unit tests (also in CI) — exclusions in tests/unit/known_failures.txt
cd build && make naab_unit_tests -j4 && cd .. && bash tests/unit/run_unit_tests.sh
```

Test categories in `tests/` (non-exhaustive — 40+ directories total): governance_v4, security, stdlib, vm, cli, e2e, integration, bugs, gorilla, scanner, formatter, lsp, platform, chaos, robustness, agent, unit, property.
Expand Down Expand Up @@ -250,7 +253,7 @@ include/naab/ All headers
- `src/runtime/language_registry.cpp` — executor registration
- `<<python ... >>` syntax — `>>` must be at line start to close block
- Executor base: `executeWithReturn()`/`callFunction()` use NaabVal
- **Subprocess containment** (`src/runtime/subprocess_helpers.h/cpp`): `SubprocessContainment` struct applied to all polyglot child processes via `execute_subprocess_with_pipes()`. 5-layer defense: RLIMIT_NPROC=0 (blocks fork/subprocess), PATH restriction, env scrubbing, timeout (SIGKILL), allow_exec/allow_fork flags. `SubprocessContainment::fromCurrentSandbox()` reads current sandbox level: `standard` → fork+exec blocked, `elevated` → fork allowed, `unrestricted` → no restrictions. Pre-execution source scanning (governance checks) catches static patterns; containment catches runtime-constructed commands that evade static analysis.
- **Subprocess containment** (`src/runtime/subprocess_helpers.h/cpp`): `SubprocessContainment` struct applied to all polyglot child processes via `execute_subprocess_with_pipes()`. The memory budget is `RLIMIT_DATA` (private writable memory), with `RLIMIT_AS` only as a backstop ceiling (4x the budget, at least 2 GB) for shared anonymous mappings, which `RLIMIT_DATA` does not count. It used to be `RLIMIT_AS` at the budget, which counts untouched reservations: node 22 needs 512-768 MB of address space just to start, so `process.run("node", ...)` died with "Failed to reserve virtual memory" under `elevated`. Test: `tests/security/test_child_memory_limit.sh` (M-02/M-03 are why the fix is not "drop the limit"). 5-layer defense: RLIMIT_NPROC=0 (blocks fork/subprocess), PATH restriction, env scrubbing, timeout (SIGKILL), allow_exec/allow_fork flags. `SubprocessContainment::fromCurrentSandbox()` reads current sandbox level: `standard` → fork+exec blocked, `elevated` → fork allowed, `unrestricted` → no restrictions. Pre-execution source scanning (governance checks) catches static patterns; containment catches runtime-constructed commands that evade static analysis.

### Stdlib Notable
- `array.sort()` mutates in place, `array.sorted()` returns a new sorted array (non-mutating)
Expand Down Expand Up @@ -412,4 +415,5 @@ Always run `bash tests/security/test_error_msg_leaks.sh` after changing any erro
- **Governance is discovered PER PROCESS from the script's own directory, so a spawned interpreter is governed by whatever config sits next to the CHILD -- or by none.** `capabilities.shell.enabled: true` therefore means project governance is ADVISORY for anything that can spawn: a program writes a `.naab` file somewhere no `govern.json` is discoverable, runs `naab-lang --no-governance` on it, and the child reads what the parent is HARD-blocked from. Measured on `62244b3` with a **validly signed** parent config, `sandbox_level: elevated`: the parent's own `file.read("secret.txt")` is blocked at exit 3 (the control), while the spawned child prints `CHILD_READ:POLICY_SECRET` and the parent exits 0. **Signing does not help** -- the implicit bypass-flag block at `main.cpp:1583` fires only for a config the process actually discovers, and the child discovers none; `integrity.blocked_flags` is inert for the same reason. `--no-governance` cannot simply be removed either: `global_require_governance` defaults **true** (`main.cpp:560`), so waiving the "no govern.json found" error is the one job nothing else does. **`blocked_commands: ["naab-lang"]` is NOT containment** -- it matches the command line, so a renamed copy of the same binary walks straight past it (measured: literal name blocked, `cp naab-lang mytool` ESCAPED). A substring match on a command line looks like containment in a table and is not; same shape as `shell_allowed`'s content scan versus its execution gate, which this file already separates for exactly this reason. The containment that works is `capabilities.shell.enabled: false`, because it removes SYS_EXEC at the SANDBOX layer rather than matching a string -- verified to stop the renamed binary too (`SANDBOX VIOLATION`, exit 1, both spellings). This is the logical extreme of the `process.run` note above: that pipeline keeps taint and BSD; this one keeps nothing, because the child is a fresh process with a fresh (empty) policy. Do NOT "fix" it by having the parent pass its config path down -- a child chooses its own arguments, so that is a request, not a gate.
- **A polyglot block's stdout is captured, not forwarded — assert on side effects.** A test that runs `<<python print("TOKEN") >>` and greps the parent's stdout for `TOKEN` sees nothing even when the block ran perfectly. The first draft of `test_polyglot_gate_coverage.sh` did exactly that and reported fifteen of nineteen languages UNMEASURABLE, including several already measured as running. Have the block WRITE A FILE and assert on the file: it is observable, and under a restricted sandbox it is also a write the configuration does not permit, which is the stronger claim anyway.
- **The filesystem gate is a hand-written module allowlist, and it has been short three times.** `GovernanceEngine::filesystemAccessMode(module, method)` is the ONLY thing that decides whether a stdlib call gets `checkFilesystemAllowed()` + `checkPathAccess()`; both engines (`vm.cpp` OP_CALL_METHOD, `call_dispatch.cpp`) skip the check when it returns `""`. It shipped knowing only `file`, gained `csv`/`log`, and was still missing `io`, `env` and `path` — so `io.read_file`/`write_file`/`exists`/`list_dir`, `env.load_dotenv` and `path.exists`/`path.resolve` reached the disk with no `blocked_paths`/`allowed_paths` check at all. Their implementations call `Sandbox::canRead`/`canWrite` and stop, and **the sandbox is an allowlist with no concept of `blocked_paths`** (`sandbox.cpp` `canRead`/`canWrite`), so the project path policy was simply not in the path. Measured on `fb1e4bd` with `filesystem.mode:"write"` + `blocked_paths:["vault/"]`: `file.read` exit 3, `io.read_file` exit 0 returning the contents, on BOTH engines — and `io.write_file("govern.json", ...)` overwrote the policy file in its own run under `Governance: PASS`, because `addGovernanceProtectedPaths()` only protects what reaches `checkPathAccess`. Note `filesystem.mode:"none"` DID stop these, but via the sandbox (exit 1, catchable), not governance (exit 3) — which is why the gap survives any audit that only tests the off switch. **`io` is why this must stay an explicit per-method allowlist and never gain a write-by-default fallback like `file` has**: `io.write`/`output`/`write_error`/`print`/`println`/`log` are CONSOLE calls, and a fallback would gate every print statement in the language. When adding a stdlib module that opens a file, add it here or it is ungoverned. A default argument is invisible to this gate — it classifies the path the caller WROTE, so `env.load_dotenv()`'s `.env` default is resolved and checked inside `env_impl.cpp` instead. The same rule settled `path`: `exists` stats and `resolve` calls `fs::canonical` (disclosing symlink targets), while `join`/`dirname`/`basename`/`normalize` are lexical and must not be gated. Test: `tests/governance_v4/test_io_env_fs_gate.sh` (IE-06 and IE-12 are load-bearing — without it a blanket deny on `module=="io"` passes every other arm; IE-09 isolates the default-argument half).
- **Timeouts nest through `ScopedTimeout` — never call `setExecutionTimeout()`/`clearTimeout()` around a block.** There is one timer per thread; the CLI and REST wrap the whole run in a `ScopedTimeout`, and executors wrap each block in another. Before the fix every inner scope re-armed that timer and CLEARED it on exit, so one `<<javascript>>` expression (or `codegen.run`) removed `--timeout` for the rest of the script — a `while true` after it ran until killed from outside, both engines. `ScopedTimeout` now reads the outer deadline (`ResourceLimiter::currentDeadline()`), only ever TIGHTENS it, and restores it on exit; `0` means "no limit of its own" (it used to arm a zero-second timer). The cancel counter is per thread, because a process-wide one let a scope on another thread cancel the main thread's timer. Anything that runs in-process without polling `isTimeoutTriggered()` needs its own interrupt: QuickJS has an interrupt handler, embedded CPython gets a pending call queued from the timer thread via `setTimeoutInterruptHook()` (on 3.11 this also needs a brief GIL acquire, because `Py_AddPendingCall` from a non-main thread does not flag the eval loop), and `http.*` aborts through a curl progress callback. Known limits: Python blocked inside C (`time.sleep`) and Python on worker threads are not interrupted. Test: `tests/security/test_timeout_reach.sh` (P-04 is the control; the N arms assert on the loop AFTER the block).
- **GovernanceHardError catch-and-rethrow required** — any new `catch (const std::exception&)` or `catch (const std::runtime_error&)` in interpreter.cpp or vm.cpp that could intercept governance exceptions MUST have a preceding `catch (const governance::GovernanceHardError&) { throw; }`. Without this, HARD governance violations become catchable by NAAb try/catch.
82 changes: 69 additions & 13 deletions docs/unit-test-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,57 @@ stall is most likely the runner wedge `windows.yml` documents. Measured on that
run: `test_r22_fixes.sh` alone took about 6 of the 9m39s shell phase (the slow
`naab-gov scan` of an 11 MB file).

**Open (2a):** embedded Python.
**Fixed (2a):** the timeout's timer thread now queues a CPython pending call
that raises `TimeoutError` in the running block, and re-queues itself while the
timeout stands, so `except Exception: pass` in a loop cannot swallow it.
Queuing alone was measured to do nothing on CPython 3.11: `Py_AddPendingCall`
computes the eval breaker on the *calling* thread, where "can handle pending
calls" is false, so the loop is never told. A brief GIL acquire from the timer
thread makes the running thread re-take the GIL and recompute the breaker on
its own thread (skipped on Android, where `PyGILState_Ensure` on a foreign
thread is the bionic CFI crash). Measured: the 20 s busy loop stops at 3.06 s
in both engines. **Limits:** a block blocked inside C (`time.sleep`, a socket
read) is not interrupted, since CPython only runs pending calls between
bytecodes (`time.sleep(15)` took 15 s on both builds), and Python on a worker
thread is not interrupted, since CPython runs pending calls on its main thread
only.

### 2c. One block cancelled the script's `--timeout` (found while fixing 2a)

There is one timer per thread. The CLI and REST wrap the run in a
`ScopedTimeout`, and the JS, shell, subprocess and C++ executors wrap every
block in another. Each scope re-armed the timer with its own budget and its
destructor **cleared** it, so after a single `<<javascript>>` expression the
script had no timeout at all. Measured, `--timeout 3`: a `while true` after
`let v = <<javascript 1 + 1 >>` ran until killed from outside, in both
engines. `codegen.run` did the same by calling `setExecutionTimeout` /
`clearTimeout` directly. A second layer: the cancel counter was process-wide,
so a scope on another thread (the tree-walker runs this JS off the main
thread) cancelled the main thread's timer, and a REST request could cancel a
concurrent request's.

**Fixed:** `ScopedTimeout` nests. A scope can only tighten the deadline in
force, never extend it, and restores the outer deadline on exit. The cancel
counter is per thread. `0` now means "no limit of its own", which also fixes
the zero-second timer in section 3 (it killed every subprocess on start under
the `UNRESTRICTED` preset).

### 2d. `http.*` outlived `--timeout` (was section 5, unmeasured)

Measured once an address that drops SYNs was found (`8.8.8.8:81`):
`http.get(url, {}, 0)` was still connecting 20 s into a `--timeout 3` run.
curl never returns to the interpreter mid-transfer, and `timeout_ms = 0` is
libcurl's "never". **Fixed:** a progress callback aborts the transfer when the
timeout fires (curl calls it at least once a second, including while
connecting), a non-positive `timeout_ms` falls back to the 30 s default, and
curl's own timeouts are capped at the time left before the script's deadline.
The cap is what holds on Windows: there the progress callback was not called
during connect, and the first CI run stopped at curl's 10 s connect timeout
instead of 3 s. With the callback disabled locally, the cap alone stops the
request at 3 s.

Regression suite for 2a, 2c and 2d: `tests/security/test_timeout_reach.sh`,
9 arms. Against the old code, all 8 non-control arms fail and the control passes.

## 3. Real but not reachable today

Expand All @@ -138,11 +188,11 @@ run: `test_r22_fixes.sh` alone took about 6 of the 9m39s shell phase (the slow
runs on whichever thread calls `.get()`, while another thread's `submit()`
runs `cleanupCompleted()` and erases wrappers it sees as done -- a wrapper
can be freed while its callback is still returning through it.
- **Zero means two things.** `PermissionLevel::UNRESTRICTED` sets
`max_cpu_seconds = 0` ("no limit"); the shell, JS, generic-subprocess and
persistent-process executors pass it to `ScopedTimeout(0)`, whose timer fires
immediately. The CLI and REST API always overwrite the value from
`--timeout`, so no measured path reaches it. Clamp at `ScopedTimeout` anyway.
- **Zero means two things** (fixed with 2c). `PermissionLevel::UNRESTRICTED`
sets `max_cpu_seconds = 0` ("no limit"); the shell, JS and generic-subprocess
executors passed it to `ScopedTimeout(0)`, whose timer fired immediately.
`ScopedTimeout(0)` now arms nothing. The persistent-process executor
converts it to a millisecond budget of its own and is unchanged.
- **`naab::Context` cannot run polyglot.** Executor registration
(`initialize_executors()`) lives in `src/cli/main.cpp`, not in `libnaab`, so an
embedder gets "No executor found" for every polyglot block. An API gap rather
Expand Down Expand Up @@ -170,13 +220,19 @@ run: `test_r22_fixes.sh` alone took about 6 of the 9m39s shell phase (the slow
- **Other `std::async` sites** (`vm.cpp`, `call_dispatch.cpp`) never abandon
their futures on a timeout, so they do not share SafeRegex's defect.

## 5. Unmeasured

- **`http.*` with `timeout_ms = 0`.** The script-supplied value goes straight
to `CURLOPT_TIMEOUT_MS`, which libcurl documents as "never time out", and
nothing clamps it to `--timeout`. Could not be measured here: SSRF protection
refuses loopback, and no external slow endpoint was available. *Code*, not
*measured*.
## 5. CI

`naab_unit_tests` now runs in CI (`ci.yml`, Build & Test) through
`tests/unit/run_unit_tests.sh`. The 30 known failures are listed in
`tests/unit/known_failures.txt`, each with its reason from this document. The
runner fails if an unlisted test fails, if a listed test no longer exists, or
if a listed `fails` entry starts passing, so the list has to shrink when
something is fixed. Three entries are `hang` (the `AsyncCallbackPool`
deadlocks and the use-after-free) and are not run. Two shell tests changed
status with 2c: `ShellWithTimeout` used to pass only because the zero-second
timer killed the command; it now shows the async-executor timeout defect. The
two `executeBlocking` shell tests are refused by the fail-closed sandbox,
because the callback thread has none (a fixture issue).

## 6. Lessons that generalise

Expand Down
9 changes: 9 additions & 0 deletions include/naab/error_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ std::string suggestDictKey(
const std::string& requested_key,
const std::vector<std::string>& actual_keys);

// Print the "[hint] dict.get(...) returned null" diagnostic for a miss with
// no default. Shared by both engines (three call sites). Capped per run: the
// hint exists for typos, but building a map from data -- counting files from
// git log -- misses on every new key, and one hint per distinct key buried
// real output under hundreds of lines. Thread-safe.
void hintDictGetMiss(
const std::string& requested_key,
const std::vector<std::string>& actual_keys);

} // namespace error
} // namespace naab

21 changes: 21 additions & 0 deletions include/naab/python_c_wrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,27 @@ char* python_c_object_to_string(void* obj);
*/
void python_c_warmup(void);

/**
* Execution-timeout interrupt.
*
* `--timeout` is a flag the NAAb interpreter polls; code running inside
* CPython never polls it, so a <<python>> busy loop ran to completion however
* long it took. python_c_request_interrupt() queues a CPython pending call
* that raises TimeoutError in the running code. It needs neither a thread
* state nor the GIL (so no PyGILState_Ensure on a foreign thread), and is
* called from the timeout's timer thread.
*
* The pending call re-checks `check` when it runs, so a stale request cannot
* fire into a later, unrelated block; while the timeout stands it re-queues
* itself, so `except Exception: pass` in a loop cannot swallow it.
*
* Limit: CPython runs pending calls on the MAIN thread only, so Python running
* on a worker thread (parallel polyglot groups) is not interrupted.
*/
typedef int (*NaabPyTimeoutCheckFn)(void);
void python_c_set_timeout_check(NaabPyTimeoutCheckFn check);
void python_c_request_interrupt(void);

/**
* Shutdown Python interpreter (call once from main thread)
*
Expand Down
Loading
Loading