Files
cleveragents-core/.opencode/instructions/bash-commands.md
T
drew 2f1be34d12 feat(auto-agents): implementer parity — verify-invariant verifiers, implementer-helpers skill, watchdog gate, _opencode_worker audit
Closes the four open items in `docs/development/auto-agents-tier-2-3-plan.md`
§ "Revised remaining scope (2026-05-08)" plus three rounds of fresh-eyes
critique fold-in (rounds 3, 5, and post-round-5 polish).

Highlights:

- New continuous invariant verifiers on a shared `_verify_common.py`
  substrate: `verify_review_invariant.py` (R1: approval-without-CI) and
  `verify_implementer_invariant.py` (I1: head-commit fails commit-lint,
  I2: PR description missing Epic reference). Strictly additive cron-job-
  shaped scripts that open idempotent `auto/invariant-violation` issues;
  safe to run every 15 minutes in production.

- New `implementer-helpers` skill at
  `.opencode/skills/implementer-helpers/SKILL.md` + CLI at
  `tools/implementer_validate.py` (4 subcommands:
  validate-commit-message, validate-pr-compliance, validate-file-budget,
  validate-changelog). Mirrors the reviewer side; `tools/_commit_lint.py`
  is shared so a future change to commit policy updates one place.

- `auto-agents.md` watchdog gate: `DISPATCHERS_RUNNING=1` puts the
  primary orchestrator into watchdog-only mode. Heartbeat resolution +
  age computation factored into `tools/_watchdog_helpers.py` + the CLI
  `tools/watchdog_check.py` so the agent only needs
  `python3 tools/watchdog_check.py *` and `sleep *` bash permissions.
  The reader honours the env-var override first, then falls back to a
  freshest-mtime scan across `/var/run` / `$XDG_RUNTIME_DIR` / `/tmp`
  (deliberately diverging from the dispatcher's first-existing fallback
  to guard against stale heartbeats from previous root-owned sessions
  masking healthy user-mode heartbeats).

- `_opencode_worker.py` audit: structured `error_kind` classification
  at every transport-error / timeout return site, plumbed through
  `_dispatch_runtime.py` into the cycle-log; new `_request_read` retry
  helper (3 × 0.5s linear backoff, transport-only) wrapping every
  idempotent read in a worker session so a single transient flap on a
  polling GET cannot trash a 10-minute worker session.

- Static heredoc lint at `tests/auto_agents/test_prompt_heredoc_lint.py`
  glob-walks every agent prompt and skill recipe markdown, rejecting any
  heredoc bash recipe in a fenced code block (per `bash-commands.md`
  rule 2 — heredocs fail at OpenCode's permission-engine parse time).

- `bash-commands.md` rule 2 + its fix-it advice both lead with
  apostrophe-safe `printf "%s" "<body>"` (double-quoted) form;
  single-quoted form documented as the fragile JSON-only fallback.

- `CHANGELOG.md` carries the full multi-round narrative (round 3
  CRITICAL/HIGH/MEDIUM/LOW fold-in, round 5 docstring drift +
  telemetry refactor + broader heredoc lint scope, post-round-5
  doc-drift polish).

Net delta: +911 passing tests / 3 skipped (was 825 / 3); ruff clean
on every new file; pre-existing lint debt in `_dispatch_runtime.py`,
`_opencode_worker.py`, `conftest.py`, `_commit_lint.py` unchanged
and out of scope for this commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 11:48:12 -04:00

7.9 KiB

Bash Command Rules

These rules apply to every bash tool call you make. They describe the small set of constructs that hit a hard permission denied error from the OpenCode permission engine.

How permissions actually work

When you call the bash tool, OpenCode does not glob-match your raw command line as a single string. It:

  1. Parses your command with tree-sitter-bash into an AST.
  2. Walks the AST and pulls out every individual command node (including ones nested inside &&, ||, ;, |, command substitutions, if/then, etc.).
  3. Asks the permission engine to match each command's text independently against your agent's permission.bash allow/deny rules.

A bash invocation succeeds if every extracted command node matches an allow rule. It is denied as soon as any single command node fails to match.

This means the failure modes below are not about characters like && — they are about specific shell constructs whose extracted text either contains literal newlines (which the * glob cannot span) or contains inner commands that must also be allowed.

What is fine (despite previous documentation)

  • Single-line chains with &&, ||, ;, |, or background &. Each sub-command is matched independently. As long as every step has an allow rule, the whole chain runs. Example: mkdir -p /tmp/x && git clone https://... /tmp/x/repo runs fine if both mkdir -p /tmp/** and git clone * are allowed.
  • Line-continued chains with backslash-newline between commands. Tree-sitter consumes the continuation at the list level; each command's matched text is clean. Example: mkdir -p /tmp/x \ && git clone ... /tmp/x/repo is fine.
  • Inline ${VAR} references in arguments. The matcher sees the literal text ${VAR}, and * globs (and **) match it. Example: git clone "https://${TOKEN}@host/repo" /tmp/work/repo is fine.
  • ${RANDOM} for unique paths. Same as above — it is literal text to the matcher, expanded by the shell after the permission decision.
  • Bare variable assignments like WORK_DIR="/tmp/x". They produce zero command AST nodes, so nothing is checked. Pairing them with a command (WORK_DIR="/tmp/x" && mkdir -p "$WORK_DIR") only matches the mkdir, not the assignment.

Hard rules — these constructs DO get denied

  1. No multi-line continuations inside a single command's argument list. When you split one command's flags across lines with \ + newline:

    curl -s "https://api.example.com/foo" \
      -H "Authorization: token ${TOKEN}" \
      -d '{"key": "value"}'
    

    tree-sitter sees this as one command node whose text contains real \n characters. The * in curl * does not span newlines, so the match fails. Collapse onto a single physical line, or write the body to a file first and pass it via -d @/tmp/body.json on a single line.

  2. No heredocs. cat << 'EOF' > /tmp/file and python3 - << 'PY' look like one command, but source(node) returns the entire redirected_statement text including the heredoc body — newlines and all.

    Use printf "%s" "<body>" > /tmp/file (double-quoted) as the default form for any prose-y body (PR descriptions, commit messages, CHANGELOG entries, review bodies). Apostrophes inside double quotes are literal text — no escaping needed — and prose bodies don't contain shell substitutions (${VAR}, `cmd`, \X) in practice. Chain several printf "%s" "<chunk>" >> /tmp/file calls if the content does not fit on one physical line.

    The single-quoted form printf '%s' '<body>' is also legal but fragile: it breaks the moment the body contains a literal apostrophe (the apostrophe closes the single-quoted string and the rest of the line gets reparsed by the shell). Use it only for content you've personally inspected and confirmed has no apostrophes — JSON bodies with double-quoted keys and pre- serialised payloads are the typical safe case. For everything prose, use double quotes. See .opencode/skills/implementer-helpers/SKILL.md § Usage for the full rationale.

  3. No multi-line python3 -c "..." / node -e "..." / bash -c "..." strings. The interpreter accepts a multi-line argument, but the bash command containing it has a quoted argument with real \n inside, which the glob cannot span. Write the script to disk first (printf '...' > /tmp/script.py) and run it (python3 /tmp/script.py).

  4. Command substitution $(...) and backticks add a second match target. Tree-sitter recurses into the substitution and extracts the inner command as its own command node. So echo $(date +%s) requires both echo * AND date * to be allowed. If your agent only allows echo *, this fails. Avoid $(...) unless every inner command is also explicitly allowed; prefer hardcoded values or ${RANDOM} / ${VAR} references.

  5. Nested commands inside if, for, while, case still get extracted by descendantsOfType("command"). Whatever runs inside then/do/) must independently match an allow rule.

