fix(guard): refuse every asked-about call where no one can be asked

In the permission mode that shows no prompts, pre-bash-executor refused only a
merge into main and a push. A tag, a package publish and destructive git still
went through unattended, which is what the approval guard exists to stop. Now
every call it would ask about, and a command it could not read, is refused
there with the way forward (operator decision 2026-10-09). No new exemption:
the mode is still read in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-09 19:36:21 +02:00
commit bb0fb361a5
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 19 additions and 11 deletions

View file

@ -410,7 +410,7 @@ The executor implements defense-in-depth security across three layers:
**Guards during the build.** All are command hooks; none runs a model. **Guards during the build.** All are command hooks; none runs a model.
- **Approval guard (P12)** — `pre-bash-executor.mjs` answers `ask` before a merge into `main`, a push, a tag or package publish, and destructive git (hard reset, forced clean, force-deleted branch, dropped stash, discarded changes, history rewrite). Claude Code shows the prompt labelled `[plugin:voyage]`, also in auto mode, and the prompt names the way forward. `post-approval-log.mjs` writes an approved merge or push to `main` (`merge`) and a tag or publish (`release`) to the approval log with a one-hour expiry. It logs exactly what the person was asked, and nothing in a permission mode that shows no prompts, where no one is asked; other pushes and destructive git are asked about but not logged. Claude Code's own notification for a waiting permission prompt is the alert; Voyage adds none. `/trekexecute` relies on it: where the guard asks, the executor never asks the same thing again. In the permission mode where Claude Code shows no prompts there is no one to ask, so the guard refuses a merge into `main` and a push instead, and says to run it yourself (operator decision 2026-10-09). - **Approval guard (P12)** — `pre-bash-executor.mjs` answers `ask` before a merge into `main`, a push, a tag or package publish, and destructive git (hard reset, forced clean, force-deleted branch, dropped stash, discarded changes, history rewrite). Claude Code shows the prompt labelled `[plugin:voyage]`, also in auto mode, and the prompt names the way forward. `post-approval-log.mjs` writes an approved merge or push to `main` (`merge`) and a tag or publish (`release`) to the approval log with a one-hour expiry. It logs exactly what the person was asked, and nothing in a permission mode that shows no prompts, where no one is asked; other pushes and destructive git are asked about but not logged. Claude Code's own notification for a waiting permission prompt is the alert; Voyage adds none. `/trekexecute` relies on it: where the guard asks, the executor never asks the same thing again. In the permission mode where Claude Code shows no prompts there is no one to ask, so the guard refuses every call it would ask about instead (merge into `main`, push, tag or publish, destructive git) and says to run it yourself (operator decisions 2026-10-09).
- **Plan gate (P3)** — `pre-plan-gate.mjs` runs Voyage's plan gate on the plan Claude Code's plan mode presents and sends an invalid Voyage plan back to planning; when the gate cannot run, the prompt says "not measured". An approved Voyage plan is logged with the hash of its text. A plan without `plan_version` passes untouched. - **Plan gate (P3)** — `pre-plan-gate.mjs` runs Voyage's plan gate on the plan Claude Code's plan mode presents and sends an invalid Voyage plan back to planning; when the gate cannot run, the prompt says "not measured". An approved Voyage plan is logged with the hash of its text. A plan without `plan_version` passes untouched.
- **Test lock (P9)** — while a step's test is locked (`lib/util/test-lock.mjs`), `pre-write-executor.mjs` refuses Write, Edit and NotebookEdit on it, and `stop-test-lock.mjs` refuses "done" in the session that took the lock until the executor unlocks it. - **Test lock (P9)** — while a step's test is locked (`lib/util/test-lock.mjs`), `pre-write-executor.mjs` refuses Write, Edit and NotebookEdit on it, and `stop-test-lock.mjs` refuses "done" in the session that took the lock until the executor unlocks it.
- **Post-edit check (P7)** — `post-edit-check.mjs` runs the target repo's own formatter and linter on each edited file (check-only; a tool is never downloaded) and logs the result. `lib/verification/step-check.mjs` re-runs them on the diff at step end, adds the type checker, and fails the step on findings. A repo without tools, or a configured tool that is missing or crashes, reads "not measured", never green. The check is on only while Voyage executes a plan in that repo, or when the repo switches it on with `.claude/voyage-guards.json` = `{ "post_edit_check": true }`. - **Post-edit check (P7)** — `post-edit-check.mjs` runs the target repo's own formatter and linter on each edited file (check-only; a tool is never downloaded) and logs the result. `lib/verification/step-check.mjs` re-runs them on the diff at step end, adds the type checker, and fails the step on findings. A repo without tools, or a configured tool that is missing or crashes, reads "not measured", never green. The check is on only while Voyage executes a plan in that repo, or when the repo switches it on with `.claude/voyage-guards.json` = `{ "post_edit_check": true }`.

View file

