Skip to content

fix: refuse an rm whose path starts with a variable that can be empty - #889

Merged
REPPL merged 5 commits into
mainfrom
fix/guard-rm-unguarded-variable-path
Oct 10, 2026
Merged

REPPL merged 5 commits into
mainfrom
fix/guard-rm-unguarded-variable-path

Conversation

@REPPL

@REPPL REPPL commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

An agent's rm -f "$VAR"/* makes the host raise its own dangerous-rm prompt: if $VAR is empty, the path becomes /*. The person cannot tell whether the variable is set, so they approve blind. abcd's guard let the shape through, and sub-agents never receive the injected SHELL rules, so nothing steered an agent to the safe form first.

What changes

  • A new blocker entry, rm-unguarded-variable-path. It refuses an rm whose operand starts with a variable that can be empty followed by /: "$VAR"/…, $VAR/…, "${VAR}"/…, "$VAR/…", ${VAR:-}/…, "$1"/…. Its successor is "${VAR:?}"/… or a literal path. The guard refuses and never rewrites the command. "${VAR:?}"/…, non-empty defaults ("${TMPDIR:-/tmp}"/…), trims, a bare "$VAR" operand, a variable later in the path (./build/$name), and $HOME/$PWD (covered by the existing rm entries) are allowed.
  • A new operand predicate, arg_shapes, in the registry's pattern language. The tokenizer now records per expansion whether its operator guarantees a non-empty value (:?, :-, :=) or transforms it. sh -c strings are read the same way.
  • Teaching. The SHELL rules domain picks the entry up from the registry. Every agent definition that can run a shell carries a generated guard-lessons block holding the same lesson text, and agentlessons_test.go refuses a missing or stale block (-update regenerates it). Those agents get a PATCH prompt_version bump with an agents CHANGELOG entry, as the agent-contract lint requires. commands/guard.md describes the entry.
  • Existing tests and scripts. Six guard tests that pinned "$BUILD_DIR"/*-style shapes as allowed now expect the block. hooks/bootstrap.sh and scripts/preflight-receipt.sh use ${VAR:?} on variables that are provably never empty, so their behaviour is unchanged.

Review

An independent review read the diff and probed about 80 inputs, then returned SHIP. It raised three minor notes, captured for a follow-up: the successor's own form is refused inside a double-quoted sh -c string, a nested default guard is not read, and a variable followed by a substitution is not followed.

Still to check by hand: in a live host session, the guard's refusal should land before the host's own prompt.

make preflight is clean on 8458cdd52.

Resolves: iss-2610091942156774
Refs: iss-2610100938485695
Assisted-by: Claude:claude-opus-5-5

REPPL added 5 commits October 10, 2026 09:37
…h through

An agent's `rm -f "$VAR"/*` raises the host's own permission prompt,
which the person cannot judge. The product thinker chose a guard entry
that blocks the shape with the `${VAR:?}` rewrite as its successor, plus
the same rule taught to agents, built after the v0.13.4 cut.

Refs: iss-2610091942156774
Assisted-by: Claude:claude-opus-5-5
An agent's rm -f "$VAR"/* raised the host's own dangerous-rm prompt, which
the person had to approve without knowing whether VAR was empty, while the
guard let the shape through. The bundled registry now carries the blocker
rm-unguarded-variable-path. It refuses an rm whose operand starts with a
variable that can be empty directly followed by a slash ("$VAR"/x, $VAR/x,
"${VAR}"/x, ${VAR}/x and "$VAR/x"), inside a string a shell runs as on the
line, and its successor is the rewrite "${VAR:?}"/... or a literal path.
The guard never rewrites the command itself.

The pattern language gains one operand predicate, arg_shapes, whose only
shape is unguarded-variable-path. The tokenizer decides it per word from
the expansions it recorded, because the written spelling cannot tell
${VAR:?} from ${VAR}. A default that cannot be empty, a variable later in
the path, a bare "$VAR", a trim or other transform, and $HOME and $PWD are
not refused.

The SHELL domain teaches the entry from the registry. Every agent
definition that can run a shell, except the scribe, whose definition is
held to ledger content, carries a block generated from the same entry and
kept in step by a test, with its prompt_version bumped and a changelog
entry. Two repository scripts (hooks/bootstrap.sh, scripts/preflight-receipt.sh)
that removed under a variable that is never empty now spell it with :?, so
running them is not refused.

Resolves: iss-2610091942156774
Assisted-by: Claude:claude-opus-5-5
The review of the new entry found the successor's own rewrite refused
inside a double-quoted shell string, a nested default guard not read, and
a variable followed by a substitution not followed. All three are minor and
recorded for a follow-up rather than widening this change.

Refs: iss-2610100938485695
Assisted-by: Claude:claude-opus-5-5
Main took question-drafter to 0.1.1 for the question-chip change while this
branch took it to 0.1.1 for the guard-lessons block, so the merged definition
changed without a bump. This branch's entry becomes 0.1.2.

Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 3a6f790 Oct 10, 2026
13 checks passed
@REPPL
REPPL deleted the fix/guard-rm-unguarded-variable-path branch October 10, 2026 19:57
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.

1 participant