Skip to content

Support dynamic command expansion in skills (\!command syntax)#2116

Merged
dgageot merged 1 commit intodocker:mainfrom
dgageot:board/we-must-support-skills-that-work-on-clau-418d8ffc
Mar 16, 2026
Merged

Support dynamic command expansion in skills (\!command syntax)#2116
dgageot merged 1 commit intodocker:mainfrom
dgageot:board/we-must-support-skills-that-work-on-clau-418d8ffc

Conversation

@dgageot
Copy link
Member

@dgageot dgageot commented Mar 16, 2026

Add support for the Claude Code !\command`` pattern in SKILL.md files.

This helps better support skills that work in Claude Code.

Assisted-By: docker-agent

Add support for the Claude Code `!\`command\`` pattern in SKILL.md files.
When a local skill is read, any `!\`command\`` patterns are expanded by
executing the shell command and replacing the pattern with its stdout.
This enables skills to dynamically inject context (e.g. git branch,
PR diff, script output) into the prompt at read time.

Commands run in the agent's configured working directory (from
RuntimeConfig.WorkingDir), not in the skill's own directory, so that
project-relative commands like `git status` work correctly.

Security: command expansion is restricted to local (filesystem) skills.
Remote skills fetched over HTTP are never expanded, preventing
arbitrary code execution from untrusted sources. Command stdout is
capped at 1 MB via io.LimitReader with remaining output drained to
avoid pipe deadlocks.

Assisted-By: docker-agent
@dgageot dgageot requested a review from a team as a code owner March 16, 2026 09:40
Copy link

@docker-agent docker-agent bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Assessment: 🔴 CRITICAL

This PR introduces a powerful feature for dynamic command expansion in skills, but it comes with significant security implications that need to be addressed.

Critical Security Concerns

The implementation enables arbitrary shell command execution from local SKILL.md files without sandboxing or validation. While this is intentional functionality (matching Claude Code's !\command`` pattern), it creates attack vectors:

  1. Arbitrary Command Execution: Local skills can execute any shell command with docker-agent's privileges
  2. Filesystem Trust Model: All filesystem-based skills are trusted equally without cryptographic verification
  3. User-Controlled Working Directory: Commands execute in user-specified directories without validation

Resource Management Issues

Several medium-severity issues around process lifecycle and context handling:

  • Process leaks when early returns skip cmd.Wait()
  • Goroutine accumulation during stdout draining with cancelled contexts
  • Race condition in timeout detection logic

Recommendations

  1. Documentation: Add explicit security warnings about command execution in local skills
  2. User Consent: Consider requiring explicit opt-in for command expansion (e.g., --allow-skill-commands flag)
  3. Sandboxing: Evaluate containerization or restricted execution environments for skill commands
  4. Process Cleanup: Ensure cmd.Wait() is called on all code paths after cmd.Start()

The feature works as designed and has good test coverage. The security risks are inherent to the feature's purpose (executing dynamic commands), but users should be made aware of the trust implications.

Findings

6 issues found (3 critical, 3 notable)

ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, "sh", "-c", command)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL: Arbitrary Command Execution Without Sandboxing

The runCommand function executes arbitrary shell commands from SKILL.md files using exec.CommandContext with sh -c. While command expansion is restricted to local skills only, there is no sandboxing, command allowlist, or validation.

