11import { describe , it , expect } from "vitest"
2- import { mergeTaskPermissions , matchesAnyPattern , taskPermissionsSchema } from "../task-permissions.js"
2+ import {
3+ mergeTaskPermissions ,
4+ matchesAnyPattern ,
5+ matchesAllPatternLayers ,
6+ taskPermissionsSchema ,
7+ toTaskPermissions ,
8+ } from "../task-permissions.js"
39import type { TaskPermissions } from "../task-permissions.js"
410
511describe ( "TaskPermissions" , ( ) => {
@@ -34,6 +40,28 @@ describe("TaskPermissions", () => {
3440 } )
3541 } )
3642
43+ describe ( "toTaskPermissions" , ( ) => {
44+ it ( "wraps flat filePatterns into a single layer" , ( ) => {
45+ const input = { filePatterns : [ "src/.*" ] }
46+ const result = toTaskPermissions ( input )
47+ expect ( result . _filePatternLayers ) . toEqual ( [ [ "src/.*" ] ] )
48+ expect ( result . filePatterns ) . toEqual ( [ "src/.*" ] )
49+ } )
50+
51+ it ( "wraps flat commandPatterns into a single layer" , ( ) => {
52+ const input = { commandPatterns : [ "npm test.*" ] }
53+ const result = toTaskPermissions ( input )
54+ expect ( result . _commandPatternLayers ) . toEqual ( [ [ "npm test.*" ] ] )
55+ } )
56+
57+ it ( "leaves layers undefined when patterns are not set" , ( ) => {
58+ const input = { allowedTools : [ "read_file" ] }
59+ const result = toTaskPermissions ( input )
60+ expect ( result . _filePatternLayers ) . toBeUndefined ( )
61+ expect ( result . _commandPatternLayers ) . toBeUndefined ( )
62+ } )
63+ } )
64+
3765 describe ( "mergeTaskPermissions" , ( ) => {
3866 it ( "returns undefined when both are undefined" , ( ) => {
3967 expect ( mergeTaskPermissions ( undefined , undefined ) ) . toBeUndefined ( )
@@ -49,18 +77,25 @@ describe("TaskPermissions", () => {
4977 expect ( mergeTaskPermissions ( parent , undefined ) ) . toEqual ( parent )
5078 } )
5179
52- it ( "intersects filePatterns when both defined" , ( ) => {
53- const parent : TaskPermissions = { filePatterns : [ "src/.*" , "tests/.*" ] }
54- const child : TaskPermissions = { filePatterns : [ "src/.*" , "docs/.*" ] }
80+ it ( "accumulates filePatterns as separate layers when both defined" , ( ) => {
81+ const parent = toTaskPermissions ( { filePatterns : [ "src/.*" , "tests/.*" ] } )
82+ const child = toTaskPermissions ( { filePatterns : [ "src/.*" , "docs/.*" ] } )
5583 const merged = mergeTaskPermissions ( parent , child )
56- expect ( merged ?. filePatterns ) . toEqual ( [ "src/.*" ] )
84+ // Both layers are kept (AND semantics between layers)
85+ expect ( merged ?. _filePatternLayers ) . toEqual ( [
86+ [ "src/.*" , "tests/.*" ] ,
87+ [ "src/.*" , "docs/.*" ] ,
88+ ] )
5789 } )
5890
59- it ( "intersects commandPatterns when both defined" , ( ) => {
60- const parent : TaskPermissions = { commandPatterns : [ "npm test.*" , "npm run lint" ] }
61- const child : TaskPermissions = { commandPatterns : [ "npm test.*" , "npm run build" ] }
91+ it ( "accumulates commandPatterns as separate layers when both defined" , ( ) => {
92+ const parent = toTaskPermissions ( { commandPatterns : [ "npm test.*" , "npm run lint" ] } )
93+ const child = toTaskPermissions ( { commandPatterns : [ "npm test.*" , "npm run build" ] } )
6294 const merged = mergeTaskPermissions ( parent , child )
63- expect ( merged ?. commandPatterns ) . toEqual ( [ "npm test.*" ] )
95+ expect ( merged ?. _commandPatternLayers ) . toEqual ( [
96+ [ "npm test.*" , "npm run lint" ] ,
97+ [ "npm test.*" , "npm run build" ] ,
98+ ] )
6499 } )
65100
66101 it ( "intersects allowedTools when both defined" , ( ) => {
@@ -85,42 +120,50 @@ describe("TaskPermissions", () => {
85120 } )
86121
87122 it ( "uses parent filePatterns when child has none" , ( ) => {
88- const parent : TaskPermissions = { filePatterns : [ "src/.*" ] }
123+ const parent = toTaskPermissions ( { filePatterns : [ "src/.*" ] } )
89124 const child : TaskPermissions = { deniedTools : [ "execute_command" ] }
90125 const merged = mergeTaskPermissions ( parent , child )
91- expect ( merged ?. filePatterns ) . toEqual ( [ "src/.*" ] )
126+ expect ( merged ?. _filePatternLayers ) . toEqual ( [ [ "src/.*" ] ] )
92127 expect ( merged ?. deniedTools ) . toEqual ( [ "execute_command" ] )
93128 } )
94129
95- it ( "returns empty array when intersection is empty" , ( ) => {
130+ it ( "returns empty array when allowedTools intersection is empty" , ( ) => {
96131 const parent : TaskPermissions = { allowedTools : [ "read_file" ] }
97132 const child : TaskPermissions = { allowedTools : [ "write_to_file" ] }
98133 const merged = mergeTaskPermissions ( parent , child )
99134 expect ( merged ?. allowedTools ) . toEqual ( [ ] )
100135 } )
101136
102- it ( "handles complex nested merge scenario with exact string matching " , ( ) => {
103- const grandparent : TaskPermissions = {
137+ it ( "handles nested delegation where child narrows scope " , ( ) => {
138+ const grandparent = toTaskPermissions ( {
104139 filePatterns : [ "src/.*" ] ,
105140 commandPatterns : [ "npm.*" ] ,
106141 allowedTools : [ "read_file" , "write_to_file" , "search_files" ] ,
107142 deniedTools : [ "execute_command" ] ,
108- }
109- const parent : TaskPermissions = {
143+ } )
144+ const parent = toTaskPermissions ( {
110145 filePatterns : [ "src/components/.*" ] ,
111146 allowedTools : [ "read_file" , "write_to_file" ] ,
112- }
147+ } )
113148
114- // Intersection uses exact string matching, so "src/components/.*" (child )
115- // is not equal to "src/.*" (parent) -- intersection is empty
116- const merged1 = mergeTaskPermissions ( grandparent , parent )
117- expect ( merged1 ?. filePatterns ) . toEqual ( [ ] )
149+ const merged = mergeTaskPermissions ( grandparent , parent )
150+
151+ // Both layers are kept -- runtime enforces AND between them
152+ expect ( merged ?. _filePatternLayers ) . toEqual ( [ [ "src/.*" ] , [ "src/components/.*" ] ] )
118153 // allowedTools intersection: read_file and write_to_file are in both
119- expect ( merged1 ?. allowedTools ) . toEqual ( [ "read_file" , "write_to_file" ] )
154+ expect ( merged ?. allowedTools ) . toEqual ( [ "read_file" , "write_to_file" ] )
120155 // commandPatterns: only grandparent has them, so they pass through
121- expect ( merged1 ?. commandPatterns ) . toEqual ( [ "npm.*" ] )
156+ expect ( merged ?. _commandPatternLayers ) . toEqual ( [ [ "npm.*" ] ] )
122157 // deniedTools: only grandparent has them, so they pass through
123- expect ( merged1 ?. deniedTools ) . toEqual ( [ "execute_command" ] )
158+ expect ( merged ?. deniedTools ) . toEqual ( [ "execute_command" ] )
159+ } )
160+
161+ it ( "deduplicates identical pattern layers" , ( ) => {
162+ const parent = toTaskPermissions ( { filePatterns : [ "src/.*" ] } )
163+ const child = toTaskPermissions ( { filePatterns : [ "src/.*" ] } )
164+ const merged = mergeTaskPermissions ( parent , child )
165+ // Identical layers are deduplicated
166+ expect ( merged ?. _filePatternLayers ) . toEqual ( [ [ "src/.*" ] ] )
124167 } )
125168 } )
126169
@@ -149,4 +192,31 @@ describe("TaskPermissions", () => {
149192 expect ( matchesAnyPattern ( "rm -rf /" , [ "npm.*" , "yarn.*" ] ) ) . toBe ( false )
150193 } )
151194 } )
195+
196+ describe ( "matchesAllPatternLayers" , ( ) => {
197+ it ( "returns true when layers is undefined" , ( ) => {
198+ expect ( matchesAllPatternLayers ( "anything" , undefined ) ) . toBe ( true )
199+ } )
200+
201+ it ( "returns true when layers is empty" , ( ) => {
202+ expect ( matchesAllPatternLayers ( "anything" , [ ] ) ) . toBe ( true )
203+ } )
204+
205+ it ( "returns true when value matches all layers" , ( ) => {
206+ const layers = [ [ "src/.*" ] , [ "src/components/.*" ] ]
207+ expect ( matchesAllPatternLayers ( "src/components/Button.tsx" , layers ) ) . toBe ( true )
208+ } )
209+
210+ it ( "returns false when value fails to match one layer" , ( ) => {
211+ const layers = [ [ "src/.*" ] , [ "src/components/.*" ] ]
212+ // Matches src/.* but not src/components/.*
213+ expect ( matchesAllPatternLayers ( "src/utils/helper.ts" , layers ) ) . toBe ( false )
214+ } )
215+
216+ it ( "handles single layer like matchesAnyPattern" , ( ) => {
217+ const layers = [ [ "src/.*" , "tests/.*" ] ]
218+ expect ( matchesAllPatternLayers ( "tests/unit/test.ts" , layers ) ) . toBe ( true )
219+ expect ( matchesAllPatternLayers ( "docs/readme.md" , layers ) ) . toBe ( false )
220+ } )
221+ } )
152222} )
0 commit comments