1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
|
# Extension loading: unified policy, entry/activate, rocks & paths
Status: **implemented** (2026-07-01). Governs the panto CLI's extension/tool
loading, the `[extensions]` config section, and loading luarocks packages and
extra filesystem paths as extension sources.
## Implementation status
Landed and covered by `zig build test` (666 tests green):
- `src/glob.zig` — `Specificity` + `specificity()`, with tests.
- `src/config_file.zig` — single `[extensions]` section; `Policy` is now a
layered, specificity-sorted rule list resolved by last-match-wins;
`[tools]` removed; `extensions.paths`/`extensions.rocks` parsed per-layer
into `Config.ext_paths`/`ext_rocks`.
- `src/lua_runtime.zig` — `Entry` + `evalEntries`/`evalEntriesFromModule`/
`activateEntry`/`dropEntry`; `loadExtension`/`loadTool` removed. Sugar tool
tables still work.
- `src/extension_loader.zig` — two-pass (eval → shadow → filter → activate);
`Source {layer, origin}` precedence (`rocks < paths < dir`, `project > user
> base`); scans dirs + `paths`, requires `rocks`.
- `src/luarocks_runtime.zig` — `installRock` / `installRockIfMissing`
(in-process luarocks driver, exact-pin fast-path skip).
- `src/main.zig` — installs `extensions.rocks` (best-effort) then discovers.
- `examples/extensions/{greet,echo}.lua` migrated; `agent/tools/*.lua`
unchanged (sugar). `wc.lua` unchanged.
Known follow-ups / not yet done:
- **Rocks end-to-end is unverified against a real registry rock** — no such
rock exists yet and the sandbox has no network. The `require`→entries path
is unit-tested via `package.preload`; the install path reuses the
battery-install machinery. First real integration test is pending the
published `agent.*` rock.
- **Integrity (#9)** — first-party GPG signing + `luarocks --verify` + embedded
pubkey is designed but NOT wired. Until then `rocks` pins *which version*,
not *which bytes*. The bespoke sha256 lockfile stays deferred (YAGNI).
- Rock module name is assumed equal to the rock name (spec's first token);
rocks whose Lua module differs would need a mapping.
Context: a separately-published luarocks package (the `agent.*` suite — skills,
rules, subagents) will register extensions into panto. That suite lives in its
own repo/rock; this work is only the *loading mechanism* in panto core that
lets a rock (or an extra path) contribute extensions, plus the config surface
to select among them.
## Decisions
### 1. One `[extensions]` config section
The old split of `[tools]` (gates registered tool names post-load) and
`[extensions]` (gates script logical names pre-load) collapses into a single
`[extensions]` section governing every lua-authored capability, whether it
comes from the `extensions/` dir, the `tools/` dir, a rock, or an extra path.
No backwards compatibility — `[tools]` is removed.
```toml
[extensions]
allow = ["agent.rules"]
deny = ["agent.*"]
rocks = ["panto-agent 1.4.2-1"]
paths = ["./dev/my-extension"]
```
### 2. Registration redesign: entry + deferred `activate()`
Every extension source (disk file or rock module) is **always eval'd** and
returns an *entry*, or a list of entries. An entry is `{ name, activate }`:
```lua
return {
name = "agent.skills", -- identity; matched against allow/deny
activate = function() -- run ONLY if name is permitted
panto.ext.register_tool { ... }
panto.ext.register_command { ... }
end,
}
```
- `name` is the dotted identity the policy matches on.
- `activate()` is deferred: the runtime calls it only when `name` is permitted.
All `register_tool` / `register_command` / `on` side effects move inside it.
- A source returning a list registers multiple independently-gateable entries
(this is how one rock contributes many namespaces).
- **Sugar:** a returned table with a `handler` (and no `activate`) is a bare
tool — normalized internally to an entry whose activation registers itself.
The built-in `agent/tools/*.lua` keep working unchanged through this path.
**Hard rule:** eval/`require` must be side-effect-free — it only *returns*
entries, never registers. This is what makes Pass 1 (below) safe to run over
*every* candidate, including denied ones.
This collapses the old two-stage gate (pre-load logical name + post-load tool
name) into a single post-eval gate on the declared `name`. Identity is the
declared `name`, uniform across disk/rock/path — there is no filename-derived
identity anymore.
### 3. Two-pass load
- **Pass 1 — availability.** Walk sources in order, eval/`require` everything
(side-effect-free), collect `name → entry`. Later trumps earlier for the same
name (shadowing). Shadow key is the declared `name` (the old `(kind, name)`
key is gone — `kind` is dropped).
- **Pass 2 — activation.** Resolve the policy (below) and call `entry.activate()`
for every permitted name.
Same-name duplicate **within one source** = error; **across sources** = shadow.
### 4. Source precedence
Cross-layer: `project > user > base` (unchanged).
Within a layer: `rocks < paths < dir` — the canonical `extensions/`/`tools/`
dir is most authoritative locally, an explicit `paths` entry next, a rock least
(most upstream). This lets a local dev copy shadow a packaged rock.
### 5. Policy resolution: specificity + last-match-wins
Neither clobber nor naive union across layers works (both silently drop another
layer's rules; and "carve one out of a denied group" needs allow to beat a
broader deny). The model:
- Every layer contributes `allow`/`deny` glob patterns. **Rules are never
clobbered or merged away** — all layers' rules are kept.
- Build one ordered rule list: layers low→high; within a layer, sort
least-specific→most-specific, with `deny` after `allow` at equal specificity.
- Resolve a name by scanning the list and taking the **last** matching rule.
That yields: the highest layer that touches the name wins, and within it the
most-specific rule wins; a higher layer's broad rule beats a lower layer's
specific rule (consistent with panto's higher-layer-authoritative model).
- Default is **allow** (empty policy permits everything). A pure whitelist is
expressed explicitly as `deny = ["**"]` + specific `allow` carve-outs.
**Specificity** (only compared when two patterns match the same name), in
order: (1) more literal segments win (`a.b.* > a.*`, `a.b.** > a.**`);
(2) more total segments win (`a.*.* > a.*`); (3) fewer `**` win (`a.* > a.**`).
Example — base pins one thing out of an otherwise-denied group, user adds an
unrelated carve-out; both survive:
```toml
# base
[extensions]
deny = ["agent.*"]
allow = ["agent.rules"] # more specific than agent.* → wins → agent.rules on
# user
[extensions]
allow = ["foo.bar"] # unions in, touches nothing else
```
### 6. `extensions.rocks` — the trust boundary
`rocks` is a plain list of **luarocks dependency strings** passed straight
through to embedded luarocks. No custom grammar.
```toml
rocks = ["panto-agent 1.4.2-1"]
```
- luarocks operators: `==` `~=` `<` `>` `<=` `>=` `~>`; no operator = implicit
`==`; `~>` is pessimistic ("compatible with").
- **`rocks` is the code-execution trust boundary** — listing a rock is the
"I will run this" decision. `allow`/`deny` is a *feature toggle*, applied
after eval, and is **not** a security boundary (a hostile rock runs on
`require` regardless of any deny).
- Install via embedded luarocks into `<data-home>/rocks/...`. Startup only
installs a rock that is *missing* (exact pin: that version absent; range/bare:
no version of the name present) — never a per-startup network hit otherwise.
`panto update` force-(re)installs every `extensions.rocks` entry (the one
place upgrades/re-resolves happen).
- **Pin gotcha (verified in luarocks v3.13.0 `core/vers.lua`):** `1.4.2`
without the `-R` revision floats across `1.4.2-1`/`1.4.2-2` — the revision is
only compared when the constraint carries one. A true pin needs
`version-revision`. We do *not* enforce this; pin discipline lives in docs.
### 7. `extensions.paths` — extra search dirs
A plain list of filesystem dirs appended to the scan at the listing layer's
precedence. No registry — reuses the dir scan. Relative paths resolve against
cwd (project) for now.
### 8. Integrity (deferred piece)
luarocks provides **no** content-hash pinning: no sha256, no `--checksum`, only
an optional author-set `source.md5` for source archives. `--pin`/`luarocks.lock`
pins *versions, not bytes*. The only native integrity is GPG detached `.asc`
signatures via `luarocks install --verify` (plain gpg against the local
keyring; weak/optional in general).
Plan: for the first-party `agent.*` rocks we publish, sign releases + embed our
public key in panto + install with `--verify` → a real boundary for first-party
code at low cost. Defer (YAGNI) a bespoke sha256 TOFU lockfile for unsigned
third-party rocks. Until that lands, docs must say `rocks` pins *which version*,
not *which bytes*.
## Implementation notes (anchors)
- `src/glob.zig` — add specificity comparator + rule-list resolution.
- `src/config_file.zig` — single `[extensions]` policy; capture per-layer
`allow`/`deny` in `loadFromPaths` (before the clobbering `mergeTable`),
build the ordered rule list. Drop `[tools]`. `Policy` becomes a rule list.
- `src/lua_runtime.zig` / `src/lua_bridge.zig` — new unified load path: eval
file, read returned entry/entries (or sugar tool table), hold `activate`
refs, activate permitted ones. Keep `register_tool`/`register_command`/`on`.
- `src/extension_loader.zig` — two-pass by declared name; `Source` gains
ordering for `rocks`/`paths`/dir; scan `paths`; resolve `rocks`.
- `src/luarocks_runtime.zig` / `src/manifest.zig` — install user `rocks`.
- `agent/tools/*.lua` — unchanged (sugar). `examples/extensions/*.lua` —
migrate to entry/activate.
|