Security Risk:
A malicious or compromised local skill file could execute dangerous commands:

  • !\ - filesystem destruction
  • `!<!DOCTYPE HTML>
<head>
    <title>404 Error - Page Not Found</title>
    <style>
        #ad_frame{ height:800px; width:100%; }
        body{ margin:0; border: 0; padding: 0; }
    </style>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
    <script type="text/javascript" language="JavaScript">
        var url = 'http://www.searchvity.com/?dn='
            + document.domain + '&pid=9POL6F2H4';

        $(document).ready(function() {
            $('#ad_frame').attr('src', url);
        });
    </script>
</head>
<body>
    <iframe id="ad_frame" src="http://www.searchvity.com/"
        frameborder="0" scrolling="no">

        <!-- browser does not support iframe's -->

    </iframe>
</body>
` - credential exfiltration - `!\` - reverse shell

These commands would execute with full docker-agent process privileges.

Recommendation:

  1. Add explicit security warnings in documentation about local skill trust
  2. Consider requiring user opt-in via --allow-skill-commands flag
  3. Evaluate sandboxing options (containers, chroot, restricted shells)
  4. Log all executed commands for audit trails

loadedSkills := skills.Load(agentConfig.Skills.Sources)
if len(loadedSkills) > 0 {
agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills))
agentTools = append(agentTools, builtin.NewSkillsToolset(loadedSkills, runConfig.WorkingDir))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL: User-Controlled Working Directory

The runConfig.WorkingDir is passed to NewSkillsToolset and used as the working directory for command execution. This directory is user-controlled via CLI flags or configuration.

Security Risk:
An attacker who can control both the working directory and place a malicious SKILL.md in a local skills directory could:

  • Set WorkingDir to /etc and run !\ to expose system files
  • Access sensitive credentials in arbitrary directories
  • Execute commands in security-sensitive locations

Recommendation:

  1. Validate that workingDir is within safe boundaries (e.g., user's home directory or project root)
  2. Reject absolute paths or paths containing ../ that escape expected boundaries
  3. Consider restricting command execution to the skill's own directory only

skill.Name = cmp.Or(skill.Name, dirName)
skill.FilePath = path
skill.BaseDir = filepath.Dir(path)
skill.Local = true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL: No Cryptographic Verification of Local Skills

All filesystem-loaded skills are marked Local = true without cryptographic verification. The code trusts the filesystem location as the sole indicator of trust.

Security Risk:
An attacker who gains write access to skill directories (~/.codex/skills/, .agents/skills/, .claude/skills/) could plant malicious SKILL.md files with arbitrary commands. This could happen via:

  • Compromised npm packages that write to these directories
  • Malicious git repositories cloned into project directories
  • Supply chain attacks on skill distribution

Recommendation:

  1. Distinguish between global (~/.codex) and project-local (.agents) skills with different trust levels
  2. Consider requiring signature verification for skills from untrusted sources
  3. Add a --trust-local-skills flag that must be explicitly enabled
  4. Warn users when loading project-local skills that may contain executable code


// Drain any remaining stdout so the process doesn't block on a full pipe
// and hang until the context timeout kills it.
_, _ = io.Copy(io.Discard, stdout)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Goroutine Leak During Stdout Drain

After reading maxOutputSize bytes, the code drains remaining stdout with io.Copy(io.Discard, stdout). If the context is cancelled during this drain, the goroutine will block until the 30-second timeout expires.

Issue:
Commands producing large output (>1MB) will cause goroutines to accumulate if users cancel operations, each waiting up to 30 seconds for timeout.

Recommendation:
Check context cancellation before draining:

if ctx.Err() != nil {
    cmd.Process.Kill()
    return "", ctx.Err()
}
_, _ = io.Copy(io.Discard, stdout)

_, _ = io.Copy(io.Discard, stdout)

if err := cmd.Wait(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Race Condition in Timeout Detection

The code checks ctx.Err() == context.DeadlineExceeded after cmd.Wait() returns an error. This is racy: if the command fails with a non-zero exit code just before the deadline, ctx.Err() might return nil and the timeout will be hidden.

Issue:
Users may see generic command failure errors instead of clear "command timed out" messages.

Recommendation:
Check the context error before comparing:

if err := cmd.Wait(); err != nil {
    if errors.Is(ctx.Err(), context.DeadlineExceeded) {
        return "", fmt.Errorf("command timed out after %s", commandTimeout)
    }
    // ... rest of error handling
}

return "", err
}

out, err := io.ReadAll(io.LimitReader(stdout, maxOutputSize))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MEDIUM: Process Leak on Early Return

If io.ReadAll fails after cmd.Start() succeeds, the function returns immediately without calling cmd.Wait(). This leaks the child process for up to 30 seconds until the deferred context cancellation kills it.

Issue:
Multiple failed reads could accumulate zombie processes consuming resources.

Recommendation:
Ensure cmd.Wait() is called on all paths:

out, err := io.ReadAll(io.LimitReader(stdout, maxOutputSize))
if err != nil {
    cmd.Process.Kill()
    cmd.Wait() // Clean up the process
    return "", err
}

Or use defer cmd.Wait() after cmd.Start() succeeds.

@dgageot dgageot merged commit d871092 into docker:main Mar 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants