migrate security parser to sh-syntax and async validation
This commit is contained in:
@@ -104,7 +104,7 @@ Actors can emit events in `ActorExecutionResult.events`. Pipeline status also em
|
|||||||
|
|
||||||
## Security Middleware
|
## Security Middleware
|
||||||
|
|
||||||
- Shell command parsing uses `bash-parser` AST traversal and extracts `Command`/`Word` nodes.
|
- Shell command parsing uses async `sh-syntax` (WASM-backed mvdan/sh parser) with fail-closed command/redirect extraction.
|
||||||
- Rules are validated with strict Zod schemas (`src/security/schemas.ts`) before execution.
|
- Rules are validated with strict Zod schemas (`src/security/schemas.ts`) before execution.
|
||||||
- `SecurityRulesEngine` enforces:
|
- `SecurityRulesEngine` enforces:
|
||||||
- binary allowlists
|
- binary allowlists
|
||||||
|
|||||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -11,8 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
||||||
"@openai/codex-sdk": "^0.104.0",
|
"@openai/codex-sdk": "^0.104.0",
|
||||||
"bash-parser": "^0.5.0",
|
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
"sh-syntax": "^0.5.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -934,85 +934,6 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/arity-n": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/arity-n/-/arity-n-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-fExL2kFDC1Q2DUOx3whE/9KoN66IzkY4b4zUHUBFM1ojEYjZZYDcUW3bek/ufGionX9giIKDC5redH2IlGqcQQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/array-last": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/babylon": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"babylon": "bin/babylon.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bash-parser": {
|
|
||||||
"version": "0.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bash-parser/-/bash-parser-0.5.0.tgz",
|
|
||||||
"integrity": "sha512-AQR43o4W4sj4Jf+oy4cFtGgyBps4B+MYnJg6Xds8VVC7yomFtQekhOORQNHfQ8D6YJ0XENykr3TpxMn3rUtgeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"array-last": "^1.1.1",
|
|
||||||
"babylon": "^6.9.1",
|
|
||||||
"compose-function": "^3.0.3",
|
|
||||||
"curry": "^1.2.0",
|
|
||||||
"deep-freeze": "0.0.1",
|
|
||||||
"filter-iterator": "0.0.1",
|
|
||||||
"filter-obj": "^1.1.0",
|
|
||||||
"has-own-property": "^0.1.0",
|
|
||||||
"identity-function": "^1.0.0",
|
|
||||||
"iterable-lookahead": "^1.0.0",
|
|
||||||
"iterable-transform-replace": "^1.1.1",
|
|
||||||
"magic-string": "^0.16.0",
|
|
||||||
"map-iterable": "^1.0.1",
|
|
||||||
"map-obj": "^2.0.0",
|
|
||||||
"object-pairs": "^0.1.0",
|
|
||||||
"object-values": "^1.0.0",
|
|
||||||
"reverse-arguments": "^1.0.0",
|
|
||||||
"shell-quote-word": "^1.0.1",
|
|
||||||
"to-pascal-case": "^1.0.0",
|
|
||||||
"transform-spread-iterable": "^1.1.0",
|
|
||||||
"unescape-js": "^1.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/compose-function": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/compose-function/-/compose-function-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"arity-n": "^1.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/curry": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/curry/-/curry-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-PAdmqPH2DUYTCc/aknv6RxRxmqdRHclvbz+wP8t1Xpg2Nu13qg+oLb6/5iFoDmf4dbmC9loYoy9PwwGbFt/AqA=="
|
|
||||||
},
|
|
||||||
"node_modules/deep-freeze": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==",
|
|
||||||
"license": "public domain"
|
|
||||||
},
|
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
@@ -1067,20 +988,6 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/filter-iterator": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/filter-iterator/-/filter-iterator-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-v4lhL7Qa8XpbW3LN46CEnmhGk3eHZwxfNl5at20aEkreesht4YKb/Ba3BUIbnPhAC/r3dmu7ABaGk6MAvh2alA=="
|
|
||||||
},
|
|
||||||
"node_modules/filter-obj": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1109,100 +1016,6 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/has-own-property": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-own-property/-/has-own-property-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-14qdBKoonU99XDhWcFKZTShK+QV47qU97u8zzoVo9cL5TZ3BmBHXogItSt9qJjR0KUMFRhcCW8uGIGl8nkl7Aw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/identity-function": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/identity-function/-/identity-function-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-kNrgUK0qI+9qLTBidsH85HjDLpZfrrS0ElquKKe/fJFdB3D7VeKdXXEvOPDUHSHOzdZKCAAaQIWWyp0l2yq6pw==",
|
|
||||||
"license": "public domain"
|
|
||||||
},
|
|
||||||
"node_modules/is-iterable": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-iterable/-/is-iterable-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-EdOZCr0NsGE00Pot+x1ZFx9MJK3C6wy91geZpXwvwexDLJvA4nzYyZf7r+EIwSeVsOLDdBz7ATg9NqKTzuNYuQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iterable-lookahead": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iterable-lookahead/-/iterable-lookahead-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-hJnEP2Xk4+44DDwJqUQGdXal5VbyeWLaPyDl2AQc242Zr7iqz4DgpQOrEzglWVMGHMDCkguLHEKxd1+rOsmgSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iterable-transform-replace": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iterable-transform-replace/-/iterable-transform-replace-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-AVCCj7CTUifWQ0ubraDgx5/e6tOWaL5qh/C8BDTjH0GuhNyFMCSsSmDtYpa4Y3ReAAQNSjUWfQ+ojhmjX10pdQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"curry": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
|
||||||
"version": "0.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.16.0.tgz",
|
|
||||||
"integrity": "sha512-c4BEos3y6G2qO0B9X7K0FVLOPT9uGrjYwYRLFmDqyl5YMboUviyecnXWp94fJTSMwPw2/sf+CEYt5AGpmklkkQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"vlq": "^0.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/map-iterable": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/map-iterable/-/map-iterable-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-siKFftph+ka2jWt8faiOWFzKP+eEuXrHuhYBitssJ5zJm209FCw5JBnaNLDiaCCb/CYZmxprdM6P7p16nA6YRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"curry": "^1.2.0",
|
|
||||||
"is-iterable": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/map-obj": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-pairs": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-pairs/-/object-pairs-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-3ECr6K831I4xX/Mduxr9UC+HPOz/d6WKKYj9p4cmC8Lg8p7g8gitzsxNX5IWlSIgFWN/a4JgrJaoAMKn20oKwA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/object-values": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-values/-/object-values-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-+8hwcz/JnQ9EpLIXzN0Rs7DLsBpJNT/xYehtB/jU93tHYr5BFEO8E+JGQNOSqE7opVzz5cGksKFHt7uUJVLSjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -1213,55 +1026,26 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reverse-arguments": {
|
"node_modules/sh-syntax": {
|
||||||
"version": "1.0.0",
|
"version": "0.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/reverse-arguments/-/reverse-arguments-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
|
||||||
"integrity": "sha512-/x8uIPdTafBqakK0TmPNJzgkLP+3H+yxpUJhCQHsLBg1rYEVNR2D8BRYNWQhVBjyOd7oo1dZRVzIkwMY2oqfYQ==",
|
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shell-quote-word": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote-word/-/shell-quote-word-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-lT297f1WLAdq0A4O+AknIFRP6kkiI3s8C913eJ0XqBxJbZPGWUNkRQk2u8zk4bEAjUJ5i+fSLwB6z1HzeT+DEg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/string.fromcodepoint": {
|
|
||||||
"version": "0.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz",
|
|
||||||
"integrity": "sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg=="
|
|
||||||
},
|
|
||||||
"node_modules/to-no-case": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/to-pascal-case": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-pascal-case/-/to-pascal-case-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-QGMWHqM6xPrcQW57S23c5/3BbYb0Tbe9p+ur98ckRnGDwD4wbbtDiYI38CfmMKNB5Iv0REjs5SNDntTwvDxzZA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-space-case": "^1.0.0"
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/sh-syntax"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/to-space-case": {
|
"node_modules/tslib": {
|
||||||
"version": "1.0.0",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "MIT",
|
"license": "0BSD"
|
||||||
"dependencies": {
|
|
||||||
"to-no-case": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/transform-spread-iterable": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/transform-spread-iterable/-/transform-spread-iterable-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-/GnF26X3zC8wfWyRzvuXX/Vb31TrU3Rwipmr4MC5hTi6X/yOXxXUSw4+pcHmKJ2+0KRrcS21YWZw77ukhVJBdQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"curry": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
@@ -1304,21 +1088,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unescape-js": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/unescape-js/-/unescape-js-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"string.fromcodepoint": "^0.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vlq": {
|
|
||||||
"version": "0.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz",
|
|
||||||
"integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
||||||
"@openai/codex-sdk": "^0.104.0",
|
"@openai/codex-sdk": "^0.104.0",
|
||||||
"bash-parser": "^0.5.0",
|
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
"sh-syntax": "^0.5.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class SecureCommandExecutor {
|
|||||||
onStdoutChunk?: (chunk: string) => void;
|
onStdoutChunk?: (chunk: string) => void;
|
||||||
onStderrChunk?: (chunk: string) => void;
|
onStderrChunk?: (chunk: string) => void;
|
||||||
}): Promise<SecureCommandExecutionResult> {
|
}): Promise<SecureCommandExecutionResult> {
|
||||||
const validated = this.rulesEngine.validateShellCommand({
|
const validated = await this.rulesEngine.validateShellCommand({
|
||||||
command: input.command,
|
command: input.command,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
toolClearance: input.toolClearance,
|
toolClearance: input.toolClearance,
|
||||||
|
|||||||
@@ -132,16 +132,16 @@ export class SecurityRulesEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
validateShellCommand(input: {
|
async validateShellCommand(input: {
|
||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
toolClearance?: ToolClearancePolicy;
|
toolClearance?: ToolClearancePolicy;
|
||||||
}): ValidatedShellCommand {
|
}): Promise<ValidatedShellCommand> {
|
||||||
const resolvedCwd = resolve(input.cwd);
|
const resolvedCwd = resolve(input.cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.assertCwdBoundary(resolvedCwd);
|
this.assertCwdBoundary(resolvedCwd);
|
||||||
const parsed = parseShellScript(input.command);
|
const parsed = await parseShellScript(input.command);
|
||||||
const toolClearance = input.toolClearance
|
const toolClearance = input.toolClearance
|
||||||
? parseToolClearancePolicy(input.toolClearance)
|
? parseToolClearancePolicy(input.toolClearance)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -83,6 +83,80 @@ export function parseExecutionEnvPolicy(input: unknown): ExecutionEnvPolicy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const commandTargetSchema = z
|
||||||
|
.object({
|
||||||
|
binary: stringTokenSchema,
|
||||||
|
args: z.array(stringTokenSchema).default([]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CommandTarget = z.infer<typeof commandTargetSchema>;
|
||||||
|
|
||||||
|
export const commandTargetsSchema = z.array(commandTargetSchema);
|
||||||
|
|
||||||
|
export function parseCommandTargets(input: unknown): CommandTarget[] {
|
||||||
|
const parsed = commandTargetsSchema.parse(input);
|
||||||
|
return parsed.map((target) => ({
|
||||||
|
binary: target.binary,
|
||||||
|
args: [...target.args],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parsedShellAssignmentSchema = z
|
||||||
|
.object({
|
||||||
|
raw: stringTokenSchema,
|
||||||
|
key: stringTokenSchema,
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ParsedShellAssignment = z.infer<typeof parsedShellAssignmentSchema>;
|
||||||
|
|
||||||
|
export const parsedShellCommandSchema = z
|
||||||
|
.object({
|
||||||
|
binary: stringTokenSchema,
|
||||||
|
args: z.array(stringTokenSchema).default([]),
|
||||||
|
flags: z.array(stringTokenSchema).default([]),
|
||||||
|
assignments: z.array(parsedShellAssignmentSchema).default([]),
|
||||||
|
redirects: z.array(stringTokenSchema).default([]),
|
||||||
|
words: z.array(stringTokenSchema).default([]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ParsedShellCommand = z.infer<typeof parsedShellCommandSchema>;
|
||||||
|
|
||||||
|
export const parsedShellScriptSchema = z
|
||||||
|
.object({
|
||||||
|
commandCount: z.number().int().nonnegative(),
|
||||||
|
commands: z.array(parsedShellCommandSchema).default([]),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((value) => value.commandCount === value.commands.length, {
|
||||||
|
message: "commandCount must match commands.length",
|
||||||
|
path: ["commandCount"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParsedShellScript = z.infer<typeof parsedShellScriptSchema>;
|
||||||
|
|
||||||
|
export function parseParsedShellScript(input: unknown): ParsedShellScript {
|
||||||
|
const parsed = parsedShellScriptSchema.parse(input);
|
||||||
|
return {
|
||||||
|
commandCount: parsed.commandCount,
|
||||||
|
commands: parsed.commands.map((command) => ({
|
||||||
|
binary: command.binary,
|
||||||
|
args: [...command.args],
|
||||||
|
flags: [...command.flags],
|
||||||
|
assignments: command.assignments.map((assignment) => ({
|
||||||
|
raw: assignment.raw,
|
||||||
|
key: assignment.key,
|
||||||
|
value: assignment.value,
|
||||||
|
})),
|
||||||
|
redirects: [...command.redirects],
|
||||||
|
words: [...command.words],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type SecurityViolationHandling = "hard_abort" | "validation_fail";
|
export type SecurityViolationHandling = "hard_abort" | "validation_fail";
|
||||||
|
|
||||||
export const securityViolationHandlingSchema = z.union([
|
export const securityViolationHandlingSchema = z.union([
|
||||||
|
|||||||
@@ -1,47 +1,40 @@
|
|||||||
import parseBash from "bash-parser";
|
import { parse, type File } from "sh-syntax";
|
||||||
import { SecurityViolationError } from "./errors.js";
|
import { SecurityViolationError } from "./errors.js";
|
||||||
|
import {
|
||||||
|
parseCommandTargets,
|
||||||
|
parseParsedShellScript,
|
||||||
|
} from "./schemas.js";
|
||||||
|
import type {
|
||||||
|
CommandTarget as SchemaCommandTarget,
|
||||||
|
ParsedShellAssignment as SchemaParsedShellAssignment,
|
||||||
|
ParsedShellCommand as SchemaParsedShellCommand,
|
||||||
|
ParsedShellScript as SchemaParsedShellScript,
|
||||||
|
} from "./schemas.js";
|
||||||
|
|
||||||
type UnknownRecord = Record<string, unknown>;
|
export type CommandTarget = SchemaCommandTarget;
|
||||||
|
export type ParsedShellAssignment = SchemaParsedShellAssignment;
|
||||||
|
export type ParsedShellCommand = SchemaParsedShellCommand;
|
||||||
|
export type ParsedShellScript = SchemaParsedShellScript;
|
||||||
|
|
||||||
export type ParsedShellAssignment = {
|
type QuoteMode = "none" | "single" | "double";
|
||||||
raw: string;
|
|
||||||
key: string;
|
type ShellToken = {
|
||||||
value: string;
|
value: string;
|
||||||
|
dynamic: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedShellCommand = {
|
type RedirectMatch = {
|
||||||
binary: string;
|
target: string;
|
||||||
args: string[];
|
consumed: number;
|
||||||
flags: string[];
|
|
||||||
assignments: ParsedShellAssignment[];
|
|
||||||
redirects: string[];
|
|
||||||
words: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedShellScript = {
|
const REDIRECT_OPERATOR_PATTERN =
|
||||||
commandCount: number;
|
/^(?:\d+)?(?:&>>|>>|&>|<<-|<<<|<<|<>|>\||>&|<&|>|<)$/;
|
||||||
commands: ParsedShellCommand[];
|
const COMPACT_REDIRECT_PATTERN =
|
||||||
};
|
/^(?:\d+)?(?:&>>|>>|&>|<<-|<<<|<<|<>|>\||>&|<&|>|<)(.+)$/;
|
||||||
|
|
||||||
function isRecord(value: unknown): value is UnknownRecord {
|
function parseWithShSyntax(script: string): Promise<File> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return parse(script);
|
||||||
}
|
|
||||||
|
|
||||||
function readString(value: unknown): string | undefined {
|
|
||||||
return typeof value === "string" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readTextNode(node: unknown): string | undefined {
|
|
||||||
if (!isRecord(node)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = readString(node.text);
|
|
||||||
if (!text) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAssignment(raw: string): ParsedShellAssignment | undefined {
|
function parseAssignment(raw: string): ParsedShellAssignment | undefined {
|
||||||
@@ -62,59 +55,306 @@ function parseAssignment(raw: string): ParsedShellAssignment | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined {
|
function isWhitespace(value: string): boolean {
|
||||||
if (node.type !== "Command") {
|
return value === " " || value === "\t" || value === "\r" || value === "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommandBoundary(value: string): boolean {
|
||||||
|
return value === ";" || value === "|" || value === "&";
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockUnsupportedExpansion(script: string, reason: string): never {
|
||||||
|
throw new SecurityViolationError(reason, {
|
||||||
|
code: "SHELL_SUBSHELL_BLOCKED",
|
||||||
|
details: {
|
||||||
|
script,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeCommands(script: string): ShellToken[][] {
|
||||||
|
const commands: ShellToken[][] = [];
|
||||||
|
let currentCommand: ShellToken[] = [];
|
||||||
|
let currentToken = "";
|
||||||
|
let currentTokenDynamic = false;
|
||||||
|
let mode: QuoteMode = "none";
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
const flushToken = (): void => {
|
||||||
|
if (currentToken.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCommand.push({
|
||||||
|
value: currentToken,
|
||||||
|
dynamic: currentTokenDynamic,
|
||||||
|
});
|
||||||
|
currentToken = "";
|
||||||
|
currentTokenDynamic = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCommand = (): void => {
|
||||||
|
flushToken();
|
||||||
|
if (currentCommand.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commands.push(currentCommand);
|
||||||
|
currentCommand = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const markDynamicForToken = (char: string): void => {
|
||||||
|
if (
|
||||||
|
char === "$" ||
|
||||||
|
(char === "~" && currentToken.length === 0) ||
|
||||||
|
char === "*" ||
|
||||||
|
char === "?" ||
|
||||||
|
char === "[" ||
|
||||||
|
char === "]" ||
|
||||||
|
char === "{" ||
|
||||||
|
char === "}"
|
||||||
|
) {
|
||||||
|
currentTokenDynamic = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < script.length; index += 1) {
|
||||||
|
const char = script[index];
|
||||||
|
const next = script[index + 1] ?? "";
|
||||||
|
if (!char) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "single") {
|
||||||
|
if (char === "'") {
|
||||||
|
mode = "none";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentToken += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "double") {
|
||||||
|
if (escaped) {
|
||||||
|
currentToken += char;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\"") {
|
||||||
|
mode = "none";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "`" || (char === "$" && next === "(")) {
|
||||||
|
blockUnsupportedExpansion(
|
||||||
|
script,
|
||||||
|
"Command substitution is not permitted by security policy.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "$") {
|
||||||
|
currentTokenDynamic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentToken += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
currentToken += char;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "`" || (char === "$" && next === "(")) {
|
||||||
|
blockUnsupportedExpansion(
|
||||||
|
script,
|
||||||
|
"Command substitution is not permitted by security policy.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((char === "<" || char === ">") && next === "(") {
|
||||||
|
blockUnsupportedExpansion(
|
||||||
|
script,
|
||||||
|
"Process substitution is not permitted by security policy.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((char === "(" || char === ")") && currentToken.length === 0) {
|
||||||
|
blockUnsupportedExpansion(script, "Subshell execution is not permitted by security policy.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "'") {
|
||||||
|
mode = "single";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\"") {
|
||||||
|
mode = "double";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
char === "#" &&
|
||||||
|
currentToken.length === 0 &&
|
||||||
|
(index === 0 ||
|
||||||
|
isWhitespace(script[index - 1] ?? "") ||
|
||||||
|
isCommandBoundary(script[index - 1] ?? ""))
|
||||||
|
) {
|
||||||
|
while (index < script.length && script[index] !== "\n") {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (index >= script.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
flushCommand();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWhitespace(char)) {
|
||||||
|
flushToken();
|
||||||
|
if (char === "\n") {
|
||||||
|
flushCommand();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "&" && next === "&") {
|
||||||
|
flushCommand();
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "|" && next === "|") {
|
||||||
|
flushCommand();
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "|" && next === "&") {
|
||||||
|
flushCommand();
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "|" || char === ";" || (char === "&" && next !== ">")) {
|
||||||
|
flushCommand();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
markDynamicForToken(char);
|
||||||
|
currentToken += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaped || mode !== "none") {
|
||||||
|
throw new SecurityViolationError("Shell command failed AST parsing.", {
|
||||||
|
code: "SHELL_PARSE_FAILED",
|
||||||
|
details: {
|
||||||
|
script,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flushCommand();
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRedirect(tokens: ShellToken[], index: number): RedirectMatch | undefined {
|
||||||
|
const token = tokens[index];
|
||||||
|
if (!token) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const binary = readTextNode(node.name)?.trim();
|
const value = token.value;
|
||||||
if (!binary) {
|
if (REDIRECT_OPERATOR_PATTERN.test(value)) {
|
||||||
|
const targetToken = tokens[index + 1];
|
||||||
|
if (!targetToken) {
|
||||||
|
throw new SecurityViolationError("Shell command failed AST parsing.", {
|
||||||
|
code: "SHELL_PARSE_FAILED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target: targetToken.value,
|
||||||
|
consumed: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = value.match(COMPACT_REDIRECT_PATTERN);
|
||||||
|
if (compact?.[1]) {
|
||||||
|
return {
|
||||||
|
target: compact[1],
|
||||||
|
consumed: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCommandTokens(tokens: ShellToken[]): ParsedShellCommand | undefined {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
const assignments: ParsedShellAssignment[] = [];
|
const assignments: ParsedShellAssignment[] = [];
|
||||||
const redirects: string[] = [];
|
const redirects: string[] = [];
|
||||||
|
let binaryToken: ShellToken | undefined;
|
||||||
|
|
||||||
const prefix = Array.isArray(node.prefix) ? node.prefix : [];
|
for (let index = 0; index < tokens.length; ) {
|
||||||
for (const entry of prefix) {
|
const redirect = matchRedirect(tokens, index);
|
||||||
if (!isRecord(entry) || entry.type !== "AssignmentWord") {
|
if (redirect) {
|
||||||
|
redirects.push(redirect.target);
|
||||||
|
index += redirect.consumed;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = readString(entry.text);
|
const token = tokens[index];
|
||||||
if (!raw) {
|
if (!token) {
|
||||||
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignment = parseAssignment(raw);
|
if (!binaryToken) {
|
||||||
|
const assignment = parseAssignment(token.value);
|
||||||
if (assignment) {
|
if (assignment) {
|
||||||
assignments.push(assignment);
|
assignments.push(assignment);
|
||||||
}
|
index += 1;
|
||||||
}
|
|
||||||
|
|
||||||
const suffix = Array.isArray(node.suffix) ? node.suffix : [];
|
|
||||||
for (const entry of suffix) {
|
|
||||||
if (!isRecord(entry)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "Word") {
|
binaryToken = token;
|
||||||
const text = readString(entry.text);
|
index += 1;
|
||||||
if (text) {
|
|
||||||
args.push(text);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type !== "Redirect") {
|
args.push(token.value);
|
||||||
continue;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileWord = readTextNode(entry.file);
|
if (!binaryToken) {
|
||||||
if (fileWord) {
|
return undefined;
|
||||||
redirects.push(fileWord);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (binaryToken.dynamic) {
|
||||||
|
throw new SecurityViolationError("Dynamic or computed binary names are blocked.", {
|
||||||
|
code: "DYNAMIC_BINARY_BLOCKED",
|
||||||
|
details: {
|
||||||
|
binary: binaryToken.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = binaryToken.value.trim();
|
||||||
|
if (!binary) {
|
||||||
|
throw new SecurityViolationError("Dynamic or computed binary names are blocked.", {
|
||||||
|
code: "DYNAMIC_BINARY_BLOCKED",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -127,10 +367,35 @@ function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseShellScript(script: string): ParsedShellScript {
|
function readTopLevelStatements(ast: File): unknown[] {
|
||||||
let ast: unknown;
|
const astRecord = ast as unknown as Record<string, unknown>;
|
||||||
|
const stmt = astRecord.Stmt;
|
||||||
|
if (Array.isArray(stmt)) {
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmts = astRecord.Stmts;
|
||||||
|
if (Array.isArray(stmts)) {
|
||||||
|
return stmts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractExecutionTargets(shellInput: string): Promise<CommandTarget[]> {
|
||||||
|
const parsed = await parseShellScript(shellInput);
|
||||||
|
return parseCommandTargets(
|
||||||
|
parsed.commands.map((command) => ({
|
||||||
|
binary: command.binary,
|
||||||
|
args: command.args,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseShellScript(script: string): Promise<ParsedShellScript> {
|
||||||
|
let ast: File;
|
||||||
try {
|
try {
|
||||||
ast = parseBash(script);
|
ast = await parseWithShSyntax(script);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SecurityViolationError("Shell command failed AST parsing.", {
|
throw new SecurityViolationError("Shell command failed AST parsing.", {
|
||||||
code: "SHELL_PARSE_FAILED",
|
code: "SHELL_PARSE_FAILED",
|
||||||
@@ -141,40 +406,26 @@ export function parseShellScript(script: string): ParsedShellScript {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topLevelStatements = readTopLevelStatements(ast);
|
||||||
|
if (topLevelStatements.length === 0) {
|
||||||
|
return parseParsedShellScript({
|
||||||
|
commandCount: 0,
|
||||||
|
commands: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandTokens = tokenizeCommands(script);
|
||||||
const commands: ParsedShellCommand[] = [];
|
const commands: ParsedShellCommand[] = [];
|
||||||
const seen = new WeakSet<object>();
|
for (const tokenList of commandTokens) {
|
||||||
|
const parsedCommand = parseCommandTokens(tokenList);
|
||||||
const visit = (node: unknown): void => {
|
if (!parsedCommand) {
|
||||||
if (!isRecord(node)) {
|
continue;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seen.has(node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(node);
|
|
||||||
|
|
||||||
const parsedCommand = toParsedCommand(node);
|
|
||||||
if (parsedCommand) {
|
|
||||||
commands.push(parsedCommand);
|
commands.push(parsedCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const value of Object.values(node)) {
|
return parseParsedShellScript({
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
visit(item);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
visit(ast);
|
|
||||||
|
|
||||||
return {
|
|
||||||
commandCount: commands.length,
|
commandCount: commands.length,
|
||||||
commands,
|
commands,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/types/bash-parser.d.ts
vendored
3
src/types/bash-parser.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
declare module "bash-parser" {
|
|
||||||
export default function parseBash(script: string): unknown;
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
parseShellScript,
|
parseShellScript,
|
||||||
} from "../src/security/index.js";
|
} from "../src/security/index.js";
|
||||||
|
|
||||||
test("shell parser extracts Command and Word nodes across chained expressions", () => {
|
test("shell parser extracts commands across chained expressions", async () => {
|
||||||
const parsed = parseShellScript("FOO=bar git status && npm test | cat > logs/output.txt");
|
const parsed = await parseShellScript(
|
||||||
|
"FOO=bar git status && npm test | cat > logs/output.txt",
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(parsed.commandCount, 3);
|
assert.equal(parsed.commandCount, 3);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -33,11 +35,12 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries",
|
|||||||
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-worktree-"));
|
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-worktree-"));
|
||||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-state-"));
|
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-state-"));
|
||||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||||
|
const protectedFilePath = resolve(worktreeRoot, ".ai_ops", "project-context.json");
|
||||||
|
|
||||||
const rules = new SecurityRulesEngine({
|
const rules = new SecurityRulesEngine({
|
||||||
allowedBinaries: ["git", "npm", "cat"],
|
allowedBinaries: ["git", "npm", "cat"],
|
||||||
worktreeRoot,
|
worktreeRoot,
|
||||||
protectedPaths: [stateRoot, projectContextPath],
|
protectedPaths: [stateRoot, projectContextPath, protectedFilePath],
|
||||||
requireCwdWithinWorktree: true,
|
requireCwdWithinWorktree: true,
|
||||||
rejectRelativePathTraversal: true,
|
rejectRelativePathTraversal: true,
|
||||||
enforcePathBoundaryOnArguments: true,
|
enforcePathBoundaryOnArguments: true,
|
||||||
@@ -45,7 +48,7 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries",
|
|||||||
blockedEnvAssignments: [],
|
blockedEnvAssignments: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowed = rules.validateShellCommand({
|
const allowed = await rules.validateShellCommand({
|
||||||
command: "git status && npm test | cat > logs/output.txt",
|
command: "git status && npm test | cat > logs/output.txt",
|
||||||
cwd: worktreeRoot,
|
cwd: worktreeRoot,
|
||||||
toolClearance: {
|
toolClearance: {
|
||||||
@@ -56,26 +59,55 @@ test("rules engine enforces binary allowlist, tool policy, and path boundaries",
|
|||||||
|
|
||||||
assert.equal(allowed.parsed.commandCount, 3);
|
assert.equal(allowed.parsed.commandCount, 3);
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
rules.validateShellCommand({
|
rules.validateShellCommand({
|
||||||
command: "cat ../secrets.txt",
|
command: "cat ../secrets.txt",
|
||||||
cwd: worktreeRoot,
|
cwd: worktreeRoot,
|
||||||
}),
|
}),
|
||||||
(error) =>
|
(error: unknown) =>
|
||||||
error instanceof SecurityViolationError &&
|
error instanceof SecurityViolationError && error.code === "PATH_TRAVERSAL_BLOCKED",
|
||||||
error.code === "PATH_TRAVERSAL_BLOCKED",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
rules.validateShellCommand({
|
rules.validateShellCommand({
|
||||||
command: "git status",
|
command: "git status",
|
||||||
cwd: stateRoot,
|
cwd: stateRoot,
|
||||||
}),
|
}),
|
||||||
(error) =>
|
(error: unknown) =>
|
||||||
|
error instanceof SecurityViolationError && error.code === "CWD_OUTSIDE_WORKTREE",
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
rules.validateShellCommand({
|
||||||
|
command: "echo $(unauthorized_bin)",
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof SecurityViolationError && error.code === "SHELL_SUBSHELL_BLOCKED",
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
rules.validateShellCommand({
|
||||||
|
command: "git status && unauthorized_bin",
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof SecurityViolationError && error.code === "BINARY_NOT_ALLOWED",
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
rules.validateShellCommand({
|
||||||
|
command: `cat > ${protectedFilePath}`,
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
error instanceof SecurityViolationError &&
|
error instanceof SecurityViolationError &&
|
||||||
error.code === "CWD_OUTSIDE_WORKTREE",
|
error.code === "PATH_INSIDE_PROTECTED_PATH",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user