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>
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:
- Parses your command with
tree-sitter-bashinto an AST. - Walks the AST and pulls out every individual
commandnode (including ones nested inside&&,||,;,|, command substitutions,if/then, etc.). - Asks the permission engine to match each command's text
independently against your agent's
permission.bashallow/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/reporuns fine if bothmkdir -p /tmp/**andgit 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/repois 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/repois 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 zerocommandAST nodes, so nothing is checked. Pairing them with a command (WORK_DIR="/tmp/x" && mkdir -p "$WORK_DIR") only matches themkdir, not the assignment.
Hard rules — these constructs DO get denied
-
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
commandnode whose text contains real\ncharacters. The*incurl *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.jsonon a single line. -
No heredocs.
cat << 'EOF' > /tmp/fileandpython3 - << 'PY'look like one command, butsource(node)returns the entireredirected_statementtext 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 severalprintf "%s" "<chunk>" >> /tmp/filecalls 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. -
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\ninside, which the glob cannot span. Write the script to disk first (printf '...' > /tmp/script.py) and run it (python3 /tmp/script.py). -
Command substitution
$(...)and backticks add a second match target. Tree-sitter recurses into the substitution and extracts the inner command as its owncommandnode. Soecho $(date +%s)requires bothecho *ANDdate *to be allowed. If your agent only allowsecho *, this fails. Avoid$(...)unless every inner command is also explicitly allowed; prefer hardcoded values or${RANDOM}/${VAR}references. -
Nested commands inside
if,for,while,casestill get extracted bydescendantsOfType("command"). Whatever runs insidethen/do/)must independently match an allow rule.
When you receive a permission denied error
- If the command has line-continued args (
\+ newline inside a single command) — collapse onto one physical line, or write the payload to/tmp/body.jsonwithprintfand pass via-d @/tmp/.... - If you used a heredoc — replace with one or more single-line
printf "%s" "<body>" > /tmp/filecalls (double-quoted — apostrophes inside double quotes are literal text, no escaping needed). The single-quotedprintf '%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. - If you used
python3 -c "..."with a multi-line script — write the script to/tmp/script.pywithprintfand run it aspython3 /tmp/script.py. - 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. - 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.