When you receive a permission denied error

  1. If the command has line-continued args (\ + newline inside a single command) — collapse onto one physical line, or write the payload to /tmp/body.json with printf and pass via -d @/tmp/....
  2. If you used a heredoc — replace with one or more single-line printf "%s" "<body>" > /tmp/file calls (double-quoted — apostrophes inside double quotes are literal text, no escaping needed). The single-quoted printf '%s' '<body>' form is also legal but breaks on apostrophes in the body, so reserve it for pre-serialised JSON / payload strings you've inspected to be apostrophe-free. Same posture as rule 2 above.
  3. If you used python3 -c "..." with a multi-line script — write the script to /tmp/script.py with printf and run it as python3 /tmp/script.py.
  4. If you used $(...) or backticks — drop the substitution. Use a hardcoded suffix from your prompt parameters (PR number, head SHA prefix), or the shell builtin ${RANDOM} for uniqueness. Inline ${VAR} references for env vars are always fine.
  5. If a single primitive command is still denied — the operation is genuinely outside your agent's allowed scope. Exit and report the error in your final response. Do not retry with cosmetic variations.

Examples

These all run cleanly:

mkdir -p /tmp/pr-review-worker-30
git clone "https://${FORGEJO_REVIEWER_PAT}@git.cleverthis.com/drew/cleveragents-core.git" /tmp/pr-review-worker-30/repo
mkdir -p /tmp/pr-review-worker-30 && git -C /tmp/pr-review-worker-30 status
git -C /tmp/pr-review-worker-30/repo config user.name "${GIT_USER_NAME}"
printf '{"event":"REQUEST_CHANGES","body":"Long review body...","commit_id":"abc123"}' > /tmp/pr-review-worker-30/review.json
curl -s -X POST "https://git.cleverthis.com/api/v1/repos/drew/cleveragents-core/pulls/30/reviews" -H "Authorization: token ${FORGEJO_REVIEWER_PAT}" -H "Content-Type: application/json" -d @/tmp/pr-review-worker-30/review.json

These get denied:

curl -s "https://api.example.com/foo" \
  -H "Authorization: token ${TOKEN}" \
  -d '{"key": "value"}'
cat << 'EOF' > /tmp/body.json
{
  "event": "REQUEST_CHANGES",
  "body": "Long review body..."
}
EOF
python3 -c "
import json
print(json.dumps({'a': 1}))
"
echo $(date +%s)

(The first three contain real newlines inside a single matched command; the last needs both echo * and date * allowed simultaneously.)

Extra: cd is a shell builtin and rarely useful

Most agents do not allow cd. Use git -C <path> for git operations and absolute paths for everything else. If you would have written cd /tmp/foo && git status, write git -C /tmp/foo status instead.