Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .claude/skills/spawn-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ Work autonomously, read the codebase as needed, and commit any changes.

## Spawning an agent

**Step 0: Assign BRANCH and TASK safely — never inline user text**

User-provided task text must NEVER be typed literally inside a double-quoted
shell argument: `$(...)`, backticks and `$VAR` expand on the **host** shell
before the container ever sees them. Always assign through a single-quoted
heredoc first, then expand only the variable:

```bash
# SAFE: single-quoted delimiter ('EOF') disables all expansion of the body
TASK=$(cat <<'EOF'
<paste the user's task text here, verbatim>
EOF
)
BRANCH=feat/my-feature # only [A-Za-z0-9._/-], no leading '-' or '/', no '..'
```

```bash
# UNSAFE — never do this (host expands $(...) inside double quotes):
container run ... --task "Fix the bug in $(parse_user_input)"
```

Branch names must match `[A-Za-z0-9._/-]`, start with an alphanumeric, and
contain no `..` — the `q` CLI rejects anything else, and raw `container run`
invocations must follow the same rule.

**Step 1: Check env vars**
```bash
test -n "$CLAUDE_CONTAINER_OAUTH_TOKEN" || echo "ERROR: export CLAUDE_CONTAINER_OAUTH_TOKEN=<token>"
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/spawn-agent/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
{
"id": 3,
"prompt": "Show me what agents are currently running. Also list the worktrees that exist.",
"expected_output": "Claude runs container list filtered by the project prefix (qubits-team), and also lists the worktrees directory. Shows the results in a readable format.",
"expected_output": "Claude runs container list filtered by the project prefix (basename of the git root, e.g. stackai), and also lists the worktrees directory. Shows the results in a readable format.",
"files": [],
"expectations": [
"Runs `container list` (not `container ps` or other docker-style commands)",
"Filters output by project prefix (e.g., grep qubits-team)",
"Filters output by project prefix (basename of git root, e.g., grep stackai)",
"Also shows worktrees on disk (ls on WORKTREES_DIR or equivalent)",
"Does NOT attempt to spawn a new agent"
]
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/spawn-agent/evals/list_and_monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ User says:
## Expected behavior (list)

1. Skill triggers
2. Runs: `container list 2>/dev/null | grep "qubits-team"`
2. Runs: `container list 2>/dev/null | grep "<project-name>"` (basename of git root, e.g. `stackai`)
3. Also shows worktrees on disk (with status from `status.json` if available)
4. Presents output in a readable format to the user

Expand All @@ -29,7 +29,7 @@ User says:
1. Skill triggers
2. Reads `status.json` from `$AGENTS_HOME/feat/jwt-auth/.agent/status.json` for quick status
3. If more detail needed, reads container logs or persisted `.agent/agent.log`
4. Sanitizes container name correctly: `feat/jwt-auth` → `qubits-team-feat-jwt-auth`
4. Sanitizes container name correctly: `feat/jwt-auth` → `<project-name>-feat-jwt-auth`
5. **Reads and summarizes** the output — does NOT just dump raw logs
6. Tells user: agent is working on X, currently at step Y, last action was Z

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/spawn-agent/evals/multi_agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ User says:
4. Launches 3 `container run -d` commands sequentially
5. Lists all 3 containers at the end with:
```bash
container list | grep "qubits-team"
container list | grep "<project-name>" # basename of git root, e.g. stackai
```
6. Tells user how to monitor each one

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/spawn-agent/evals/spawn_feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ User says:
2. Determines agent type = **feature**
3. Detects git root from current directory
4. Builds task prompt for feature type mentioning "JWT authentication in the API"
5. Sanitizes branch: `feat/jwt-auth` → container name `qubits-team-feat-jwt-auth`
5. Sanitizes branch: `feat/jwt-auth` → container name `<project-name>-feat-jwt-auth` (e.g. `stackai-feat-jwt-auth`)
6. Checks `CLAUDE_CONTAINER_OAUTH_TOKEN` is set (warns if not)
7. Runs `container run -d --rm ...` with:
- `--worktree feat/jwt-auth`
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/spawn-agent/evals/stop_agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ User says:

1. Skill triggers
2. Sanitizes branch for container name: `feat-jwt-auth`
3. Runs: `container stop qubits-team-feat-jwt-auth`
3. Runs: `container stop <project-name>-feat-jwt-auth` (e.g. `stackai-feat-jwt-auth`)
4. If user asked to clean worktree, also runs:
```bash
git -C <git-root> worktree remove --force <worktrees-dir>/feat/jwt-auth
Expand Down
6 changes: 0 additions & 6 deletions .gitmodules

This file was deleted.

4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ The rules below cannot be enforced by Ruff and must be applied by hand:
## Architecture