@ -16,7 +16,7 @@ All four pipeline commands accept a boolean `--gates {true|false}` flag. Presenc
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `gates_mode_flag_override`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`. Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `gates_mode_flag_override`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge (and any push) instead, since no one can be asked (operator decision 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself. **The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
### Path A/B/C decision (v3.4.0; Path C closed 2026-05-05) ### Path A/B/C decision (v3.4.0; Path C closed 2026-05-05)

View file

@ -22,8 +22,8 @@
// tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is // tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is
// labelled [plugin:voyage] and also appears in auto mode (hooks reference // labelled [plugin:voyage] and also appears in auto mode (hooks reference
// section PreToolUse decision control). Advisories are added to the prompt. // section PreToolUse decision control). Advisories are added to the prompt.
// In the permission mode that shows no prompts, a merge into main and a // In the permission mode that shows no prompts, every call it would ask
// push are BLOCKED instead: there is no one to ask (operator, 2026-10-09). // about is BLOCKED instead: there is no one to ask (operator, 2026-10-09).
// - WARN (exit 0): risky but recoverable operations — advisory to stderr // - WARN (exit 0): risky but recoverable operations — advisory to stderr
// - Allow (exit 0): everything else // - Allow (exit 0): everything else
// //
@ -274,13 +274,14 @@ try {
} }
// In the permission mode where Claude Code shows no prompts, an "ask" reaches // In the permission mode where Claude Code shows no prompts, an "ask" reaches
// no one and the call runs unattended. A merge into main and a push are refused // no one and the call runs unattended. Everything this guard would ask about —
// there instead, with the way forward (operator decision 2026-10-09: nothing // merge into main, push, tag/release, destructive git, and a command it could
// goes to main without a person, in any mode). /trekexecute relies on this: its // not read — is refused there instead, with the way forward (operator
// main-merge gate is a lock, and this guard's question is the one question. // decisions 2026-10-09: first merge and push, then the other two classes).
// /trekexecute relies on this: its main-merge gate is a lock, and this guard's
// question is the one question.
const NO_PROMPT_MODE = 'bypassPermissions'; const NO_PROMPT_MODE = 'bypassPermissions';
const unattended = input.permission_mode === NO_PROMPT_MODE const unattended = input.permission_mode === NO_PROMPT_MODE ? approvals : [];
? approvals.filter((m) => m.cls === 'merge' || m.cls === 'push') : [];
if (unattended.length > 0) { if (unattended.length > 0) {
process.stderr.write( process.stderr.write(
`[voyage] BLOCKED: ${unattended.map((m) => m.name).join(', ')} with no one to ask\n` + `[voyage] BLOCKED: ${unattended.map((m) => m.name).join(', ')} with no one to ask\n` +

View file

@ -319,6 +319,13 @@ test('P12: the guard asks before a merge into main; in the mode with no prompts
assert.match(blocked.stderr, /no one to ask/); assert.match(blocked.stderr, /no one to ask/);
assert.match(blocked.stderr, /Way forward/); assert.match(blocked.stderr, /Way forward/);
assert.equal(guard(dir, 'git push origin feature', NO_PROMPT_MODE).status, 2, 'a push is refused too'); assert.equal(guard(dir, 'git push origin feature', NO_PROMPT_MODE).status, 2, 'a push is refused too');
// Operator 2026-10-09, second decision: the other two classes as well.
for (const cmd of ['git tag v1.0', 'npm publish', 'git reset --hard HEAD~1', 'git push --force origin feature']) {
assert.match(guard(dir, cmd, 'default').stdout, /"permissionDecision":"ask"/, `the control: ${cmd} is asked where someone can answer`);
const r = guard(dir, cmd, NO_PROMPT_MODE);
assert.equal(r.status, 2, `${cmd}: refused where no one is asked`);
assert.match(r.stderr, /no one to ask/);
}
const plain = guard(dir, 'git status', NO_PROMPT_MODE); const plain = guard(dir, 'git status', NO_PROMPT_MODE);
assert.equal(plain.status, 0, 'an ordinary command still runs'); assert.equal(plain.status, 0, 'an ordinary command still runs');
assert.equal(plain.stdout, ''); assert.equal(plain.stdout, '');

View file

@ -67,7 +67,7 @@ const EXEMPT = new Map([
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']], ['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]], ['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']], ['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
['hooks/scripts/pre-bash-executor.mjs', ['mode-read', 1, "reads the session's permission_mode from hook input so a merge into main or a push is refused when no person can be asked; starts nothing - operator decision 2026-10-09"]], ['hooks/scripts/pre-bash-executor.mjs', ['mode-read', 1, "reads the session's permission_mode from hook input so every call the guard would ask about (merge into main, push, tag/release, destructive git) is refused when no person can be asked; starts nothing - operator decision 2026-10-09"]],
['tests/commands/trekexecute-coupling.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the guard under test; starts nothing - operator decision 2026-10-09']], ['tests/commands/trekexecute-coupling.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the guard under test; starts nothing - operator decision 2026-10-09']],
]); ]);