Skip to content

skillscript-runtime v0.25.0

Choose a tag to compare

@github-actions github-actions released this 28 Jun 19:31

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.NAME in 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 — a shell(...) op or a $ connector.tool dispatch. (1) Lint (tier-1) blocks a marker anywhere else (emit, $set, a condition, an # Output: template, a file_*/notify op, an op (fallback: ...)) via secret-use-only, an undeclared marker via secret-undeclared, and a non-literal/dynamic name ({{secret.${VAR}}}) via secret-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 bundled EnvSecretProvider resolves {{secret.NAME}} from the env var SKILLSCRIPT_SECRET_NAME. The prefix is intentional — it scopes which environment is secret-reachable, so {{secret.PATH}} looks for SKILLSCRIPT_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 SecretProvider interface whose resolve(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 via bootstrap({ secretProvider }) or bootstrapFromEnv's overrides.
  • 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}}") -> OUT then emit("${OUT}"), or a curl that 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.