Skip to content

Least-privilege pre-authorization of skill frontmatter commands (fix silent slash-command no-ops) #233

Description

@saucam

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:

  1. Enumerate skill dirs — user (~/.claude/skills) and project (<workdir>/.claude/skills).
    Pack skills are symlinked into the user dir, so they are covered.
  2. For each SKILL.md, extract every !`...` substitution.
  3. Emit one verbatim Bash(<exact command>) rule per distinct command — no wildcards,
    no prefix grants.
  4. Append to the existing allowedTools array.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions