Skip to content

v7.200.5

Choose a tag to compare

@github-actions github-actions released this 27 Oct 01:15
cda37c8

Coders can remove an empty directory outside their declared scope

What

The shell write-scope gate no longer blocks a bare rmdir <dir> just because
the directory is outside the coder's declared scope, as long as the directory
is inside the workspace.

  • Admitted: rmdir <dir> with no options and exactly one literal, unquoted
    operand whose location, with every symlink or junction in its parent chain
    resolved, is strictly inside the session workspace, when the command runs
    under a POSIX shell (the bash tool, or the shell tool on a non-Windows
    host). The target must not exist yet or must be a real directory.
  • The exemption applies only to a plain command line, judged on the
    command text after the gate's existing eval / sh -c unwrapping. That
    text may hold only letters, digits, spaces, newlines and the characters
    _ . / - = & | ; < >, and every command in it must be one of rmdir,
    mkdir, echo, true, false or test. None of these commands can
    change the shell's working directory, and without $, quotes,
    backslashes, backticks, parentheses, braces, brackets or # nothing in
    that text can expand, be evaluated, be quoted, define a function or start
    a comment, so rmdir runs in the directory the gate checks and removes
    exactly the operand the gate checks. && / || / ; lists, pipelines,
    redirections such as 2>/dev/null, FOO=1 rmdir x prefixes and if and
    loop bodies are fine:
    rmdir .scratch-a/tmp1, rmdir dir/, rmdir ./dir, rmdir a && rmdir b,
    mkdir x && rmdir x, rmdir a 2>/dev/null || true,
    rmdir a && echo done and FOO=1 rmdir a keep the exemption, and each
    rmdir of a distinct operand is judged separately.
  • Every other line gives every rmdir in it the ordinary declared-scope
    check. That includes any text with a character outside the set above:
    rmdir "dir", rmdir a && echo "done", echo $HOME; rmdir a,
    [ -d src ] && rmdir a, subshells and brace groups ((rmdir a),
    { rmdir a; }), case, $(...), backticks, any # (rmdir d#/x
    removes the one directory d#/x in bash, and rmdir a # note loses the
    exemption too), and paths containing ~ + , : @ %. An interior tab is
    refused outright as unsafe command text, and an interior carriage return
    is never exempt either. It also includes any other
    command in the line: cd, pushd, popd, function
    definitions, source and ., trap, alias,
    an upper-case RMDIR, and every other command
    (ls d && rmdir d, cd build && rmdir tmp). A line with a
    here-document normally loses the exemption too, because the gate reads
    the body and delimiter lines as commands that must also be on the list
    (a body on a plain line cannot expand anything). This closes code that
    bash evaluates at run time, such as the bash 5.3 current-shell
    substitutions ${ cd ..; } and ${| cd ..; }, including a quoted one
    that arithmetic ($((x))) or a test -v / [ -v subscript evaluates.
  • Unchanged (still blocked as before): rmdir -p, any other option
    (--ignore-fail-on-non-empty, --), several operands, globs, brace
    expansion, variables or command substitution in the operand, targets
    outside the workspace, symlink escapes, the workspace root itself, every
    recursive form (rm -rf, rmdir /s, rd /s), a target that exists as a
    file or symlink, and any rmdir run by a Windows executor (a
    powershell -Command / pwsh -c / cmd /c wrapper, a PowerShell-shaped
    command, or the shell tool on Windows). In PowerShell rmdir is an
    alias of Remove-Item, which also deletes files.
  • Never exempt (the ordinary declared-scope check applies):
    • a sequence that names the same operand more than once, for example
      rmdir a; cd ../other && rmdir a or rmdir a; rmdir -p a, because the
      gate cannot tell those writes apart;
    • an operand with a .. path segment (link/../x), even on a plain
      line;
    • repository control state: a target under the workspace's .git or
      .swarm directory; the exemption never applies there for any role.
  • File-authority checks still apply to the target and are unchanged; only
    the declared-scope membership check is skipped. Default-protected prefixes such as .github/workflows,
    docs/releases and node_modules are not separately protected for a bare
    rmdir (issue #3450 scopes the exemption to the whole workspace), so a
    coder whose declared scope excludes them can now remove an empty
    directory there. The target's parent directory must already exist when
    the gate runs: mkdir x && rmdir x keeps the exemption (the parent is the
    workspace root), but mkdir -p d/e && rmdir d/e in one command still gets
    the ordinary scope check because d does not exist yet.
  • /swarm guardrail explain has no tool or platform flag and evaluates the
    command as the shell tool, so on a Windows host it conservatively reports
    block for a bare rmdir that the live bash tool admits. The
    scope_validate MCP advisory does not apply the exemption at all and also
    stays conservative.

Why

rmdir(2) refuses to remove a non-empty directory (ENOTEMPTY), so a bare
rmdir cannot destroy file data. Blocking it protected nothing, but it left
agent-created empty scratch directories behind and forced a human to clean
them up. The gate does not check whether the directory is empty first: that
check would race with the command (TOCTOU). The kernel's refusal is the
authoritative check.

Migration

None.


Fix: sentinel receipt conflicts report their real reason, not store_unavailable

What

Filing a second knowledge_receipt with trace_id: "none" (the no-retrieval
sentinel) in the same session used to be rejected as store_unavailable —
the retry-inviting infrastructure category — even though the rejection is a
permanent, deterministic identity conflict: within one session the first
"none" receipt fixes the recorded task_id/phase (and grace window), and
every later filing that disagrees conflicts with the immutable record.

The conflict is now reported with the precise, actionable reason from the
existing receipt taxonomy:

  • wrong_task — the filed task_id differs from (or is omitted against, in
    either direction) the recorded one;
  • wrong_phase — the filed phase differs from or is omitted against the
    recorded one;
  • duplicate_conflicting_terminal — the recorded grace window no longer
    matches. Task and phase agree in this case; the trigger is a change to the
    knowledge.receipt_close_grace_days setting between the two filings, so
    re-filing cannot resolve it — an operator restores the original setting.

The rejection detail keeps the conflicting immutable empty retrieval for …
prefix and names the conflicting axis with the recorded and filed values.
Every interpolated value is sanitized through the same single-line diagnostic
sanitizer used elsewhere in the plugin (control and bidirectional characters
become ?), truncated on code-point boundaries with a visible … marker
when over the per-field budget, and the axis sentence is budgeted to survive
in full. The knowledge_receipt tool surfaces the same code as reason, and
its description now distinguishes the two cases: wrong_task/wrong_phase
are fixed by re-filing with the originally recorded task_id/phase
(idempotent), while duplicate_conflicting_terminal is not fixable by
re-filing at all.

Genuine store failures keep their existing codes unchanged — lock exhaustion
still returns lock_timeout, journal corruption store_corrupt, and
unclassified I/O failures store_unavailable — so the permanent-conflict
class and the store class stay distinguishable by code, and only the store
class invites retry. The phase-complete gate's receipt-failure classifier
recognizes the three conflict codes as a permanent RECEIPT_CONFLICT bucket
— checked ahead of the detail-string heuristics for both the ledger result
shape and the validator's rejection shape. Its recovery points at the
knowledge_receipt tool for the wrong_task/wrong_phase arms (re-file
with the recorded identity); the duplicate_conflicting_terminal grace arm
is named as operator-actionable, since re-filing cannot resolve it.

Why

The misfiled category violated the plugin's failure-channel separation
(AGENTS.md invariant 9): agents reading store_unavailable legitimately treat
the failure as a transient store condition and retry, but the task/phase
conflicts are permanent for the session horizon. The precise codes already
existed in the receipt taxonomy but were never produced for this class, and no
test covered the conflict at all.

Migration

No breaking changes:

  • The immutability rule itself is unchanged — same-arguments retries remain
    idempotent with identical event ids as long as the resolved grace window is
    unchanged.
  • The rejection JSON's reason value on this path changes from
    store_unavailable to one of the three precise codes (the output shape is
    unchanged; the codes already existed in the receipt taxonomy).
  • No schema, journal-format, keying, or grace-policy change.