Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/prevent-command-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Prevent edited issue and comment events from replaying unchanged embedded `/squad` commands, require dual-principal repository authorization for `/squad revoke-improvement`, and ignore unauthorized revocation comments during live approval revalidation.
12 changes: 7 additions & 5 deletions docs/src/content/docs/guide/gh-aw.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ wins: `/squad plan accept scope` is not treated as `/squad plan`.
| Review | `/squad review` | Show how to rerun the current pull request's automatic independent review | Does not dispatch branch-selected code; human approval remains mandatory |
| Retrospective | `/squad retro` | Run the shared retrospective immediately | Authorized manual run; weekly and evidence-driven wakeups use the same durable gate |
| Governance | `/squad approve-improvement` | Request implementation of an exact retrospective proposal revision | Human write/maintain/admin permission, `Approved-Revision:` hash and exact `Approved-Path:` lines; dispatcher relays nested `issue_number` and `approval_comment_id`, never approval authority |
| Governance | `/squad revoke-improvement` | Withdraw a prior `/squad approve-improvement` | Reserved, read-only command available to any actor; emits no output of any kind — the comment itself is the record that later runs re-check |
| Governance | `/squad revoke-improvement` | Withdraw a prior `/squad approve-improvement` | Reserved state-changing command requiring write, maintain, or admin authorization; emits no output of any kind — the comment itself is the record that later runs re-check |
Comment thread
bradygaster marked this conversation as resolved.
Resolved
Comment thread
bradygaster marked this conversation as resolved.
Resolved

### Implementation provenance

Expand Down Expand Up @@ -875,9 +875,11 @@ and validates its human author/provenance, permissions, issue state, revision an
scope, both before work and before safe outputs; it never substitutes a newer
approval or treats a relay actor as an approver.

`/squad revoke-improvement` is a reserved no-dispatch route: a later human comment
withdraws the referenced approval. Missing/edited/stale/revoked approvals require
a fresh comment for the current content. Manual retries select
`/squad revoke-improvement` is a reserved no-dispatch route: only a later
unedited, non-app human issue comment from an author with live write, maintain,
or admin permission withdraws the referenced approval. Unresolved permission,
malformed timestamps, incomplete history, or missing/edited/stale/revoked
approvals fail closed and require a fresh comment for the current content. Manual retries select
**Actions → Squad Improvement Worker → Run workflow**, on the default branch,
and supply the same `issue_number` and `approval_comment_id` (the numeric suffix
of its `#issuecomment-N` URL). Retrying is not approval. Both workers remain
Expand All @@ -889,7 +891,7 @@ Opt-in reconciliation adds at most 48: three action pages, five all-state PR
pages and two comment pages for each of twenty candidates. Candidates rotate
every six hours; an incomplete history requires human inspection. Live retro
output checks add at most 14 reads; receiving ordinary-worker checks at most
nine. Improvement authorization is bounded to fourteen reads per check, twice
nine. Improvement authorization is bounded to 36 reads per check, twice
per worker run, plus two dispatcher checks. GitHub supplies no transaction
covering a comment and a PR: revocation is checked immediately before safe
outputs, not after publication; human review remains the final authority.
Expand Down
159 changes: 157 additions & 2 deletions test/gh-aw-command-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path';
import {
classifySquadCommand,
commandRequiresAuthorization,
editedCommandShouldRoute,
enforceSquadCommandContract,
isAuthorizedPermission,
rejectionComment,
Expand Down Expand Up @@ -115,6 +116,7 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
['\n\n/squad status\n', 'status', null],
['activate phase 3', 'activate', 3],
['research Focus only on P1', 'research', null],
['revoke-improvement', 'revoke-improvement', null],
])('accepts bare or slash-prefixed dispatch %j', (command, mode, phase) => {
expect(classifySquadCommand(dispatch(command), 'workflow_dispatch')).toMatchObject({
status: 'accepted',
Expand All @@ -124,6 +126,104 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
});
});