- **`config/`** — Container infrastructure: `Dockerfile.wolfi` (production, multi-stage: Rust tool compilation → runtime with Claude CLI, Node, Python), `entrypoint.sh` (credential injection + worktree creation + su-exec privilege drop), `Makefile` (orchestration)
- **`app/cli/`** — Python CLI (`q`) using Typer+Rich that wraps Makefile targets. Entry point registered as `q` in pyproject.toml. Commands delegate to `make` via `utils.run_make()`
- **`.claude/skills/`** — Host-side Claude Code skills for multi-agent orchestration (spawn-agent, spawn-agent-workspace)
- **`app/cli/`** — Python CLI (`q`) using Typer that wraps Makefile targets. Entry point registered as `q` in pyproject.toml. Commands delegate to `make` via `utils.run_make()`
- **`.claude/skills/`** — Host-side Claude Code skills for multi-agent orchestration (spawn-agent); `spawn-agent-workspace/` is the gitignored eval-output workspace (see `docs/agents/evals.md`)
- **`docs/agents/`** — All project documentation (container reference, CLI, setup/auth, skill architecture, evals)

### Key concepts
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ make stop-agent BRANCH=feat/oauth2 # stop when done
```
stackai/
├── app/
│ ├── cli/ # Python CLI (q command)
│ └── agents-templates/ # Agent template examples
│ └── cli/ # Python CLI (q command)
├── config/
│ ├── Dockerfile.wolfi # Production image (ARM64, glibc)
│ ├── Dockerfile # CI image (Alpine, amd64)
Expand All @@ -161,8 +160,7 @@ stackai/
│ ├── setup.md # Authentication guide
│ ├── evals.md # Evaluation framework
│ └── cli.md # CLI command reference
├── iac/ # Infrastructure as Code
└── model/ # ML fine-tuning experiments
└── iac/ # Infrastructure as Code
```

## How it works
Expand Down
1 change: 0 additions & 1 deletion app/agents-templates
Submodule agents-templates deleted from b715e4
2 changes: 1 addition & 1 deletion app/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "qubits-cli"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["typer>=0.12", "rich>=13"]
dependencies = ["typer>=0.12"]

[project.scripts]
q = "container_cli.main:app"
Expand Down
15 changes: 14 additions & 1 deletion app/cli/src/container_cli/commands/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
import typer

from container_cli.targets import Target
from container_cli.utils import check_token, print_agent_status, run_make
from container_cli.utils import (
check_token,
print_agent_status,
run_make,
validate_branch,
validate_task,
)

app = typer.Typer(help="Agent lifecycle commands")

