Skip to content

Commit b3dfd41

Browse files
committed
feat(s110): Lua in the BEAM, under limits the machine enforces
A run_lua tool and skill-owned scripts run Lua inside this process: no operating system process, no filesystem handle, no socket. A model asked to total a column produces a number that looks right; a script produces one that is right and leaves the script in the transcript. The limits are the virtual machine's rather than a promise. The runner sets its own max_heap_size and is killed at the boundary without this code being involved, a wall clock bounds the run, and the pool refuses an over-limit run by name rather than queueing it invisibly, because a run that waits an unbounded time to start has no wall-clock bound at all. One limit is reported rather than claimed. luerl_sandbox enforces max_reductions by polling with a 100 ms sleep between checks, so caps of 1,000 and of 1,000,000 both stop a tight loop at 20 to 34 million reductions. Reductions are a statistic here and the bound that stops a runaway script is time; docs/sandbox.md carries the measurements rather than the claim. What a script wants done it asks for through Effects.Runner.execute/3, the executor a model's own call uses, so it is decided by the same gate and leaves the same receipts. A test asserts a denied call gives the script nothing, because that is what would make Lua a second door. A call needing a person fails immediately rather than holding the sandbox open: a run is bounded in seconds and a person is not, and letting a script block would turn every approval into a timeout. A skill may ship a script under its own scripts/ directory. Where it may read is checked by resolving the path rather than trusting it, so a traversal is caught by where it lands. script_path/2 is public so the test exercises the real function; the first version re-implemented the check and asserted against its own copy, which is the one way to make a security test meaningless. The skill scanner gained a sandbox_escape rule, and both the code and the test say it is a reviewer's signal rather than a control: the control is that the globals are absent at run time, which the sandbox tests assert one by one. Three corrections to the slice's own assumptions, all from measurement before code: luerl_sandbox is a module of luerl rather than a package, lua_entry has existed in the skills parser since slice 040, and AC1's reduction bound is not one this library delivers. mix gate: 794 passed (14 properties, 780 tests), 18 excluded. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 90c3d0a commit b3dfd41

13 files changed

Lines changed: 446 additions & 17 deletions

File tree

‎.sobelow-skips‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:14,1DC4768
22
Traversal.FileModule: Directory Traversal in `File.mkdir_p`,lib/trinity/data_dir/lock.ex:60,4ECF02C
33
Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/data_dir/lock.ex:146,5EFB666
4+
Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/sandbox/skill.ex:75,30481DE
45
Traversal.FileModule: Directory Traversal in `File.rm`,lib/trinity/data_dir/lock.ex:85,7D2B826
56
Traversal.FileModule: Directory Traversal in `File.write`,lib/trinity/data_dir/lock.ex:135,42D2817

‎.sobelow-skips.reasons‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
5EFB666 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock read/1: reads <data_dir>/LOCK, a path this module composes from the data directory and a constant file name; no caller supplies it.
1515
7D2B826 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock.release/2: removes <data_dir>/LOCK only when the file carries this process's own token; same composed path as the read.
1616
42D2817 Rekeyed at slice 013 (the moduledoc grew three lines for the macOS and Windows liveness check). Slice 010, Trinity.DataDir.Lock take_over/2: rewrites <data_dir>/LOCK after the recorded holder's pid is found dead; same composed path, same constant name.
17+
30481DE Slice 110, the skill script runner: the path sobelow cannot trace is the return of `Trinity.Sandbox.Skill.script_path/2`, which resolves it with `Path.expand/2` and refuses anything landing outside the skill's own `scripts/` directory. sobelow sees a function parameter and stops there; it cannot follow the containment into the previous call. The check is public precisely so it can be tested rather than trusted, and `test/trinity/sandbox/skill_test.exs` asserts the traversal case, the absolute-path case and the pathless-skill case against the real function rather than a copy of it. This is the only finding sobelow reports in the tree.

‎CHANGELOG.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,41 @@ evidence for each increment is retained by the maintainers and summarised here.
2424

2525
## 2026-09-24
2626