describe.each([
['issues', issue],
['issue_comment', comment],
] as const)('edited-event replay gate for %s', (eventName, payload) => {
function edited(previousBody: unknown, currentBody: string) {
return {
...payload(currentBody),
action: 'edited',
changes: { body: { from: previousBody } },
};
}

it.each([
[
'Context before.\n/squad plan implementation\nContext after.',
'Changed context before.\n\n /squad plan implementation \nChanged context after.',
],
[
'Context before.\n/squad status extra\nContext after.',
'Changed context before.\n\n /squad status extra \nChanged context after.',
],
])('noops when only surrounding prose or command whitespace changes', (before, after) => {
const event = edited(before, after);
const current = classifySquadCommand(event, eventName);
expect(editedCommandShouldRoute(event, eventName, current)).toBe(false);
});

it.each([
['/squad DANCE', '/squad dance'],
['/squad STATUS EXTRA', '/squad status extra'],
])('noops a rejected invocation case-only edit while preserving its current text: %j -> %j', (before, after) => {
const event = edited(before, after);
const current = classifySquadCommand(event, eventName);
expect(current).toMatchObject({ status: 'rejected', rejectedCommand: after });
expect(editedCommandShouldRoute(event, eventName, current)).toBe(false);
expect(rejectionComment(current as Extract<typeof current, { status: 'rejected' }>))
.toContain(`\`\`\`text\n${after}\n\`\`\``);
});

it.each([
['ordinary body', '/squad status'],
['/squad status', '/squad plan'],
['/squad status extra', '/squad dance'],
['Use `/squad status` after review.', '/squad status'],
['```text\n/squad status\n```', '/squad status'],
])('routes a newly introduced or materially changed command: %j -> %j', (before, after) => {
const event = edited(before, after);
const current = classifySquadCommand(event, eventName);
expect(editedCommandShouldRoute(event, eventName, current)).toBe(true);
});

it('rejects issue-body and edited-comment revocations but accepts a new comment revocation', () => {
expect(classifySquadCommand(issue('/squad revoke-improvement'), 'issues')).toMatchObject({
status: 'rejected',
source: 'issue',
reason: 'Issue-body revocations are not durable. Post /squad revoke-improvement as a new, unedited human issue comment.',
});
expect(classifySquadCommand(comment('/squad revoke-improvement'), 'issue_comment')).toMatchObject({
status: 'accepted',
source: 'comment',
mode: 'revoke-improvement',
});
expect(classifySquadCommand({
...comment('/squad revoke-improvement'),
action: 'edited',
}, 'issue_comment')).toMatchObject({
status: 'rejected',
source: 'comment',
reason: 'Edited comment revocations are not durable. Post /squad revoke-improvement as a new, unedited human issue comment.',
});
});

if (eventName === 'issue_comment') {
it('routes an unchanged edited revocation from its accepted pre-edit state to rejection', () => {
const event = edited('/squad revoke-improvement', '/squad revoke-improvement');
const current = classifySquadCommand(event, eventName);
expect(current).toMatchObject({
status: 'rejected',
reason: 'Edited comment revocations are not durable. Post /squad revoke-improvement as a new, unedited human issue comment.',
});
expect(editedCommandShouldRoute(event, eventName, current)).toBe(true);
});
}

it.each([
undefined,
null,
42,
{ unexpected: 'shape' },
])('fails closed when previous-body evidence is unavailable or malformed: %j', previousBody => {
const event = previousBody === undefined
? { ...payload('/squad status'), action: 'edited' }
: edited(previousBody, '/squad status');
const current = classifySquadCommand(event, eventName);
expect(editedCommandShouldRoute(event, eventName, current)).toBe(false);
});
});