Expand All @@ -23,6 +29,8 @@ def spawn(
] = None,
) -> None:
"""Spawn a detached headless agent container."""
validate_branch(branch)
validate_task(task)
check_token()
make_vars: dict[str, str] = {"BRANCH": branch, "TASK": task}
if cpus is not None:
Expand All @@ -47,6 +55,7 @@ def logs(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show logs for a branch agent."""
validate_branch(branch)
run_make(Target.LOGS_AGENT, {"BRANCH": branch})


Expand All @@ -55,6 +64,7 @@ def follow(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Follow live streaming logs for a branch agent."""
validate_branch(branch)
run_make(Target.FOLLOW_AGENT, {"BRANCH": branch}, tty=True)


Expand All @@ -63,6 +73,7 @@ def stop(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Stop a branch agent container."""
validate_branch(branch)
run_make(Target.STOP_AGENT, {"BRANCH": branch})


Expand All @@ -71,6 +82,7 @@ def status(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show agent status from persisted status.json file."""
validate_branch(branch)
print_agent_status(branch, label="status")


Expand All @@ -79,4 +91,5 @@ def summary(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show structured lifecycle events for a branch agent."""
validate_branch(branch)
run_make(Target.SUMMARY_AGENT, {"BRANCH": branch})
10 changes: 7 additions & 3 deletions app/cli/src/container_cli/commands/pi_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@
build.py are not modified — pi commands live under their own subapp.
"""

from __future__ import annotations

from typing import Annotated

import typer

from container_cli.targets import Target
from container_cli.utils import print_agent_status, run_make
from container_cli.utils import print_agent_status, run_make, validate_branch, validate_task

app = typer.Typer(help="PI agent lifecycle (local mlx_lm.server backend)")

Expand Down Expand Up @@ -63,6 +61,8 @@ def spawn(
The mlx_lm.server must be running on the host. Check with:
uv run iac server status
"""
validate_branch(branch)
validate_task(task)
typer.echo(
"[pi] reminder: ensure mlx_lm.server is running (`uv run iac server status` from /iac)"
)
Expand Down Expand Up @@ -91,6 +91,7 @@ def logs(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Show logs for a PI agent (live container or persisted log)."""
validate_branch(branch)
run_make(Target.LOGS_PI_AGENT, {"BRANCH": branch})


Expand All @@ -99,6 +100,7 @@ def follow(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Follow live streaming logs for a PI agent."""
validate_branch(branch)
run_make(Target.FOLLOW_PI_AGENT, {"BRANCH": branch}, tty=True)


Expand All @@ -107,6 +109,7 @@ def stop(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Stop a PI agent container."""
validate_branch(branch)
run_make(Target.STOP_PI_AGENT, {"BRANCH": branch})


Expand All @@ -115,4 +118,5 @@ def status(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Show PI agent status from persisted status.json file."""
validate_branch(branch)
print_agent_status(branch, label="pi-status")
3 changes: 1 addition & 2 deletions app/cli/src/container_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from container_cli.commands import agents, build, network, pi_agents, run

app = typer.Typer(name="q", help="Container management CLI for Claude agent containers")
agents_app = agents.app

# Register top-level commands from build module
app.command("build")(build.build)
Expand All @@ -24,7 +23,7 @@
app.command("spawn")(agents.spawn)

# Register agents sub-app
app.add_typer(agents_app, name="agents")
app.add_typer(agents.app, name="agents")

# Register PI agent sub-app (extension — local mlx_lm backend, no Claude token)
app.add_typer(pi_agents.app, name="pi")
Expand Down
49 changes: 47 additions & 2 deletions app/cli/src/container_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

import json
import os
import re
import subprocess
from pathlib import Path

import typer

from container_cli.targets import Target

# Branch names must start with an alphanumeric and use only safe characters,
# so a value can never be parsed as a flag (leading `-`), an absolute path
# (leading `/`), or a shell word boundary once it reaches make/git.
_BRANCH_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")


def find_git_root() -> Path:
"""Return the absolute path of the repository root.
Expand Down Expand Up @@ -51,6 +57,40 @@ def agents_home() -> Path:
return find_git_root().parent / ".worktrees"


def validate_branch(branch: str) -> None:
"""Reject branch names that could be misinterpreted by make, git, or the shell.

Args:
branch: Candidate git branch name received from the CLI.

Raises:
typer.Exit: With code 1 when the name is empty, contains `..`, or has
characters outside `[A-Za-z0-9._/-]` (or does not start with an
alphanumeric character).
"""
if not branch or ".." in branch or not _BRANCH_RE.fullmatch(branch):
typer.echo(f"[error] invalid branch name: {branch!r}", err=True)
raise typer.Exit(1)


def validate_task(task: str) -> None:
"""Reject task descriptions that could smuggle control characters to the host.

Args:
task: Task prompt text received from the CLI.

Raises:
typer.Exit: With code 1 when the task is empty or contains control
characters (newlines, carriage returns, tabs, NUL, DEL).
"""
if not task or any(ord(char) < 32 or ord(char) == 127 for char in task):
typer.echo(
"[error] invalid task: must be non-empty and contain no control characters",
err=True,
)
raise typer.Exit(1)


def check_token() -> None:
"""Verify that the Claude container OAuth token is exported.

Expand Down Expand Up @@ -100,9 +140,14 @@ def print_agent_status(branch: str, *, label: str) -> None:
label: Tag used in the not-found messages (e.g. `status`, `pi-status`).

Raises:
typer.Exit: With code 1 when no status file exists for the branch.
typer.Exit: With code 1 when the branch resolves outside the worktrees
directory or no status file exists for the branch.
"""
status_file = agents_home() / branch / ".agent" / "status.json"
base = agents_home().resolve()
status_file = (agents_home() / branch / ".agent" / "status.json").resolve()
if base not in status_file.parents:
typer.echo(f"[{label}] invalid branch path: {branch!r}", err=True)
raise typer.Exit(1)
if not status_file.exists():
typer.echo(f"[{label}] No status file found for branch '{branch}'.")
typer.echo(f"[{label}] Expected at: {status_file}")
Expand Down
46 changes: 46 additions & 0 deletions app/cli/tests/acceptance/features/input_validation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Feature: Input validation for branch and task arguments
As a user of the q CLI
I want malformed or malicious branch/task values rejected before reaching make
So that shell injection and path traversal cannot reach the host

Background:
Given the make runner is ready

Scenario: Spawn rejects a branch with shell metacharacters
Given the CLAUDE_CONTAINER_OAUTH_TOKEN is set
When I run "q spawn --branch 'feat;rm -rf x' --task implement-x"
Then the command exits with an error
And the output contains "invalid branch"
And the make runner was not invoked

Scenario: Spawn rejects a branch with path traversal
Given the CLAUDE_CONTAINER_OAUTH_TOKEN is set
When I run "q spawn --branch ../../escape --task implement-x"
Then the command exits with an error
And the output contains "invalid branch"
And the make runner was not invoked

Scenario: Spawn rejects a task containing control characters
Given the CLAUDE_CONTAINER_OAUTH_TOKEN is set
When I run spawn with a task containing a control character
Then the command exits with an error
And the output contains "invalid task"
And the make runner was not invoked

Scenario: Agent status rejects a branch that escapes the worktrees directory
When I run "q agents status --branch ../../../etc"
Then the command exits with an error
And the output contains "invalid branch"
And the make runner was not invoked

Scenario: Stop rejects a branch that begins with a dash
When I run "q agents stop --branch=-evil"
Then the command exits with an error
And the output contains "invalid branch"
And the make runner was not invoked

Scenario: PI spawn rejects a branch with shell metacharacters
When I run "q pi spawn --branch 'pi;evil' --task implement-x"
Then the command exits with an error
And the output contains "invalid branch"
And the make runner was not invoked
Loading
Loading