skillscript-runtime 0.19.15 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +35 -2
  2. package/dist/approval.d.ts +80 -54
  3. package/dist/approval.d.ts.map +1 -1
  4. package/dist/approval.js +156 -120
  5. package/dist/approval.js.map +1 -1
  6. package/dist/bootstrap.d.ts +41 -0
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/bootstrap.js +59 -2
  9. package/dist/bootstrap.js.map +1 -1
  10. package/dist/cli.js +245 -24
  11. package/dist/cli.js.map +1 -1
  12. package/dist/compile.js +7 -0
  13. package/dist/compile.js.map +1 -1
  14. package/dist/composition.d.ts.map +1 -1
  15. package/dist/composition.js +13 -1
  16. package/dist/composition.js.map +1 -1
  17. package/dist/connectors/skill-store.d.ts.map +1 -1
  18. package/dist/connectors/skill-store.js +41 -13
  19. package/dist/connectors/skill-store.js.map +1 -1
  20. package/dist/connectors/sqlite-skill-store.d.ts.map +1 -1
  21. package/dist/connectors/sqlite-skill-store.js +21 -12
  22. package/dist/connectors/sqlite-skill-store.js.map +1 -1
  23. package/dist/dashboard/spa/app.js +186 -8
  24. package/dist/dashboard/spa/index.html +2 -0
  25. package/dist/dashboard/spa/styles.css +42 -0
  26. package/dist/errors.d.ts +25 -0
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +49 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/help-content.d.ts.map +1 -1
  31. package/dist/help-content.js +2 -0
  32. package/dist/help-content.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/lint.d.ts.map +1 -1
  38. package/dist/lint.js +24 -7
  39. package/dist/lint.js.map +1 -1
  40. package/dist/mcp-server.d.ts +3 -0
  41. package/dist/mcp-server.d.ts.map +1 -1
  42. package/dist/mcp-server.js +21 -4
  43. package/dist/mcp-server.js.map +1 -1
  44. package/dist/parser.js +2 -2
  45. package/dist/parser.js.map +1 -1
  46. package/dist/runtime-config.d.ts +6 -0
  47. package/dist/runtime-config.d.ts.map +1 -1
  48. package/dist/runtime-config.js +8 -0
  49. package/dist/runtime-config.js.map +1 -1
  50. package/dist/runtime-env-resolver.d.ts +4 -0
  51. package/dist/runtime-env-resolver.d.ts.map +1 -1
  52. package/dist/runtime-env-resolver.js +27 -0
  53. package/dist/runtime-env-resolver.js.map +1 -1
  54. package/dist/runtime.d.ts +18 -0
  55. package/dist/runtime.d.ts.map +1 -1
  56. package/dist/runtime.js +84 -17
  57. package/dist/runtime.js.map +1 -1
  58. package/dist/safe-path.d.ts +22 -0
  59. package/dist/safe-path.d.ts.map +1 -1
  60. package/dist/safe-path.js +72 -1
  61. package/dist/safe-path.js.map +1 -1
  62. package/dist/scheduler.d.ts +3 -0
  63. package/dist/scheduler.d.ts.map +1 -1
  64. package/dist/scheduler.js +8 -0
  65. package/dist/scheduler.js.map +1 -1
  66. package/docs/adopter-playbook.md +190 -30
  67. package/docs/configuration.md +5 -1
  68. package/docs/connector-contract-reference.md +5 -1
  69. package/docs/language-reference.md +83 -130
  70. package/docs/sqlite-skill-store.md +5 -1
  71. package/examples/skillscripts/classify-support-ticket.skill.md +1 -1
  72. package/examples/skillscripts/data-store-roundtrip.skill.md +1 -1
  73. package/examples/skillscripts/doc-qa-with-citations.skill.md +1 -1
  74. package/examples/skillscripts/feedback-sentiment-scan.skill.md +1 -1
  75. package/examples/skillscripts/hello-world.skill.md +1 -1
  76. package/examples/skillscripts/hello-world.skill.provenance.json +1 -1
  77. package/examples/skillscripts/morning-brief.skill.md +1 -1
  78. package/examples/skillscripts/queue-length-monitor.skill.md +1 -1
  79. package/examples/skillscripts/service-health-watch.skill.md +1 -1
  80. package/examples/skillscripts/skill-store-roundtrip.skill.md +1 -1
  81. package/examples/skillscripts/youtrack-morning-sweep.skill.md +1 -1
  82. package/package.json +1 -1
  83. package/scaffold/skills/data-store-roundtrip.skill.md +1 -1
  84. package/scaffold/skills/hello-world.skill.md +1 -1
  85. package/scaffold/skills/skill-store-roundtrip.skill.md +1 -1