27+
### `slice/110` — Executable skills in an in-process sandbox
28+
Skills that can execute need a real programming surface. Trinity now has one that costs no trust: a
29+
`run_lua` tool and skill-owned scripts run Lua inside this process, with no operating system
30+
process, no filesystem handle and no socket. A model asked to total a column produces a number that
31+
looks right; a script produces one that is right and leaves the script in the transcript.
32+
33+
The limits are the virtual machine's rather than a promise. The runner sets its own heap ceiling and
34+
the machine kills it at the boundary; a wall clock bounds the run; the pool refuses an over-limit run
35+
by name instead of queueing it invisibly, because a run that waits an unbounded time to start has no
36+
wall-clock bound at all.
37+
38+
**One limit is reported rather than claimed, and the measurement is published.** The library offers a
39+
reduction cap enforced by polling every hundred milliseconds, so a cap of a thousand and a cap of a
40+
million both stop a tight loop at twenty to thirty-four million reductions. Reductions are therefore
41+
a statistic here and the bound that stops a runaway script is time. `docs/sandbox.md` carries the
42+
numbers.
43+
44+
What a script wants *done* it asks for through the same executor a model's own tool call uses, so it
45+
is decided by the same permission gate and leaves the same receipts. There is no second door, and a
46+
test asserts a denied call gives the script nothing. A call needing a person fails immediately rather
47+
than holding the sandbox open, because a run is bounded in seconds and a person is not: letting a
48+
script block would turn every approval into a timeout.
49+
50+
A skill may ship a script of its own. Where that script may read is checked by resolving the path
51+
rather than by trusting it, so a traversal is caught by where it lands rather than by what it looks
52+
like. The check is public so the test exercises the real function rather than a copy of it.
53+
54+
The skill scanner gained a rule for scripts reaching at what the sandbox removes. It is a reviewer's
55+
signal and the changelog says so: the control is that those globals are absent at run time, and a
56+
regular expression over source text would be a poor last line.
57+
58+
`docs/sandbox.md` states what this does not protect against as plainly as what it does, including
59+
that the sandbox constrains the script and the permission gate constrains the effect, and that
60+
reading the first as a guarantee about the second is the mistake the page exists to prevent.
61+
2762
### `slice/014` — The prompt window is the recent conversation
2863
A session that held more than five hundred messages was building every subsequent request from its
2964
**first** five hundred. The query that fetched the window ordered oldest-first and then took a

‎README.md‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ desktop application. Apache-2.0, developed in the open from the first commit.
3737

3838
## Status
3939

40-
Pre-alpha, and usable from source. Milestones M0 to M4 are approved, **M5a Automates** with them
41-
(050, the scheduler; 059, the MCP measurement; 060, the MCP client; 061, the MCP server; 062, MCP
42-
authorization), **M5b Reaches** in part (070, the gateway core; 080, subagents) and the first slice
43-
of **M6 Ships** (090, observability and the cost ledger), with 002 (the supply chain slice) beside
44-
them: 29 slices, each merged with a merge commit and tagged `slice/NNN`
45-
(`git tag -l 'slice/*' | wc -l` → 30, on 2026-09-24: one more tag than slices, because
40+
Pre-alpha, and usable from source. Milestones **M0** to **M4** are approved, **M5a Automates** with
41+
them (050, the scheduler; 059, the MCP measurement; 060, the MCP client; 061, the MCP server; 062,
42+
MCP authorization), **M5b Reaches** in part (070, the gateway core; 080, subagents), the first slice
43+
of **M6 Ships** (090, observability and the cost ledger), **M7 Sandboxed** (110, executable skills in
44+
an in-process sandbox), and the first slice of **M9 Donatable** (120, the open-source hygiene audit),
45+
with 002 (the supply chain slice) beside them: 36 slices, each merged with a merge commit and tagged
46+
`slice/NNN` (`git tag -l 'slice/*' | wc -l` → 37, on 2026-09-24: one more tag than slices, because
4647
`slice/090` was pushed at the wrong commit before its merge landed and this repository's ruleset
4748
forbids moving or deleting a tag; the correct one is `slice/090.1`, and `git tag -n20 slice/090.1`
4849
says so in its own annotation rather than leaving a reader to work it out). What that means in
@@ -134,6 +135,16 @@ enforces it.
134135
by model, by session, by persona, against budgets that warn and, when you ask them to, refuse.
135136
`/activity` shows the live stream with the spend beside it; a LiveDashboard page lists the
136137
running sessions, asking each process its state rather than trusting a row.
138+
- **Computes, rather than estimates.** A `run_lua` tool runs a Lua script inside this process: no
139+
operating system process, no filesystem handle, no socket. A model asked to total a column
140+
produces a number that looks right; a script produces one that is right and leaves the script in
141+
the transcript for anyone to check. The limits are the virtual machine's rather than a promise:
142+
the runner sets its own heap ceiling and is killed at it, a wall clock bounds the run, and the
143+
pool refuses an over-limit run by name instead of queueing it invisibly. What a script wants
144+
*done* it asks for through `trinity.tool`, which is decided by the same permission gate and leaves
145+
the same receipts as any other call, so there is no second door. A skill can ship a script of its
146+
own, and where that script may read is checked by resolving the path rather than by trusting it.
147+
`docs/sandbox.md` states what this does **not** protect against as plainly as what it does.
137148
- **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint,
138149
and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the
139150
model turns into cron). Each run is a fresh conversation you can open, its result waits on
@@ -144,8 +155,9 @@ enforces it.
144155

