Problem
Skill SKILL.md files may contain shell substitutions (!`...`) that are evaluated
before the agent loop starts, to inject content into the prompt. In codeoid's headless
sessions there is no approval path for these: canUseTool is not consulted pre-loop and
there is no human at a terminal, so any command not pre-allowed is hard-denied — and a
single denial aborts the entire slash-command expansion.
Result: any /skill whose frontmatter runs an unallowed command is a no-op for every
codeoid user. See #232 for the upstream reporting defect that made this invisible.
#232's backstop makes this legible. This issue makes it not happen.
Lever
options.allowedTools — which the provider already builds at
src/daemon/providers/claude/index.ts:380 — governs expansion-time permission checks,
not just in-loop tool calls. Verified: --allowedTools "Bash(sh:*)" makes a
previously-denied expansion run, with no settings.json present.
This means codeoid can fix this in code, with no mutation of the user's repo and no
instruction to hand-edit settings files.
Matching semantics (measured)
Probe skill with !`sh ./hello.sh 2>/dev/null || sh ./hello2.sh`:
| Rule |
Result |
Bash(sh ./hello.sh 2>/dev/null || sh ./hello2.sh) — exact full string |
run (num_turns: 1) |
Bash(sh ./hello.sh:*) — prefix, first part only |
denied |
Bash(sh ./hello.sh:*),Bash(sh ./hello2.sh:*) — prefix, both parts |
run (num_turns: 1) |
The checker splits compound commands and requires every part allowed, or a single
verbatim rule covering the whole string. Exact match works — so no wildcard is needed.
Proposal
At query construction, derive allow rules from the skills the session actually exposes:
- Enumerate skill dirs — user (
~/.claude/skills) and project (<workdir>/.claude/skills).
Pack skills are symlinked into the user dir, so they are covered.
- For each
SKILL.md, extract every !`...` substitution.
- Emit one verbatim
Bash(<exact command>) rule per distinct command — no wildcards,
no prefix grants.
- Append to the existing
allowedTools array.
- Audit-log what was granted and which skill declared it (the
audit_log table exists).
Properties
- No user file mutation, no repo pollution, no
settings.json instructions.
- Grants exactly what installed skills declare — a skill you did not install grants nothing.
- Self-healing: rules are regenerated from source each session, so editing a skill cannot
leave a stale grant behind.
- Inspectable after the fact via the audit log.
Tradeoff to decide
This makes "installing a skill" imply "its declared commands may run without a prompt".
That is already true in interactive Claude Code — the difference is that a human
clicks approve there. So this automates existing exposure rather than creating new
exposure, but it is a real decision and should be conscious.
Mitigations, in increasing strictness:
- audit-log every auto-grant (baseline, should ship with it)
- config flag to disable
- L4: route first use through codeoid's existing approval channel (
session:approve
→ Telegram / web UI) instead of auto-granting. The SDK cannot approve frontmatter, but
codeoid has a human-in-the-loop channel the SDK does not know about. This is the option
that actually fits the identity-first thesis.
Explicitly rejected
Blanket Bash(sh:*) / Bash(cat:*) for every session — arbitrary code execution granted
by default, in a product whose pitch is least privilege.
Related
Problem
Skill
SKILL.mdfiles may contain shell substitutions (!`...`) that are evaluatedbefore the agent loop starts, to inject content into the prompt. In codeoid's headless
sessions there is no approval path for these:
canUseToolis not consulted pre-loop andthere is no human at a terminal, so any command not pre-allowed is hard-denied — and a
single denial aborts the entire slash-command expansion.
Result: any
/skillwhose frontmatter runs an unallowed command is a no-op for everycodeoid user. See #232 for the upstream reporting defect that made this invisible.
#232's backstop makes this legible. This issue makes it not happen.
Lever
options.allowedTools— which the provider already builds atsrc/daemon/providers/claude/index.ts:380— governs expansion-time permission checks,not just in-loop tool calls. Verified:
--allowedTools "Bash(sh:*)"makes apreviously-denied expansion run, with no
settings.jsonpresent.This means codeoid can fix this in code, with no mutation of the user's repo and no
instruction to hand-edit settings files.
Matching semantics (measured)
Probe skill with
!`sh ./hello.sh 2>/dev/null || sh ./hello2.sh`:Bash(sh ./hello.sh 2>/dev/null || sh ./hello2.sh)— exact full stringnum_turns: 1)Bash(sh ./hello.sh:*)— prefix, first part onlyBash(sh ./hello.sh:*),Bash(sh ./hello2.sh:*)— prefix, both partsnum_turns: 1)The checker splits compound commands and requires every part allowed, or a single
verbatim rule covering the whole string. Exact match works — so no wildcard is needed.
Proposal
At query construction, derive allow rules from the skills the session actually exposes:
~/.claude/skills) and project (<workdir>/.claude/skills).Pack skills are symlinked into the user dir, so they are covered.
SKILL.md, extract every!`...`substitution.Bash(<exact command>)rule per distinct command — no wildcards,no prefix grants.
allowedToolsarray.audit_logtable exists).Properties
settings.jsoninstructions.leave a stale grant behind.
Tradeoff to decide
This makes "installing a skill" imply "its declared commands may run without a prompt".
That is already true in interactive Claude Code — the difference is that a human
clicks approve there. So this automates existing exposure rather than creating new
exposure, but it is a real decision and should be conscious.
Mitigations, in increasing strictness:
session:approve→ Telegram / web UI) instead of auto-granting. The SDK cannot approve frontmatter, but
codeoid has a human-in-the-loop channel the SDK does not know about. This is the option
that actually fits the identity-first thesis.
Explicitly rejected
Blanket
Bash(sh:*)/Bash(cat:*)for every session — arbitrary code execution grantedby default, in a product whose pitch is least privilege.
Related
successwithnum_turns: 0#232 — upstream SDK reporting defect (num_turns: 0reported assuccess)