@@ -1,4 +1,8 @@
1
- # Adopter playbook
1
+ ---
2
+ title: Adopter Playbook
3
+ description: "Stand up the runtime, wire your substrates, supervise it as a service, and point your agent at it over MCP."
4
+ mode: wide
5
+ ---
2
6
 
3
7
  How to wire skillscript-runtime into your deployment. Written for Joe-Programmer: you have your own substrate stack (data store, agent harness, LLM endpoint, filesystem), and you want skillscript to slot in rather than dictate.
4
8
 
@@ -95,15 +99,20 @@ Your substrate exposes itself as MCP tools (via a local MCP server or remote one
95
99
  "class": "RemoteMcpConnector",
96
100
  "config": {
97
101
  "command": "my-store-mcp-server",
98
- "args": ["--db", "/var/store"]
102
+ "args": ["--db", "/var/store"],
103
+ "framing": "newline"
99
104
  }
100
105
  }
101
106
  }
102
107
  ```
103
108
 
109
+ Set `"framing": "newline"` explicitly — `mcp-remote` and most spec-compliant MCP stdio servers use newline-delimited JSON. The legacy `lsp` default silently hangs to `init_timeout` against newline-framed servers. See [configuration.md — `RemoteMcpConnector` config — stdio framing](configuration.md#remotemcpconnector-config---stdio-framing).
110
+
111
+ The `command` (server binary, `node`/`npx`, `uv`/`uvx`, etc.) must be on the runtime host's PATH at spawn time. A programmatic bootstrap inherits its launching shell's PATH; the CLI inherits the user's shell PATH at invocation.
112
+
104
113
  - **HTTP MCP / Streamable HTTP** (Anthropic's hosted MCP, GitHub MCP, Linear MCP, etc.): the MCP server speaks JSON-RPC over HTTP with Server-Sent Events for the stream channel. Two ways to wire it:
105
114
 
106
- - **(a) Stdio bridge** — `RemoteMcpConnector` + `npx mcp-remote https://... --sse` runs a node child process that bridges HTTPS-SSE into stdio for the runtime to consume. Works today; adds the bridge subprocess overhead per call.
115
+ - **(a) Stdio bridge** — `RemoteMcpConnector` + `npx mcp-remote https://...` runs a node child process that bridges HTTP MCP into stdio for the runtime to consume. `mcp-remote` auto-negotiates the transport (Streamable HTTP first, falling back to SSE); add `--sse` only if the server requires SSE explicitly. Works today; adds the bridge subprocess overhead per call.
107
116
  - **(b) Direct HTTP connector (bundled)** — `HttpMcpConnector` speaks Streamable HTTP MCP directly, no subprocess. Substrate-neutral: works against any MCP server speaking the spec. Wired declaratively:
108
117
 
109
118
  ```json
@@ -166,14 +175,14 @@ skillfile init --here
166
175
 
167
176
  This creates `~/.skillscript/` with `skills/`, `traces/`, an empty `connectors.json`, and a `config.toml` stub.
168
177
 
169
- **If you're writing a custom bootstrap (not just using the bundled CLI):** the package is ESM-only. `npm init -y` produces a CJS `package.json` by default, which will fail your first bootstrap run with top-level-await / ESM-import errors. Switch your adopter project to ESM before authoring bootstrap code:
178
+ **If you're writing a custom bootstrap (not just using the bundled CLI):** install the package locally (`npm install skillscript-runtime` in your adopter project) so `tsx`/`node` can resolve it alongside your project dependencies. A global install is convenient for the bundled CLI but doesn't expose the package to your custom bootstrap's module resolution.
179
+
180
+ The package is ESM-only. `npm init -y` produces a CJS `package.json` by default, which will fail your first bootstrap run with top-level-await / ESM-import errors. Switch your adopter project to ESM before authoring bootstrap code:
170
181
 
171
182
  ```bash
172
183
  npm pkg set type=module