145156
Not there yet: the messaging platforms themselves (071 Telegram and 072 Discord, which need a bot
146157
token the project does not yet hold), the native desktop shell and signed releases (the rest of M6),
147-
executable skills in a sandbox (M7). The [Milestones](#milestones) section below sets out the order
148-
the remaining work is being built in.
158+
store-and-forward receipts for disconnected operation (026, waiting on an answer from the external
159+
authority plane). The [Milestones](#milestones) section below sets out the order the remaining work
160+
is being built in.
149161

150162
The interface is a local web page; the desktop shell exists as a packaging spike, not a product.
151163
If you want to follow along, watch the roadmap and the tags.
@@ -244,7 +256,8 @@ rather than described by it.
244256
| Auditability | Decisions and effects are recorded in an Ed25519-signed hash chain with checkpoints and a verifier, so an operator can reconstruct what was done, by whom and under what decision. |
245257
| Untrusted content | Anything arriving from outside the machine is marked untrusted at the boundary and is never treated as instruction. |
246258
| Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. |
247-
| Per-file licensing | Every one of the 609 files in this repository carries copyright and licence information, following the REUSE Specification 3.3. `reuse lint` exits 0 and runs in CI. What is not this project's says so: the desktop shell's icons are Tauri's template icons and are attributed to Tauri, not to us. |
259+
| Exercised by generated input | The canonicalisers, the argument validator and the receipt chain are tested with property-based testing as well as by example, because code that answers "is this the same thing" fails on the spellings nobody thought of. The suite carries a planted defect it catches and the example tests miss, so a property that has never failed is not mistaken for one that cannot. |
260+
| Per-file licensing | Every one of the 630 files in this repository carries copyright and licence information, following the REUSE Specification 3.3. `reuse lint` exits 0 and runs in CI. What is not this project's says so: the desktop shell's icons are Tauri's template icons and are attributed to Tauri, not to us. |
248261
| Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. A CycloneDX bill of materials is generated by the gate on every commit and ships beside every packaged binary, so whoever holds the artifact holds the list of what is in it; the bill states its own coverage inside the document, including what it does not cover. |
249262
| Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. Every packaged binary carries build provenance attested through the workflow's own identity and recorded in a public transparency log, verified in the same run that produced it, so a holder can check where the bytes were built without trusting this page. |
250263
| Independent self-certification | The project holds the [OpenSSF Best Practices **silver** badge](https://www.bestpractices.dev/projects/14772) (awarded 2026-09-23), assessed against the Open Source Security Foundation's published criteria at two tiers. Every required criterion at both is met. What is not met is stated rather than stretched: at passing, three suggested criteria (semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling the project does not run); at silver, a bus factor of two and an accessibility assessment, both suggested-tier, and signed version tags. |
@@ -266,7 +279,7 @@ rather than described by it.
266279
| M7 Sandboxed | Executable skills in an in-VM sandbox | 110 approved |
267280
| M9 Donatable | Open-source hygiene audited, supply chain signed, shared libraries extracted | 002 and 120 to 123 approved |
268281

269-
M0 to M5a are approved as of 2026-09-22. Of M5b, 070 and 080 are approved as of 2026-09-23; 071 and 072 wait on a messaging bot token the project does not yet hold. Of M6, 090 is approved as of 2026-09-24. Slice numbers have gaps on purpose (000, 001, 010, 011 and so on) so that a slice can be inserted
282+
M0 to M5a are approved as of 2026-09-22. Of M5b, 070 and 080 are approved as of 2026-09-23; 071 and 072 wait on a messaging bot token the project does not yet hold. Of M6, 090 is approved as of 2026-09-24. **M7** is approved as of 2026-09-24 (110). Of M9, 120 is approved as of 2026-09-24. Four slices merged on 2026-09-24 were not on the plan and are recorded as what they were: 004 (property-based testing, taken because it closes two OpenSSF gold criteria), 014 (a defect: a session past 500 messages built its prompt from the *first* 500), and 027, 028 and 029 from the connectome research. Slice numbers have gaps on purpose (000, 001, 010, 011 and so on) so that a slice can be inserted
270283
later without renumbering anything.
271284

272285
**[`ROADMAP.md`](ROADMAP.md)** sets out what the project intends to do and what it intends not to
@@ -283,6 +296,7 @@ do over the next year, and why the order is what it is.
283296
| `THIRD_PARTY_LICENSES.md` | Every dependency and its licence, generated from the CycloneDX bill. A dependency whose licence neither the bill nor a declared entry supplies fails the gate |
284297
| `LICENSES/` | The full text of every licence the tree uses, as the REUSE Specification requires |
285298
| `docs/effects-catalog.md` | The published effect catalogue: every tool that can cause an effect, its risk tier and effect class, with a version that is a hash of its own rows. Generated, and the gate fails when it and the tree disagree |
299+
| `docs/sandbox.md` | What the Lua sandbox protects against, what it does not, and which of its limits are enforced by the virtual machine rather than sampled |
286300
| `docs/telemetry.md` | The telemetry event catalogue: every event this tree emits, its measurements and metadata, and the rule about what never appears in one |
287301
| `docs/adr/` | Architecture decision records. One is added whenever a decision changes |
288302
| `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named |

‎config/test.exs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,25 @@ config :trinity, :llm,
8181
model: "chat",
8282
caps: [:stream, :tools, :json],
8383
price: %{input: 1.0, output: 2.0},
84-
# Slice 023: a small window so a long fake conversation crosses the thresholds (the fourteen
85-
# tools' schemas alone are about 1,140 estimated tokens of every request).
86-
context_tokens: 6_000
84+
# A small window so a long fake conversation crosses the thresholds. The number is a balance
85+
# and both sides of it are measured, because the first version of this comment was written
86+
# when it was not.
87+
#
88+
# **Below** it: the tool definitions are in every request and cannot be compacted away. That
89+
# comment said "the fourteen tools' schemas alone are about 1,140 estimated tokens"; there are
90+
# now 24 tools at 3,516 tokens, and at a 6,000 window (soft 4,200) the baseline request had
91+
# reached 4,228. Every session was therefore over the soft threshold before anyone said
92+
# anything, so an ordinary two-turn test spent a scripted model response on a compaction and
93+
# failed somewhere unrelated. Registering one tool at slice 110 is what crossed it.
94+
#
95+
# **Above** it: `Trinity.Memory.CompactionTest` needs a 200-turn conversation to exceed the
96+
# soft threshold, and its naive prompt measures 9,298 tokens, so the soft threshold has to
97+
# stay below that.
98+
#
99+
# 10,000 puts soft at 7,000: the tool surface is half of it rather than 84%, there is room for
100+
# a conversation, and 9,298 still crosses it. Re-measure both numbers before changing this,
101+
# rather than adjusting it until the suite goes green.
102+
context_tokens: 10_000
87103
},
88104
%{
89105
id: "fake:embed",

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ slice_id percent sha date
3232
120 80.31 78ee950 2026-09-24
3333
004 80.31 91de943 2026-09-24
3434
014 80.31 1a5d480 2026-09-24
35+
110 80.31 90c3d0a 2026-09-24

0 commit comments

Comments
 (0)