Security
run_command_line runs arbitrary shell commands. The runtime applies two layers of guardrails before each call: a structural check that blocks dangerous shell constructs, and a per-skill allowed-tools allowlist.
The structural check is capability-gated: each skillet declares, in script_runtime.shell_security, exactly which shell features its skills may use. The default grants only quoted heredocs — pipes, chaining, and substitution are all rejected — so a skillet is locked down unless you opt features back in.
These guardrails reduce the blast radius if the LLM goes off-script. They are not a sandbox. For real isolation, run with script_runtime: container (Container runtime).
Both checks run by default on every run_command_line call. They can be disabled for local debugging by setting SKILLET_BYPASS_SECURITY=1, which skips the structural check and the allowed-tools allowlist (the 30 s timeout and cwd = skill folder still apply). Leave it unset in production — it exists only to unblock local testing.
Source: src/script_runner/script_runner_security.ts.
Shell capabilities (shell_security)
A skillet’s script_runtime.shell_security is a flat list of capability atoms. Each atom grants one normally-forbidden shell feature; the resolved policy is the set of listed atoms. There are no presets — the list is the policy, so an empty list is the most locked-down state.
| Atom | Grants |
|---|---|
seclvl_pipe | | — pipelines. Every stage’s leading command must be in allowed-tools (see below). |
seclvl_chain | && || ; and bare newlines. |
seclvl_subst | $() and backtick command substitution. |
seclvl_heredoc | Quoted heredocs (<<'EOF'). |
seclvl_path_escape | Paths that resolve outside the crew root. Absent ⇒ the path sandbox is enforced. |
# .skilled_crew.yaml
script_runtime:
kind: local
shell_security: [seclvl_pipe, seclvl_heredoc] # allow `curl ... | jq ...`The default when shell_security is omitted is [seclvl_heredoc], which reproduces the historical strict behavior: a single command, quoted heredocs allowed, no pipe / chaining / substitution, and the crew-root path sandbox enforced. Every existing skillet keeps that behavior unchanged.
Structural check
Every command goes through ScriptRunnerSecurity.checkCommand first. It rejects each of the following unless the matching capability is granted:
| Pattern | Blocked unless granted by |
|---|---|
| | seclvl_pipe |
&& || ; newlines | seclvl_chain |
$() backtick ` | seclvl_subst |
.. in a token, or an absolute path outside the crew root | seclvl_path_escape |
Redirections (> >> <), comments (#), parentheses | (no atom grants these — always rejected) |
If the LLM emits a construct that isn’t granted, the call throws before the shell runs. The LLM gets the error message back and typically tries something simpler on the next turn.
cdis never available.cwdis always the skill folder. Skills that need to operate elsewhere should pass paths relative to the skill folder.- Redirections are never granted by any current atom. If you need to write a file, do it from a script the skill calls.
Pipelines and the safety invariant
When a skillet grants seclvl_pipe, a command is treated as a pipeline. The runtime tokenizes it quote-aware (so a | inside a quoted jq filter like '.[] | .title' is not a stage separator), splits it into stages on real |, and requires every stage’s leading command to be in the skill’s allowed-tools.
That per-stage check is what makes a pipe safe:
curl -s "$URL" | jq '.[:10]' # allowed when allowed-tools = Bash(curl:*) Bash(jq:*)
curl -s "$URL" | sh # rejected — `sh` is not in allowed-toolsA pipe is only as safe as
allowed-tools.seclvl_pipewith an emptyallowed-toolsis wide open — every stage passes, including| sh. Always pairseclvl_pipewith a non-empty allowlist.
allowed-tools
A skill restricts its own commands via allowed-tools in SKILL.md frontmatter:
---
name: git-stats
description: Report git statistics on the current repo.
allowed-tools: Bash(git:*)
---Each pattern has the form Bash(<command>:*). The runtime takes the leading command of each pipeline stage and requires it to match one of the declared commands. Multiple patterns are space-separated and compose as an OR:
allowed-tools: Bash(git:*) Bash(jq:*)Means the skill can call git ... or jq ... (or pipe between them, with seclvl_pipe) but nothing else. An empty / omitted allowed-tools applies no restriction.
What’s not implemented
- Non-
Bashtools. Only theBash(<command>:*)form is supported. ARead,Write, or bare-Bashtoken inallowed-toolsis not silently ignored — the check throwsUnsupported tool: <name>. Keepallowed-toolstoBash(...)patterns. - Argument-level restrictions.
Bash(git:*)lets the LLM callgit push --forceif it wants to. The*is wildcard for arguments; there’s no per-argument filter. - Redirection / output capture. No
shell_securityatom grants>>><— pipelines and heredocs are the supported composition forms. - Environment scrubbing. Skills inherit the parent process env (including
OPENAI_API_KEY). Don’t put secrets in your shell env if you don’t want your skills to see them.
Recommended defaults
- Leave
shell_securityat its default unless a skill genuinely needs a feature. Grant the narrowest set — e.g.[seclvl_pipe, seclvl_heredoc]for a skill that pipescurlintojq. - Set
allowed-toolson every skill that doesn’t need general shell — and always when you grantseclvl_pipe, since that’s what bounds each pipeline stage. - Run in container when running untrusted skillets or skillets that touch the network.
shell_security+allowed-toolsare defense-in-depth, not a security boundary. - Don’t bind-mount your home directory into the container. The default mounts only the agent folder; keep it that way unless you have a specific reason otherwise.
Reporting
If you find a way around either guardrail, please open a security issue on the repository.