173
184
  ```
174
185
 
175
- (Phase 2 cold-adopter dogfood, 2026-06-01: first improvisation an adopter hit was this exact gap — surfaced after a `bootstrap.ts` failed at module load. One-sentence flag saves the trip.)
176
-
177
186
  ### 2. Decide on substrate wiring
178
187
 
179
188
  For each of the four substrates (data store, LLM, agent harness, MCP tools), decide Case 1 or Case 2. The onboarding scaffold (`examples/onboarding-scaffold/`) is Case 1 end-to-end against a file-backed data store + OpenAI + tmux.
@@ -199,8 +208,10 @@ Create `skillscript.config.json` in your `$SKILLSCRIPT_HOME`:
199
208
 
200
209
  **For custom substrates**: write your own bootstrap. See `examples/custom-bootstrap.example.ts` and `examples/onboarding-scaffold/bootstrap.ts` for complete worked walkthroughs.
201
210
 
202
- Two security knobs that adopters wiring real substrates should know about:
211
+ Security knobs that adopters wiring real substrates should know about:
203
212
 
213
+ - **The approval boundary (secured mode).** `SKILLSCRIPT_SECURED_MODE=true` enforces that only key-signed skills perform effects. Default-deny by design — leave it off only for trusted-author / single-operator setups. Full detail, the approve flow, and key custody: [Approval + secured mode](#approval--secured-mode).
214
+ - **Filesystem path allowlist.** `SKILLSCRIPT_FS_ALLOWLIST` is default-deny — `file_read` / `file_write` refuse every path until you wire roots. See [Filesystem path allowlist](#filesystem-path-allowlist). (Keep your approval-key directory out of it.)
204
215
  - **Per-connector tool allowlists** — `allowed_tools` on each `connectors.json` MCP connector entry restricts which tools that connector can dispatch. Three-state (`undefined` = allow all, `[]` = allow none, listed = exactly those). Tier-1 `disallowed-tool` lint + runtime defense-in-depth refuse out-of-list dispatch. **`allowed_tools` belongs at the entry top-level**, sibling to `class` and `config` — NOT inside the `config` block. The loader hard-errors on misplacement (placing it inside `config:` would silently allow-all every tool — the worst-case failure mode for a security control). See `docs/configuration.md` §"Named MCP connector instances" for the JSON shape.
205
216
  - **Shell-execution discipline** — `shell(command="...")` runs structured-spawn by default (binary on PATH, whitespace-tokenized argv, no bash). `shell(command="...", unsafe=true)` opts into bash interpretation (pipes, `$VAR`, command substitution) and refuses to fire unless the runtime is configured with `enable_unsafe_shell = true` in `config.toml`. Lint flags every `unsafe=true` op tier-2 to keep audit posture visible. See `scaffold/config.toml` for the documented default + `help({topic:"lint-codes"})` for the `unsafe-shell-disabled` rule.
206
217
 
@@ -232,6 +243,154 @@ SKILLSCRIPT_HOME=/path/to/adopter skillfile dashboard --config /path/to/adopter/
232
243
 
233
244
  Each instance reads its own config; ports/paths/db files don't collide.
234
245
 
246
+ ### 6. Running as a supervised service
247
+
248
+ For cron/event fires to be reliable, the dashboard/MCP host must run supervised — surviving both reboot and crash. The scheduler is in-process, at-most-once, with no catch-up (see [Trigger model — durability honesty](#durability-honesty)): a down process silently misses fires. A cron skill like `0 3 * * *` only fires if the process happens to be up at 3am. `nohup`-from-a-shell isn't sufficient (dies on reboot/logout/crash).
249
+
250
+ Use the OS supervisor: a macOS LaunchAgent (`RunAtLoad` + `KeepAlive`) or a Linux systemd unit.
251
+
252
+ **macOS — `~/Library/LaunchAgents/com.skillscript.adopter.plist`:**
253
+
254
+ ```xml
255
+ <?xml version="1.0" encoding="UTF-8"?>
256
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
257
+ <plist version="1.0">
258
+ <dict>
259
+ <key>Label</key><string>com.skillscript.adopter</string>
260
+ <key>ProgramArguments</key>
261
+ <array>
262
+ <string>/opt/homebrew/bin/node</string>
263
+ <string>--import=tsx</string>
264
+ <string>/path/to/adopter/bootstrap.ts</string>
265
+ </array>
266
+ <key>WorkingDirectory</key><string>/path/to/adopter</string>
267
+ <key>RunAtLoad</key><true/>
268
+ <key>KeepAlive</key><true/>
269
+ <key>EnvironmentVariables</key>
270
+ <dict>
271
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
272
+ <key>SKILLSCRIPT_HOME</key><string>/path/to/adopter</string>
273
+ </dict>
274
+ <key>StandardOutPath</key><string>/path/to/adopter/logs/stdout.log</string>
275
+ <key>StandardErrorPath</key><string>/path/to/adopter/logs/stderr.log</string>
276
+ </dict>
277
+ </plist>
278
+ ```
279
+
280
+ Load with `launchctl load ~/Library/LaunchAgents/com.skillscript.adopter.plist`.
281
+
282
+ **Linux — `/etc/systemd/system/skillscript-adopter.service`:**
283
+
284
+ ```ini
285
+ [Unit]
286
+ Description=Skillscript adopter runtime
287
+ After=network.target
288
+
289
+ [Service]
290
+ ExecStart=/usr/bin/node --import=tsx /path/to/adopter/bootstrap.ts
291
+ WorkingDirectory=/path/to/adopter
292
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
293
+ Environment=SKILLSCRIPT_HOME=/path/to/adopter
294
+ Restart=always
295
+ RestartSec=5
296
+ User=adopter
297
+
298
+ [Install]
299
+ WantedBy=multi-user.target
300
+ ```
301
+
302
+ Enable with `systemctl enable --now skillscript-adopter.service`.
303
+
304
+ **The load-bearing gotcha — pin `PATH` in the supervisor environment.** launchd and systemd run with a bare PATH (typically `/usr/bin:/bin`), not your shell's. The runtime's spawned binaries — `node`/`npx` for `RemoteMcpConnector` (mcp-remote bridges), `uv`/`uvx` for Python MCP servers, every binary on the shell allowlist (`gh`, `curl`, `git`, etc.) — must be on that pinned PATH. Omit it and connectors + shell ops fail with "command not found" under the supervisor even though they work from your shell. This is the same class of environment-specific break as forgetting to pin `TMUX_TMPDIR` in a tmux launcher's plist.
305
+
306
+ Spirit-of: same PATH concern as the [Case-2 stdio host-PATH note](#case-2--mcp-tools-wiring-substrate-locked) — two surfaces (the shell that launches it ad-hoc AND the supervisor that keeps it up persistently).
307
+
308
+ **A few additional gotchas:**
309
+
310
+ - **`WorkingDirectory` matters.** Set it to the bootstrap's directory so relative imports (`./connectors-config.ts`) and `node_modules` resolve correctly. Without it, the supervisor runs from `/` and ESM imports fail.
311
+ - **Single supervisor only.** Don't also run the dashboard manually (`nohup skillfile dashboard &`) alongside the supervised instance — two listeners on the same port either collide or round-robin events, dropping fires silently.
312
+ - **`.env` still loads.** The runtime's `process.loadEnvFile` call reads `.env` from the working directory at boot, independent of the supervisor's environment block. Don't duplicate secrets in both — keep them in `.env` and let the bootstrap load them.
313
+
314
+ ### 7. Wire your agent to the runtime over MCP
315
+
316
+ With the service running, point your agent at the runtime's `/rpc` endpoint.
317
+
318
+ **Simplest path (Claude Code + similar agent hosts):** just ask. "Add the skillscript MCP server at `http://localhost:7878/rpc`" — Claude Code writes the config for you. Skip the JSON below unless you want to understand what landed or you're on a host that doesn't write its own config.
319
+
320
+ **Manual config — standard HTTP MCP** (modern agent hosts that speak HTTP MCP directly) — drop the runtime in your agent's MCP config (`~/.claude.json`, `.mcp.json`, `claude_desktop_config.json`, etc.):
321
+
322
+ ```json
323
+ {
324
+ "mcpServers": {
325
+ "skillscript": {
326
+ "type": "http",
327
+ "url": "http://localhost:7878/rpc"
328
+ }
329
+ }
330
+ }
331
+ ```
332
+
333
+ **Stdio bridge** (older Claude Desktop builds + any client that doesn't speak HTTP MCP) — bridge via `mcp-remote` the same way you'd bridge any HTTP MCP server. Pair this with the [`framing: "newline"` note from Case 2](#case-2--mcp-tools-wiring-substrate-locked) if you wire it the other direction (runtime → external HTTP MCP):
334
+
335
+ ```json
336
+ {
337
+ "mcpServers": {
338
+ "skillscript": {
339
+ "command": "npx",
340
+ "args": ["mcp-remote", "http://localhost:7878/rpc"]
341
+ }
342
+ }
343
+ }
344
+ ```
345
+
346
+ **Two operational notes:**
347
+
348
+ - **Port matches what the runtime binds.** Default `7878` (`SKILLSCRIPT_PORT` env var / `dashboard.port` config). Two-instance setups (dev + adopter side-by-side per §5) need distinct ports — e.g., adopter on `7879`. Whatever the supervisor unit specifies is what the agent config must point at.
349
+ - **Auth + bind address.** `/rpc` has no auth and binds to `127.0.0.1` by default — the right posture for a local agent talking to a local runtime. If you set `SKILLSCRIPT_HOST=0.0.0.0` to reach the runtime across the network, front it with your own auth layer (reverse proxy + bearer token, mTLS, etc.) — same posture as the `/event` HTTP ingress, which has its own bearer-token gate (`SKILLSCRIPT_EVENT_INGRESS_AUTH_TOKEN`).
350
+
351
+ ## Approval + secured mode
352
+
353
+ Every skill carries a lifecycle status — `Draft → Approved → Disabled`. **Only `Approved` skills execute**; `Draft` is the safety gate (a skill under authoring or review), `Disabled` is retired. That status gate applies in *both* runtime modes. What the mode decides is whether an `Approved` skill must additionally be **cryptographically keyed**:
354
+
355
+ | | Unsecured | Secured (`SKILLSCRIPT_SECURED_MODE=true`) |
356
+ |---|---|---|
357
+ | `Draft` skill | refused | refused |
358
+ | `Approved` skill | runs — a bare `# Status: Approved` is sufficient (unkeyed) | runs **only** if the body carries a valid operator signature |
359
+ | Tamper-evidence | none (an Approved body can be edited freely) | yes — editing the body breaks the signature |
360
+ | Effectful ops (`$`, `shell`, `file_*`, `notify`) | run | **refused unless the skill is signed** (the `effectsAuthorized` capability gate) |
361
+ | Who can grant approval | anyone who can set the status | only a holder of the operator's private key |
362
+
363
+ **Unsecured** is the trusted-author / single-operator convenience posture: you trust whoever writes skills, the status flag is the only barrier. **Secured** is the enforced boundary — an unapproved or tampered skill cannot perform a single effectful op, regardless of how it's dispatched (CLI, cron, `/event`, MCP, in-skill composition). Unsecured is the current default; secured is the recommended posture for any multi-author or networked deployment.
364
+
365
+ > The shell + filesystem allowlists (below) are **always enforced, in both modes** — they bound *what* a running skill may touch. Secured mode adds the orthogonal boundary on *whether an unapproved skill runs at all*.
366
+
367
+ ### Asymmetric signing — how secured approval works
368
+
369
+ Approval is an Ed25519 signature, not a shared secret:
370
+
371
+ - **Private key** — held by the operator, read **only** by the approve flow (`skillfile approve`). The runtime never loads it. For full isolation, run the runtime under a uid that cannot read the private key — then a co-resident agent can't forge approvals even with full read access to the runtime process. Same-uid still works, but isn't fully isolated (a documented 1.0 precondition; managed custody closes it later).
372
+ - **Public key** — non-secret; the runtime reads it to *verify* signatures on every execution. No hot-path secret is the point: verification needs only the public key.
373
+
374
+ First-run provisioning is automatic — starting the runtime or `skillfile init` in secured mode generates the keypair if absent (private written `0600`, default `~/.config/skillscript/approval.{key,pub}`, deliberately outside `SKILLSCRIPT_HOME`, the agent-readable data dir).
375
+
376
+ ### The approve flow
377
+
378
+ | Action | How |
379
+ |---|---|
380
+ | Approve one skill (review the body, then sign) | `skillfile approve <name>` |
381
+ | Batch-approve everything pending | `skillfile reapprove --apply` (dry-run preview: `skillfile reapprove`) |
382
+ | Approve from the dashboard | the **Approvals** queue — review the body + security signals, then run the emitted command at your terminal |
383
+
384
+ The dashboard is a **review** surface, not a signing surface: it surfaces the pending queue and per-skill security-signal summary, but signing happens at your terminal where the private key lives (the network-facing runtime never holds it). `skill_write` / `skill_status` **cannot** grant approval in secured mode — an agent-written or status-flipped skill lands `Draft` until a key-holder signs it. (In unsecured mode, `skill_status → Approved` is honored directly, unkeyed.)
385
+
386
+ ### Migrating an existing skill set
387
+
388
+ Turning secured mode on means any skill that's `Approved` without a valid signature is refused at execution. `skillfile reapprove` sweeps the store, reports the set needing re-blessing, and `--apply` re-signs them in one pass — so you don't run `skillfile approve` once per skill.
389
+
390
+ ### Bundled demos
391
+
392
+ Bundled example skills ship as `# Status: Draft` — a signature baked at package-build time could never validate on your install (the key is per-operator). `skillfile init` locally approves the three seeded demos with *your* machine's authority (secured → provision keypair + sign; unsecured → bare Approved), so they're runnable immediately after init.
393
+
235
394
  ## Shell binary allowlist
236
395
 
237
396
  **The runtime enforces a default-deny operator allowlist for binaries reachable via `shell(...)` ops.** Skill authors are agents, agents are a weak trust anchor (hallucination, prompt-injection, no human-in-loop at scale), and operator-side scoping converts "a human reviews every skill" from discipline into an enforced constraint at the language level.
@@ -392,6 +551,25 @@ Per-skill capability declaration: skills declare what shell binaries they need i
392
551
 
393
552
  The operator policy validates `declared ∩ allowlist` — each skill's shell footprint becomes self-documenting and auditable. Slated for a future ring once the chokepoint + observability surfaces ship.
394
553
 
554
+ ## Filesystem path allowlist
555
+
556
+ **The runtime enforces a default-deny operator allowlist for paths reachable via `file_read(...)` / `file_write(...)` ops** — the third operator boundary, mirroring the shell allowlist with the same rationale (skill authors are agents; the operator scopes which parts of the filesystem skills may touch).
557
+
558
+ | Operator switch | Controls |
559
+ |---|---|
560
+ | `SKILLSCRIPT_FS_ALLOWLIST` | Comma-separated roots under which `file_read` / `file_write` may operate |
561
+
562
+ - **Default-deny.** An unset or empty allowlist refuses *every* file op with `FilePathNotAllowedError` — a freshly-installed runtime does no file I/O until you wire roots. Applies to `file_read` as well as `file_write` (a read-then-`emit` is an exfiltration path).
563
+ - **Canonicalized before the check.** Both the target and each allowed root are resolved to their real absolute path (realpath, component-by-component), so `..` traversal and symlink evasion can't escape an allowed root — the classic allowlist bypasses are closed.
564
+ - **Off-allowlist is final.** No in-skill keyword (`approved=`, `# Autonomous: true`) escapes it. **Keep secret / key directories OUT of the allowlist** — a skill must never be able to read the operator's approval key. (This is why the default key path lives outside `SKILLSCRIPT_HOME`.)
565
+
566
+ ```bash
567
+ # permit reads/writes under a workspace + an event-drop dir
568
+ SKILLSCRIPT_FS_ALLOWLIST=/srv/skillscript/workspace,/var/skillscript/events
569
+ ```
570
+
571
+ TOCTOU note: the check resolves the real path at call time; a symlink swapped between check and open is a residual closed by fd-based opens later. Checking the resolved real path is the standard mitigation shipped today.
572
+
395
573
  ## Trigger model
396
574
 
397
575
  **The trigger surface is two primitives.**
@@ -601,7 +779,7 @@ A skill may carry template prose in one region. Splitting it into two — some p
601
779
 
602
780
  - **`unset-template-var`** (tier-1) — every `${VAR}` in the template must resolve to a `# Vars:` / `# Requires:` input, an ambient ref (`NOW`, `USER`, ...), or a `$set` / `->` binding somewhere in the skill body. Tier-1 because an unbound ref renders empty silently.
603
781
  - **`template-looks-like-target`** (tier-2) — bare `<word>:` alone in the template region, no following op-block. Ambiguous shape — author may have meant a target.
604
- - **`connector-as-tool`** (tier-1) — `$ <connector> <tool>` space-separated catches the muscle-memory foot-gun (CLI-style `git status`). Bare-form dispatch treats the first token as the tool name, sending `name: "<connector>"` to the MCP server, which replies with a misdirecting "Tool '<connector>' not found." The two correct shapes are `$ <connector>.<tool> args` (dotted) or `$ <tool> args` (bare-tool; runtime resolves).
782
+ - **`connector-as-tool`** (tier-1) — `$ <connector> <tool>` space-separated catches the muscle-memory foot-gun (CLI-style `git status`). Bare-form dispatch treats the first token as the tool name, sending `name: "<connector>"` to the MCP server, which replies with a misdirecting `Tool '<connector>' not found.` The two correct shapes are `$ <connector>.<tool> args` (dotted) or `$ <tool> args` (bare-tool; runtime resolves).
605
783
  - **`remote-result-needs-parse`** (tier-3) — `${R|length}` on an `R` bound by `$` dispatch. Per-MCP-server result shapes vary: if the server returns prose-wrapped or non-JSON text, the value binds as a STRING and `|length` returns the string's char-count instead of element-count. Add `$ json_parse ${R} -> P` after the dispatch and use `${P|length}`. Suppressed when the skill already does `$ json_parse ${R}` somewhere — your defensive parse is taken as intent.
606
784
  - **`body-template-detected`** (tier-3) — non-blank, non-`#` lines in the body region, no `${...}` interpolations, no text-consuming `# Output:` declaration. Suggests "I wrote prose; it became template by accident." Prefix with `#` to mark as comments, or add an interpolation / `# Output:` to confirm intent.
607
785
  - **`emit-with-template`** (tier-3) — skill has both a template AND `emit(text=...)` calls. Confirms the channel-shift is intentional.
@@ -750,7 +928,7 @@ Callers reading `WakeReceipt.woken` distinguish "the substrate woke them" from "
750
928
 
751
929
  **Pattern 3 — session echo on receipts.** When your substrate routes to a specific session, echo it back on `DeliveryReceipt.session_id` / `WakeReceipt.session_id`. Dashboards rendering "delivered to alice@laptop-tab-3" rather than just "delivered to alice" depend on this.
752
930
 
753
- **Pattern 4 — read `meta.origin.caller_agent_id` to attribute, not for scope.** The `DeliveryMeta` envelope your `deliver()` receives carries `origin.caller_agent_id` = the *authenticated caller* who fired the dispatch (not the skill's owner — those are separate semantics; see [Connector Contract Reference](connector-contract-reference.md) §field semantics). Use it for *attribution* — rendering "from <caller>" on the receiving end, audit logs, accountability — not for authorization scoping. Outbound substrate scoping should derive from the *skill owner* (which the runtime applies at the connector layer via `ctx.agentId`, not via the envelope). If `caller_agent_id` is undefined on a delivery you receive, it means the chain originated from a non-human trigger (cron / event / scheduler) — your substrate should attribute it as "system-fired" or similar, not assume an identity.
931
+ **Pattern 4 — read `meta.origin.caller_agent_id` to attribute, not for scope.** The `DeliveryMeta` envelope your `deliver()` receives carries `origin.caller_agent_id` = the *authenticated caller* who fired the dispatch (not the skill's owner — those are separate semantics; see [Connector Contract Reference](connector-contract-reference.md) §field semantics). Use it for *attribution* — rendering `from <caller>` on the receiving end, audit logs, accountability — not for authorization scoping. Outbound substrate scoping should derive from the *skill owner* (which the runtime applies at the connector layer via `ctx.agentId`, not via the envelope). If `caller_agent_id` is undefined on a delivery you receive, it means the chain originated from a non-human trigger (cron / event / scheduler) — your substrate should attribute it as "system-fired" or similar, not assume an identity.
754
932
 
755
933
  **Pattern 5 — surface non-fatal notes via `DeliveryReceipt.warnings`.** When your substrate needs to signal something non-fatal about a delivery — "stripped @session because verb is deliver", "rate-limit hint", "fan-out: delivered to 3 sessions" — return them as `warnings: string[]` on the receipt instead of writing to stderr. The runtime echoes warnings onto `AgentDeliveryReceiptRecord.receipt.warnings`, where the dashboard can render them and observability tools can scrape them. Stderr noise gets lost; receipt warnings are structured + caller-visible.
756
934
 
@@ -777,12 +955,7 @@ How `author` is captured depends on how the skill gets written:
777
955
 
778
956
  Adopters whose `SkillStore` is backed by an addressable substrate (e.g., a memory store) can author skills by writing the substrate record directly — without going through the MCP `skill_write` handler. This captures `SkillMeta.author` from the substrate's own writer-identity (whatever the direct-write API authenticates as).
779
957
 
780
- **Gotcha:** direct-write must declare `# Status: Draft`, not `# Status: Approved`. The runtime's hash-token tamper gate rejects skills with `# Status: Approved` that lack a `vN:<token>` stamp; the stamp is computed by the runtime's `update_status` flow, not by the substrate. To publish:
781
-
782
- 1. Write the skill with `# Status: Draft` via your substrate's direct-write API.
783
- 2. Call `skill_status({name, new_state: "Approved"})` via MCP (or the dashboard's Approve button). This stamps the token and preserves the captured author.
784
-
785
- Write-Approved-without-stamp will fail at execute time with `ApprovalRejectedError`. Always Draft-then-promote.
958
+ **Gotcha:** in **secured mode**, a direct-write declaring `# Status: Approved` without a valid signature is forced to `Draft` the substrate can't mint approval, only the operator's key can. Publish by writing `Draft`, then signing via `skillfile approve <name>` or the dashboard Approvals queue (both preserve the captured author). In **unsecured mode** a bare `# Status: Approved` direct-write is honored as-is. Either way, in-skill `$ skill_write` always lands its child `Draft` regardless of body declaration. See [Approval + secured mode](#approval--secured-mode).
786
959
 
787
960
  ## Identity propagation — for multi-agent hosts
788
961
 
@@ -825,7 +998,7 @@ X-Agent-Id: alice
825
998
 
826
999
  ### Trust model
827
1000
 
828
- The runtime trusts the host's header attestation. There's no signature verification — anyone reaching the runtime with a forged `X-Agent-Id` could claim to be anyone. The runtime is **not** the authentication boundary; the host is. Bilateral trust:
1001
+ The runtime trusts the host's header attestation. There's no signature verification on the *identity claim* (distinct from secured-mode approval, which *is* signature-verified different boundary, different mechanism): anyone reaching the runtime with a forged `X-Agent-Id` could claim to be anyone. The runtime is **not** the authentication boundary; the host is. Bilateral trust:
829
1002
 
830
1003
  - **The host** (your MCP gateway) authenticates the agent via its own auth surface (OAuth, JWT, session cookies, mTLS — whatever fits your platform) and injects the verified identity into the outbound `X-Agent-Id` header.
831
1004
  - **The runtime** trusts the host because you configured it to (`mcpCallerIdentityHeader` is opt-in; unset means "I don't trust any inbound identity claim, fall back to my own writer identity").
@@ -923,20 +1096,7 @@ See `examples/custom-bootstrap.example.ts` for a worked walkthrough.
923
1096
  - **Durable-forever opt-in via `expires_at: null`.** `DataWrite.expires_at` accepts a unix timestamp for finite expiry, `null` to opt into "durable forever" (the portable verb for substrates with default TTL — AMP memory vaults, Redis with default expiry, hosted memory APIs), or omitted (substrate's default lifecycle, may be durable or may have decay). Substrates that are durable-by-default (the bundled `SqliteDataStore`) treat `null` as a no-op. Substrates with default sweep should map `null` to their pin / no-decay flag.
924
1097
  - **Two trigger primitives, both functional.** `cron` (time-based) and `event` (HTTP `/event` ingress, named registration). All other concepts that look like triggers — session-start, agent-event, file-watch, sensor — are adapter responsibilities: the adapter POSTs `/event` when relevant. Keeps the runtime substrate-neutral; the trigger surface stays tight.
925
1098
  - **Output kinds are intentionally substrate-neutral.** `# Output:` accepts `text` / `agent: <name>` / `template: <name>` / `file: <path>` / `none`. Substrate-specific values (`slack:`, `card:`, etc.) are out of scope — adopters wanting Slack / WhatsApp / Discord / etc. delivery use either `$ slack.post ...` MCP dispatch inside the skill body OR deliver via `agent: <name>` and let the receiving agent decide.
926
- - **Authorization is hash-token approval.** Skills must carry `# Status: Approved vN:<token>` where the token re-computes from the body minus its `# Status:` line. Bundled `v1:` is CRC32 — discipline-barrier strength, suited to single-operator deployments. Adversarial threat models swap a stronger function:
927
-
928
- ```ts
929
- import { registerApprovalFn, setPreferredApprovalVersion } from "skillscript-runtime";
930
- import { createHmac } from "node:crypto";
931
-
932
- // v2: HMAC-SHA256 with operator-held key. Agent that knows the algorithm
933
- // can no longer self-stamp without the key.
934
- const key = process.env["APPROVAL_HMAC_KEY"]!;
935
- registerApprovalFn("v2", (body) => createHmac("sha256", key).update(body).digest("hex"));
936
- setPreferredApprovalVersion("v2"); // dashboard now stamps v2 on Approve clicks
937
- ```
938
-
939
- Wire this in your bootstrap BEFORE any skill is stamped — otherwise existing skills carry `v1:` tokens that still verify (CRC32 stays registered) but new approvals use the upgraded function. The runtime maintains a per-version registry, so mixed-version skill bodies coexist cleanly.
1099
+ - **Authorization is signature-based in secured mode.** An `Approved` skill runs unkeyed in unsecured mode (a bare `# Status: Approved` is sufficient) and requires a valid Ed25519 operator signature in secured mode — verified on every execution against the operator's public key, with the private key held off the runtime. See [Approval + secured mode](#approval--secured-mode) for the model, the approve flow, key custody, and migration. The shell + filesystem allowlists bound blast radius in *both* modes; secured mode is what gates whether an unapproved skill runs at all.
940
1100
 
941
1101
  ## Skill discovery + cross-agent composition
942
1102
 
@@ -1,4 +1,8 @@
1
- # Configuration
1
+ ---
2
+ title: Configuration
3
+ description: "How to configure a skillscript-runtime deployment — connectors.json substrate selection, named MCP connector wiring, runtime knobs, and adopter-custom impls."
4
+ mode: wide
5
+ ---
2
6
 
3
7
  How to configure a skillscript-runtime deployment.
4
8
 
@@ -1,4 +1,8 @@
1
- # Connector Contract Reference
1
+ ---
2
+ title: Connector Contract Reference
3
+ description: "The substrate-neutral contracts adopters implement to wire their own backends. Interface signatures, payload shapes, durability + identity conventions."
4
+ mode: wide
5
+ ---
2
6
 
3
7
  The substrate-neutral contracts skillscript-runtime exposes for adopters to wire their own substrate behind. This doc is the **canonical source of truth** for the AgentConnector contract. The interface shape was locked at v1.0 by the v0.9.6 audit (thread `b722bbf4`); the wake/deliver receipt shapes carry post-lock refinements per the v0.18.2 session-targeting + graceful-degradation requirements.
4
8