skillscript-runtime v0.25.0
Upgrade impact: none (additive). New opt-in syntax + two new tier-1 lint rules that fire only on skills using the new syntax. Existing skills don't use {{secret.…}}, so nothing changes for them. Operators who want to provision a secret add a SKILLSCRIPT_SECRET_<NAME> env var.
A skill can now reference an operator-provisioned secret by name — use it at a sink, never read it. This is the durable answer to "my skill needs a bearer token / API key" that doesn't put the credential in the skill source, the transcript, or a trace.
- Declare + place.
# Requires: secret.NAMEin the frontmatter declares the secret (an approver sees exactly which credentials a skill can reach before vouching for it). A{{secret.NAME}}marker places it. The marker is deliberately distinct from${VAR}readable substitution:${VAR}resolves the skill's variable scope and cannot reach a secret, while{{secret.NAME}}resolves only at a sink and is never bound to a variable. - Use-only, enforced at three layers. A
{{secret.NAME}}marker is legal only inside a sink — ashell(...)op or a$ connector.tooldispatch. (1) Lint (tier-1) blocks a marker anywhere else (emit,$set, a condition, an# Output:template, afile_*/notifyop, an op(fallback: ...)) viasecret-use-only, an undeclared marker viasecret-undeclared, and a non-literal/dynamic name ({{secret.${VAR}}}) viasecret-dynamic-name. (2)${VAR}substitution treats a marker as opaque — it can never reach inside to build a secret name at runtime, so the name is always exactly as authored. (3) The runtime resolves only at the sink, only for names declared in# Requires(exact-literal match), the instant before spawn/dispatch — the value never binds to a var, emits, or enters a trace (the trace records the marker). Error messages and traces are redacted so a refused or failing op never renders a resolved value. These survive lint-bypass / source-mode execution — the runtime is the authoritative gate, not just lint. (Hardened against a red-team pass that found and closed a dynamic-name bypass and a refusal-error leak before ship.) - Provisioning (
.env). The bundledEnvSecretProviderresolves{{secret.NAME}}from the env varSKILLSCRIPT_SECRET_NAME. The prefix is intentional — it scopes which environment is secret-reachable, so{{secret.PATH}}looks forSKILLSCRIPT_SECRET_PATH, never$PATH. Secrets are operator-provisioned; a skill author names them but cannot set or read them. Fail-closed: an unprovisioned or undeclared secret aborts the op (it never sends an empty or literal-marker credential to the sink). - Vault-ready by construction. Resolution goes through a
SecretProviderinterface whoseresolve(name, ctx)already carries the requesting skill's identity, so a future vault-backed provider (per-script sealing, central rotation) drops in with no call-site changes — inject it viabootstrap({ secretProvider })orbootstrapFromEnv'soverrides. - Threat model (documented boundary). A secret you authorize a skill to use in a shell, that skill can also exfiltrate — e.g.
shell("echo {{secret.X}}") -> OUTthenemit("${OUT}"), or acurlthat POSTs it anywhere. This is inherent to argv injection and is not prevented by the runtime; the controls are approval review (a human approves the skill before it runs) and preferring a connector sink (the connector holds the credential out of band, so it never round-trips through skill-visible stdout). On the shell path the resolved value is also briefly visible in the host process list (ps); the vault era closes that with sink-aware injection (0600 temp file / piped env). Use a connector sink for credentials whose exposure you can't accept.