@@ -4,7 +4,9 @@ import { pathToFileURL } from 'node:url';
44import { join } from 'node:path' ;
55import {
66 classifySquadCommand ,
7+ commandRequiresAuthorization ,
78 enforceSquadCommandContract ,
9+ isAuthorizedPermission ,
810 rejectionComment ,
911} from '../workflows/shared/squad-command-contract.mjs' ;
1012
@@ -36,9 +38,10 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
3638 ] ) ( '%s call site' , ( _name , payload ) => {
3739 it . each ( [
3840 [ '/squad cast' , 'cast' , null ] ,
41+ [ ' /squad cast ' , 'cast' , null ] ,
3942 [ ' /squad cast ' , 'cast' , null ] ,
43+ [ ' /squad cast ' , 'cast' , null ] ,
4044 [ 'Context first.\n\n/squad status ' , 'status' , null ] ,
41- [ 'Please run /squad plan implementation' , 'plan implementation' , null ] ,
4245 [ '/squad research Focus only on proposal P1' , 'research' , null ] ,
4346 [ '/squad activate phase 2' , 'activate' , 2 ] ,
4447 [ '/squad plan accept implementation phase 12' , 'plan accept implementation' , 12 ] ,
@@ -82,6 +85,27 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
8285 } ) ;
8386 } ,
8487 ) ;
88+
89+ it . each ( [
90+ [ 'Please run /squad plan implementation' , 'embedded prose' ] ,
91+ [ 'Use `/squad cast` after review.' , 'inline code' ] ,
92+ [ '```text\n/squad cast\n```' , 'fenced code' ] ,
93+ [ '~~~text\n/squad cast\n~~~' , 'tilde-fenced code' ] ,
94+ [ '````text\n```\n/squad cast\n````' , 'code after a shorter backtick fence' ] ,
95+ [ '~~~~text\n~~~\n/squad cast\n~~~~' , 'code after a shorter tilde fence' ] ,
96+ [ '````text\n~~~~\n/squad cast\n````' , 'code after a different fence delimiter' ] ,
97+ [ '````text\n```` trailing-info\n/squad cast\n````' , 'code after a fence with trailing info' ] ,
98+ [ ' /squad cast' , 'four-space-indented code' ] ,
99+ [ '\t/squad cast' , 'tab-indented code' ] ,
100+ [ ' \t/squad cast' , 'one-space-plus-tab-indented code' ] ,
101+ [ ' \t/squad cast' , 'two-spaces-plus-tab-indented code' ] ,
102+ [ ' \t/squad cast' , 'three-spaces-plus-tab-indented code' ] ,
103+ ] ) ( 'ignores %s instead of activating control-plane work' , ( body ) => {
104+ expect ( classifySquadCommand ( payload ( body ) , 'issues' ) ) . toEqual ( {
105+ status : 'none' ,
106+ source : _name === 'issue body' ? 'issue' : 'comment' ,
107+ } ) ;
108+ } ) ;
85109 } ) ;
86110
87111 describe ( 'workflow_dispatch call site' , ( ) => {
@@ -186,6 +210,10 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
186210 expect ( DISCOVERY_WORKFLOW ) . toContain ( "startsWith(github.event.comment.body, '/squad ')" ) ;
187211 expect ( DISCOVERY_WORKFLOW ) . toContain ( 'squad-command-contract.mjs' ) ;
188212 expect ( DISCOVERY_WORKFLOW ) . toContain ( 'classifySquadCommand' ) ;
213+ expect ( DISCOVERY_WORKFLOW ) . toContain ( 'commandRequiresAuthorization' ) ;
214+ expect ( DISCOVERY_WORKFLOW ) . toContain ( 'getCollaboratorPermissionLevel' ) ;
215+ expect ( DISCOVERY_WORKFLOW ) . toContain ( 'isAuthorizedPermission' ) ;
216+ expect ( DISCOVERY_WORKFLOW ) . toContain ( 'No standalone Squad command was found' ) ;
189217 expect ( DISCOVERY_WORKFLOW ) . toContain ( 'rejectionComment' ) ;
190218 expect ( DISCOVERY_WORKFLOW ) . toContain ( 'github.rest.issues.createComment' ) ;
191219 expect ( DISCOVERY_WORKFLOW ) . toContain ( 'core.setFailed(`Squad rejected command:' ) ;
@@ -194,6 +222,21 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
194222 expect ( DISCOVERY_WORKFLOW ) . toContain ( "command: result.argumentText || 'cast'" ) ;
195223 } ) ;
196224
225+ it ( 'keeps open modes public and fails mutating modes closed on collaborator permission' , ( ) => {
226+ for ( const mode of [ 'status' , 'review' , 'research' , 'plan' , 'revoke-improvement' ] ) {
227+ expect ( commandRequiresAuthorization ( { status : 'accepted' , mode } ) ) . toBe ( false ) ;
228+ }
229+ for ( const mode of [ 'cast' , 'triage' , 'plan implementation' , 'implement' ] ) {
230+ expect ( commandRequiresAuthorization ( { status : 'accepted' , mode } ) ) . toBe ( true ) ;
231+ }
232+ expect ( isAuthorizedPermission ( 'admin' ) ) . toBe ( true ) ;
233+ expect ( isAuthorizedPermission ( 'maintain' ) ) . toBe ( true ) ;
234+ expect ( isAuthorizedPermission ( 'write' ) ) . toBe ( true ) ;
235+ expect ( isAuthorizedPermission ( 'triage' ) ) . toBe ( false ) ;
236+ expect ( isAuthorizedPermission ( 'read' ) ) . toBe ( false ) ;
237+ expect ( isAuthorizedPermission ( 'unresolved' ) ) . toBe ( false ) ;
238+ } ) ;
239+
197240 it ( 'realistic source mutation is caught by the accepted-command fixtures' , async ( ) => {
198241 mkdirSync ( TEST_ROOT , { recursive : true } ) ;
199242 const root = mkdtempSync ( join ( TEST_ROOT , 'command-contract-mutant-' ) ) ;
@@ -218,4 +261,63 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
218261 rmSync ( root , { recursive : true , force : true } ) ;
219262 }
220263 } ) ;
264+
265+ it ( 'realistic fence-length mutation exposes a hidden command and is caught' , async ( ) => {
266+ mkdirSync ( TEST_ROOT , { recursive : true } ) ;
267+ const root = mkdtempSync ( join ( TEST_ROOT , 'command-fence-mutant-' ) ) ;
268+ try {
269+ const source = join ( process . cwd ( ) , 'workflows' , 'shared' , 'squad-command-contract.mjs' ) ;
270+ const mutant = join ( root , 'squad-command-contract-mutant.mjs' ) ;
271+ cpSync ( source , mutant ) ;
272+ const original = readFileSync ( mutant , 'utf8' ) ;
273+ const changed = original . replace (
274+ ' && closingFence[1].length >= fence.length) {' ,
275+ ' ) {' ,
276+ ) ;
277+ expect ( changed ) . not . toBe ( original ) ;
278+ writeFileSync ( mutant , changed ) ;
279+ const module = await import ( `${ pathToFileURL ( mutant ) . href } ?mutation=${ Date . now ( ) } ` ) ;
280+ const hidden = comment ( '````text\n```\n/squad cast\n````' ) ;
281+ expect ( module . classifySquadCommand ( hidden , 'issue_comment' ) ) . toMatchObject ( {
282+ status : 'accepted' ,
283+ mode : 'cast' ,
284+ } ) ;
285+ expect ( classifySquadCommand ( hidden , 'issue_comment' ) ) . toEqual ( {
286+ status : 'none' ,
287+ source : 'comment' ,
288+ } ) ;
289+ } finally {
290+ rmSync ( root , { recursive : true , force : true } ) ;
291+ }
292+ } ) ;
293+
294+ it ( 'realistic CommonMark tab-indentation mutation exposes hidden commands and is caught' , async ( ) => {
295+ mkdirSync ( TEST_ROOT , { recursive : true } ) ;
296+ const root = mkdtempSync ( join ( TEST_ROOT , 'command-indent-mutant-' ) ) ;
297+ try {
298+ const source = join ( process . cwd ( ) , 'workflows' , 'shared' , 'squad-command-contract.mjs' ) ;
299+ const mutant = join ( root , 'squad-command-contract-mutant.mjs' ) ;
300+ cpSync ( source , mutant ) ;
301+ const original = readFileSync ( mutant , 'utf8' ) ;
302+ const changed = original . replace (
303+ '/^(?: {4}| {0,3}\\t)/' ,
304+ '/^(?: {4}|\\t)/' ,
305+ ) ;
306+ expect ( changed ) . not . toBe ( original ) ;
307+ writeFileSync ( mutant , changed ) ;
308+ const module = await import ( `${ pathToFileURL ( mutant ) . href } ?mutation=${ Date . now ( ) } ` ) ;
309+ for ( const body of [ ' \t/squad cast' , ' \t/squad cast' , ' \t/squad cast' ] ) {
310+ expect ( module . classifySquadCommand ( comment ( body ) , 'issue_comment' ) ) . toMatchObject ( {
311+ status : 'accepted' ,
312+ mode : 'cast' ,
313+ } ) ;
314+ expect ( classifySquadCommand ( comment ( body ) , 'issue_comment' ) ) . toEqual ( {
315+ status : 'none' ,
316+ source : 'comment' ,
317+ } ) ;
318+ }
319+ } finally {
320+ rmSync ( root , { recursive : true , force : true } ) ;
321+ }
322+ } ) ;
221323} ) ;
0 commit comments