it.each(['', ' ', '\n\n'])('keeps empty activation probe non-eventful: %j', command => {
expect(classifySquadCommand(dispatch(command), 'workflow_dispatch')).toEqual({
status: 'none',
Expand Down Expand Up @@ -162,6 +262,23 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
expect(posted[0].body).toContain('The workflow stopped without running a Squad mode.');
});

it('fails the shared main-workflow contract for an issue-body revocation', async () => {
const posted: Array<{ issueNumber: number; body: string }> = [];
await expect(
enforceSquadCommandContract({
payload: issue('/squad revoke-improvement'),
eventName: 'issues',
createComment: async (issueNumber, body) => {
posted.push({ issueNumber, body });
},
}),
).rejects.toThrow('Squad rejected command: /squad revoke-improvement');

expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({ issueNumber: 1824 });
expect(posted[0].body).toContain('Issue-body revocations are not durable.');
});

it('does not comment or throw for valid commands and no-invocation non-events', async () => {
const posted: string[] = [];
const createComment = async (_issueNumber: number, body: string) => {
Expand Down Expand Up @@ -233,10 +350,10 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
});

it('keeps open modes public and fails mutating modes closed on collaborator permission', () => {
for (const mode of ['status', 'review', 'research', 'plan', 'revoke-improvement']) {
for (const mode of ['status', 'review', 'research', 'plan']) {
expect(commandRequiresAuthorization({ status: 'accepted', mode })).toBe(false);
}
for (const mode of ['cast', 'triage', 'plan implementation', 'implement']) {
for (const mode of ['cast', 'triage', 'plan implementation', 'implement', 'revoke-improvement']) {
expect(commandRequiresAuthorization({ status: 'accepted', mode })).toBe(true);
}
expect(isAuthorizedPermission('admin')).toBe(true);
Expand Down Expand Up @@ -272,6 +389,44 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
}
});

it('realistic mutations expose issue-body revocation dispatch and rejected case-only replay', async () => {
mkdirSync(TEST_ROOT, { recursive: true });
const root = mkdtempSync(join(TEST_ROOT, 'command-revoke-replay-mutants-'));
try {
const source = join(process.cwd(), 'workflows', 'shared', 'squad-command-contract.mjs');
const original = readFileSync(source, 'utf8');

const revokeMutant = join(root, 'squad-command-contract-revoke-mutant.mjs');
const withoutIssueGuard = original.replace(
" if (sourceData.source === 'issue' && classification.mode === 'revoke-improvement') {",
" if (false && sourceData.source === 'issue' && classification.mode === 'revoke-improvement') {",
);
expect(withoutIssueGuard).not.toBe(original);
writeFileSync(revokeMutant, withoutIssueGuard);
const revokeModule = await import(`${pathToFileURL(revokeMutant).href}?mutation=${Date.now()}`);
expect(revokeModule.classifySquadCommand(issue('/squad revoke-improvement'), 'issues'))
.toMatchObject({ status: 'accepted', mode: 'revoke-improvement' });

const replayMutant = join(root, 'squad-command-contract-replay-mutant.mjs');
const withoutRejectedCaseNormalization = original.replace(
"result.rejectedCommand.replace(/\\s+/g, ' ').trim().toLowerCase(),",
"result.rejectedCommand.replace(/\\s+/g, ' ').trim(),",
);
expect(withoutRejectedCaseNormalization).not.toBe(original);
writeFileSync(replayMutant, withoutRejectedCaseNormalization);
const replayModule = await import(`${pathToFileURL(replayMutant).href}?mutation=${Date.now() + 1}`);
const editedEvent = {
...comment('/squad status extra'),
action: 'edited',
changes: { body: { from: '/squad STATUS EXTRA' } },
};
const current = replayModule.classifySquadCommand(editedEvent, 'issue_comment');
expect(replayModule.editedCommandShouldRoute(editedEvent, 'issue_comment', current)).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('realistic fence-length mutation exposes a hidden command and is caught', async () => {
mkdirSync(TEST_ROOT, { recursive: true });
const root = mkdtempSync(join(TEST_ROOT, 'command-fence-mutant-'));
Expand Down
Loading
Loading