@aikidosec/safe-chain 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,23 +152,36 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/unins
152
152
 
153
153
  ## Logging
154
154
 
155
- You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag:
155
+ You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag or the `SAFE_CHAIN_LOGGING` environment variable.
156
156
 
157
- - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
157
+ ### Configuration Options
158
+
159
+ You can set the logging level through multiple sources (in order of priority):
160
+
161
+ 1. **CLI Argument** (highest priority):
162
+
163
+ - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
164
+
165
+ ```shell
166
+ npm install express --safe-chain-logging=silent
167
+ ```
158
168
 
159
- Example usage:
169
+ - `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
160
170
 
161
- ```shell
162
- npm install express --safe-chain-logging=silent
163
- ```
171
+ ```shell
172
+ npm install express --safe-chain-logging=verbose
173
+ ```
164
174
 
165
- - `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
175
+ 2. **Environment Variable**:
176
+
177
+ ```shell
178
+ export SAFE_CHAIN_LOGGING=verbose
179
+ npm install express
180
+ ```
166
181
 
167
- Example usage:
182
+ Valid values: `silent`, `normal`, `verbose`
168
183
 
169
- ```shell
170
- npm install express --safe-chain-logging=verbose
171
- ```
184
+ This is useful for setting a default logging level for all package manager commands in your terminal session or CI/CD environment.
172
185
 
173
186
  ## Minimum Package Age
174
187
 
@@ -199,6 +212,22 @@ You can set the minimum package age through multiple sources (in order of priori
199
212
  }
200
213
  ```
201
214
 
215
+ ### Excluding Packages
216
+
217
+ Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization:
218
+
219
+ ```shell
220
+ export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
221
+ ```
222
+
223
+ ```json
224
+ {
225
+ "npm": {
226
+ "minimumPackageAgeExclusions": ["@aikidosec/*"]
227
+ }
228
+ }
229
+ ```
230
+
202
231
  ## Custom Registries
203
232
 
204
233
  Configure Safe Chain to scan packages from custom or private registries.
@@ -258,6 +287,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
258
287
  - ✅ **Azure Pipelines**
259
288
  - ✅ **CircleCI**
260
289
  - ✅ **Jenkins**
290
+ - ✅ **Bitbucket Pipelines**
261
291
 
262
292
  ## GitHub Actions Example
263
293
 
@@ -347,6 +377,21 @@ pipeline {
347
377
  }
348
378
  ```
349
379
 
380
+ ## Bitbucket Pipelines Example
381
+
382
+ ```yaml
383
+ image: node:22
384
+
385
+ steps:
386
+ - step:
387
+ name: Install
388
+ script:
389
+ - npm install -g @aikidosec/safe-chain
390
+ - safe-chain setup-ci
391
+ - export PATH=~/.safe-chain/shims:$PATH
392
+ - npm ci
393
+ ```
394
+
350
395
  After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
351
396
 
352
397
  # Troubleshooting
@@ -44,20 +44,72 @@ pip3 install safe-chain-pi-test
44
44
 
45
45
  These test packages are flagged as malware and should be blocked by Safe Chain.
46
46
 
47
+ **If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below.
48
+
47
49
  ### Logging Options
48
50
 
49
- Use logging flags to get more information:
51
+ Use logging flags or environment variables to get more information:
50
52
 
51
53
  ```bash
52
54
  # Verbose mode - detailed diagnostic output for troubleshooting
53
55
  npm install express --safe-chain-logging=verbose
54
56
 
57
+ # Or set it globally for all commands in your session
58
+ export SAFE_CHAIN_LOGGING=verbose
59
+ npm install express
60
+
55
61
  # Silent mode - suppress all output except malware blocking
56
62
  npm install express --safe-chain-logging=silent
57
63
  ```
58
64
 
59
65
  ## Common Issues
60
66
 
67
+ ### Malware Not Being Blocked
68
+
69
+ **Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked
70
+
71
+ **Most Common Cause:** The package is cached in your package manager's local store
72
+
73
+ Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy.
74
+
75
+ When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
76
+
77
+ **Resolution Steps:**
78
+
79
+ 1. **Clear your package manager's cache:**
80
+
81
+ ```bash
82
+ # For npm
83
+ npm cache clean --force
84
+
85
+ # For pnpm
86
+ pnpm store prune
87
+
88
+ # For yarn (classic)
89
+ yarn cache clean
90
+
91
+ # For yarn (berry/v2+)
92
+ yarn cache clean --all
93
+
94
+ # For bun
95
+ bun pm cache rm
96
+ ```
97
+
98
+ > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times.
99
+
100
+ 2. **Clean local installation artifacts:**
101
+
102
+ ```bash
103
+ # Remove node_modules if you want a completely fresh install
104
+ rm -rf node_modules
105
+ ```
106
+
107
+ 3. **Re-test malware blocking:**
108
+
109
+ ```bash
110
+ npm install safe-chain-test # Should be blocked
111
+ ```
112
+
61
113
  ### Shell Aliases Not Working After Installation
62
114
 
63
115
  **Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version
@@ -229,11 +281,16 @@ rm -rf ~/.safe-chain
229
281
 
230
282
  ### Enable Verbose Logging
231
283
 
232
- Get detailed diagnostic output:
284
+ Get detailed diagnostic output using a CLI flag or environment variable:
233
285
 
234
286
  ```bash
287
+ # Using CLI flag
235
288
  npm install express --safe-chain-logging=verbose
236
289
  pip install requests --safe-chain-logging=verbose
290
+
291
+ # Using environment variable (applies to all commands)
292
+ export SAFE_CHAIN_LOGGING=verbose
293
+ npm install express
237
294
  ```
238
295
 
239
296
  ### Report Issues
@@ -246,4 +303,4 @@ If you encounter problems:
246
303
  - Shell type and version
247
304
  - `safe-chain --version` output
248
305
  - Output from verification commands
249
- - Verbose logs of the failing command
306
+ - Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "scripts": {
5
5
  "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
6
6
  "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
package/src/api/aikido.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import fetch from "make-fetch-happen";
2
- import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
2
+ import {
3
+ getEcoSystem,
4
+ ECOSYSTEM_JS,
5
+ ECOSYSTEM_PY,
6
+ } from "../config/settings.js";
7
+ import { ui } from "../environment/userInteraction.js";
3
8
 
4
9
  const malwareDatabaseUrls = {
5
10
  [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
@@ -17,38 +22,91 @@ const malwareDatabaseUrls = {
17
22
  * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
18
23
  */
19
24
  export async function fetchMalwareDatabase() {
20
- const ecosystem = getEcoSystem();
21
- const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
22
- const response = await fetch(malwareDatabaseUrl);
23
- if (!response.ok) {
24
- throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
25
- }
25
+ const numberOfAttempts = 4;
26
26
 
27
- try {
28
- let malwareDatabase = await response.json();
29
- return {
30
- malwareDatabase: malwareDatabase,
31
- version: response.headers.get("etag") || undefined,
32
- };
33
- } catch (/** @type {any} */ error) {
34
- throw new Error(`Error parsing malware database: ${error.message}`);
35
- }
27
+ return retry(async () => {
28
+ const ecosystem = getEcoSystem();
29
+ const malwareDatabaseUrl =
30
+ malwareDatabaseUrls[
31
+ /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
32
+ ];
33
+ const response = await fetch(malwareDatabaseUrl);
34
+ if (!response.ok) {
35
+ throw new Error(
36
+ `Error fetching ${ecosystem} malware database: ${response.statusText}`
37
+ );
38
+ }
39
+
40
+ try {
41
+ let malwareDatabase = await response.json();
42
+ return {
43
+ malwareDatabase: malwareDatabase,
44
+ version: response.headers.get("etag") || undefined,
45
+ };
46
+ } catch (/** @type {any} */ error) {
47
+ throw new Error(`Error parsing malware database: ${error.message}`);
48
+ }
49
+ }, numberOfAttempts);
36
50
  }
37
51
 
38
52
  /**
39
53
  * @returns {Promise<string | undefined>}
40
54
  */
41
55
  export async function fetchMalwareDatabaseVersion() {
42
- const ecosystem = getEcoSystem();
43
- const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
44
- const response = await fetch(malwareDatabaseUrl, {
45
- method: "HEAD",
46
- });
47
-
48
- if (!response.ok) {
49
- throw new Error(
50
- `Error fetching ${ecosystem} malware database version: ${response.statusText}`
51
- );
56
+ const numberOfAttempts = 4;
57
+
58
+ return retry(async () => {
59
+ const ecosystem = getEcoSystem();
60
+ const malwareDatabaseUrl =
61
+ malwareDatabaseUrls[
62
+ /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
63
+ ];
64
+ const response = await fetch(malwareDatabaseUrl, {
65
+ method: "HEAD",
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(
70
+ `Error fetching ${ecosystem} malware database version: ${response.statusText}`
71
+ );
72
+ }
73
+ return response.headers.get("etag") || undefined;
74
+ }, numberOfAttempts);
75
+ }
76
+
77
+ /**
78
+ * Retries an asynchronous function multiple times until it succeeds or exhausts all attempts.
79
+ *
80
+ * @template T
81
+ * @param {() => Promise<T>} func - The asynchronous function to retry
82
+ * @param {number} attempts - The number of attempts
83
+ * @returns {Promise<T>} The return value of the function if successful
84
+ * @throws {Error} The last error encountered if all retry attempts fail
85
+ */
86
+ async function retry(func, attempts) {
87
+ let lastError;
88
+
89
+ for (let i = 0; i < attempts; i++) {
90
+ try {
91
+ return await func();
92
+ } catch (error) {
93
+ ui.writeVerbose(
94
+ "An error occurred while trying to download the Aikido Malware database",
95
+ error
96
+ );
97
+ lastError = error;
98
+ }
99
+
100
+ if (i < attempts - 1) {
101
+ // When this is not the last try, back-off exponentially:
102
+ // 1st attempt - 500ms delay
103
+ // 2nd attempt - 1000ms delay
104
+ // 3rd attempt - 2000ms delay
105
+ // 4th attempt - 4000ms delay
106
+ // ...
107
+ await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500));
108
+ }
52
109
  }
53
- return response.headers.get("etag") || undefined;
110
+
111
+ throw lastError;
54
112
  }
@@ -16,6 +16,7 @@ import { getEcoSystem } from "./settings.js";
16
16
  * @typedef {Object} SafeChainRegistryConfiguration
17
17
  * We cannot trust the input and should add the necessary validations.
18
18
  * @property {unknown | string[]} customRegistries
19
+ * @property {unknown | string[]} minimumPackageAgeExclusions
19
20
  */
20
21
 
21
22
  /**
@@ -127,6 +128,27 @@ export function getPipCustomRegistries() {
127
128
  return customRegistries.filter((item) => typeof item === "string");
128
129
  }
129
130
 
131
+ /**
132
+ * Gets the minimum package age exclusions from the config file
133
+ * @returns {string[]}
134
+ */
135
+ export function getNpmMinimumPackageAgeExclusions() {
136
+ const config = readConfigFile();
137
+
138
+ if (!config || !config.npm) {
139
+ return [];
140
+ }
141
+
142
+ const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
143
+ const exclusions = npmConfig.minimumPackageAgeExclusions;
144
+
145
+ if (!Array.isArray(exclusions)) {
146
+ return [];
147
+ }
148
+
149
+ return exclusions.filter((item) => typeof item === "string");
150
+ }
151
+
130
152
  /**
131
153
  * @param {import("../api/aikido.js").MalwarePackage[]} data
132
154
  * @param {string | number} version
@@ -25,3 +25,22 @@ export function getNpmCustomRegistries() {
25
25
  export function getPipCustomRegistries() {
26
26
  return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES;
27
27
  }
28
+
29
+ /**
30
+ * Gets the logging level from environment variable
31
+ * Valid values: "silent", "normal", "verbose"
32
+ * @returns {string | undefined}
33
+ */
34
+ export function getLoggingLevel() {
35
+ return process.env.SAFE_CHAIN_LOGGING;
36
+ }
37
+
38
+ /**
39
+ * Gets the minimum package age exclusions from environment variable
40
+ * Expected format: comma-separated list of package names
41
+ * Example: "react,@aikidosec/safe-chain,lodash"
42
+ * @returns {string | undefined}
43
+ */
44
+ export function getNpmMinimumPackageAgeExclusions() {
45
+ return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
46
+ }
@@ -7,14 +7,20 @@ export const LOGGING_NORMAL = "normal";
7
7
  export const LOGGING_VERBOSE = "verbose";
8
8
 
9
9
  export function getLoggingLevel() {
10
- const level = cliArguments.getLoggingLevel();
11
-
12
- if (level === LOGGING_SILENT) {
13
- return LOGGING_SILENT;
10
+ // Priority 1: CLI argument
11
+ const cliLevel = cliArguments.getLoggingLevel();
12
+ if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) {
13
+ return cliLevel;
14
+ }
15
+ if (cliLevel) {
16
+ // CLI arg was set but invalid, default to normal for backwards compatibility.
17
+ return LOGGING_NORMAL;
14
18
  }
15
19
 
16
- if (level === LOGGING_VERBOSE) {
17
- return LOGGING_VERBOSE;
20
+ // Priority 2: Environment variable
21
+ const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase();
22
+ if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) {
23
+ return envLevel;
18
24
  }
19
25
 
20
26
  return LOGGING_NORMAL;
@@ -161,3 +167,34 @@ export function getPipCustomRegistries() {
161
167
  // Normalize each registry (remove protocol if any)
162
168
  return uniqueRegistries.map(normalizeRegistry);
163
169
  }
170
+
171
+ /**
172
+ * Parses comma-separated exclusions from environment variable
173
+ * @param {string | undefined} envValue
174
+ * @returns {string[]}
175
+ */
176
+ function parseExclusionsFromEnv(envValue) {
177
+ if (!envValue || typeof envValue !== "string") {
178
+ return [];
179
+ }
180
+
181
+ return envValue
182
+ .split(",")
183
+ .map((exclusion) => exclusion.trim())
184
+ .filter((exclusion) => exclusion.length > 0);
185
+ }
186
+
187
+ /**
188
+ * Gets the minimum package age exclusions from both environment variable and config file (merged)
189
+ * @returns {string[]}
190
+ */
191
+ export function getNpmMinimumPackageAgeExclusions() {
192
+ const envExclusions = parseExclusionsFromEnv(
193
+ environmentVariables.getNpmMinimumPackageAgeExclusions()
194
+ );
195
+ const configExclusions = configFile.getNpmMinimumPackageAgeExclusions();
196
+
197
+ // Merge both sources and remove duplicates
198
+ const allExclusions = [...envExclusions, ...configExclusions];
199
+ return [...new Set(allExclusions)];
200
+ }
@@ -1,4 +1,4 @@
1
- import { getMinimumPackageAgeHours } from "../../../config/settings.js";
1
+ import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
2
2
  import { ui } from "../../../environment/userInteraction.js";
3
3
  import { getHeaderValueAsString } from "../../http-utils.js";
4
4
 
@@ -65,6 +65,16 @@ export function modifyNpmInfoResponse(body, headers) {
65
65
  return body;
66
66
  }
67
67
 
68
+ // Check if this package is excluded from minimum age filtering
69
+ const packageName = bodyJson.name;
70
+ const exclusions = getNpmMinimumPackageAgeExclusions();
71
+ if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
72
+ ui.writeVerbose(
73
+ `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`
74
+ );
75
+ return body;
76
+ }
77
+
68
78
  const cutOff = new Date(
69
79
  new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
70
80
  );
@@ -116,8 +126,10 @@ export function modifyNpmInfoResponse(body, headers) {
116
126
  function deleteVersionFromJson(json, version) {
117
127
  state.hasSuppressedVersions = true;
118
128
 
129
+ const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
130
+
119
131
  ui.writeVerbose(
120
- `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
132
+ `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
121
133
  );
122
134
 
123
135
  delete json.time[version];
@@ -175,3 +187,17 @@ function getMostRecentTag(tagList) {
175
187
  export function getHasSuppressedVersions() {
176
188
  return state.hasSuppressedVersions;
177
189
  }
190
+
191
+ /**
192
+ * Checks if a package name matches an exclusion pattern.
193
+ * Supports trailing wildcard (*) for prefix matching.
194
+ * @param {string} packageName
195
+ * @param {string} pattern
196
+ * @returns {boolean}
197
+ */
198
+ function matchesExclusionPattern(packageName, pattern) {
199
+ if (pattern.endsWith("/*")) {
200
+ return packageName.startsWith(pattern.slice(0, -1));
201
+ }
202
+ return packageName === pattern;
203
+ }
@@ -6,27 +6,27 @@ $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
6
6
  $env:PATH = "$env:PATH$pathSeparator$safeChainBin"
7
7
 
8
8
  function npx {
9
- Invoke-WrappedCommand "npx" $args
9
+ Invoke-WrappedCommand "npx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
10
10
  }
11
11
 
12
12
  function yarn {
13
- Invoke-WrappedCommand "yarn" $args
13
+ Invoke-WrappedCommand "yarn" $args $MyInvocation.Line $MyInvocation.OffsetInLine
14
14
  }
15
15
 
16
16
  function pnpm {
17
- Invoke-WrappedCommand "pnpm" $args
17
+ Invoke-WrappedCommand "pnpm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
18
18
  }
19
19
 
20
20
  function pnpx {
21
- Invoke-WrappedCommand "pnpx" $args
21
+ Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
22
22
  }
23
23
 
24
24
  function bun {
25
- Invoke-WrappedCommand "bun" $args
25
+ Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
26
26
  }
27
27
 
28
28
  function bunx {
29
- Invoke-WrappedCommand "bunx" $args
29
+ Invoke-WrappedCommand "bunx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
30
30
  }
31
31
 
32
32
  function npm {
@@ -37,37 +37,37 @@ function npm {
37
37
  return
38
38
  }
39
39
 
40
- Invoke-WrappedCommand "npm" $args
40
+ Invoke-WrappedCommand "npm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
41
41
  }
42
42
 
43
43
  function pip {
44
- Invoke-WrappedCommand "pip" $args
44
+ Invoke-WrappedCommand "pip" $args $MyInvocation.Line $MyInvocation.OffsetInLine
45
45
  }
46
46
 
47
47
  function pip3 {
48
- Invoke-WrappedCommand "pip3" $args
48
+ Invoke-WrappedCommand "pip3" $args $MyInvocation.Line $MyInvocation.OffsetInLine
49
49
  }
50
50
 
51
51
  function uv {
52
- Invoke-WrappedCommand "uv" $args
52
+ Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine
53
53
  }
54
54
 
55
55
  function poetry {
56
- Invoke-WrappedCommand "poetry" $args
56
+ Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine
57
57
  }
58
58
 
59
59
  # `python -m pip`, `python -m pip3`.
60
60
  function python {
61
- Invoke-WrappedCommand 'python' $args
61
+ Invoke-WrappedCommand 'python' $args $MyInvocation.Line $MyInvocation.OffsetInLine
62
62
  }
63
63
 
64
64
  # `python3 -m pip`, `python3 -m pip3'.
65
65
  function python3 {
66
- Invoke-WrappedCommand 'python3' $args
66
+ Invoke-WrappedCommand 'python3' $args $MyInvocation.Line $MyInvocation.OffsetInLine
67
67
  }
68
68
 
69
69
  function pipx {
70
- Invoke-WrappedCommand "pipx" $args
70
+ Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
71
71
  }
72
72
 
73
73
  function Write-SafeChainWarning {
@@ -108,13 +108,60 @@ function Invoke-RealCommand {
108
108
  }
109
109
  }
110
110
 
111
+ function Get-ReconstructedArguments {
112
+ param(
113
+ [string]$RawLine,
114
+ [int]$RawOffset
115
+ )
116
+
117
+ if (-not $RawLine) { return $null }
118
+
119
+ $tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null)
120
+ $newArgs = @()
121
+ $foundCommand = $false
122
+
123
+ foreach ($t in $tokens) {
124
+ if (-not $foundCommand) {
125
+ if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true }
126
+ continue
127
+ }
128
+
129
+ if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break }
130
+
131
+ # Stop if complex variable expansion is used
132
+ if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') {
133
+ return $null
134
+ }
135
+
136
+ $newArgs += $t.Content
137
+ }
138
+
139
+ if ($foundCommand) {
140
+ return ,$newArgs
141
+ }
142
+ return $null
143
+ }
144
+
111
145
  function Invoke-WrappedCommand {
112
146
  param(
113
147
  [string]$OriginalCmd,
114
- [string[]]$Arguments
148
+ [string[]]$Arguments,
149
+ [string]$RawLine = $null,
150
+ [int]$RawOffset = 0
115
151
  )
116
152
 
117
- if (Test-CommandAvailable "safe-chain") {
153
+ # Use raw line parsing to recover arguments like '--' that PowerShell consumes
154
+ if ($RawLine) {
155
+ $reconstructedArgs = Get-ReconstructedArguments $RawLine $RawOffset
156
+ if ($null -ne $reconstructedArgs) {
157
+ $Arguments = $reconstructedArgs
158
+ }
159
+ }
160
+
161
+ if ($isWindowsPlatform -and (Test-CommandAvailable "safe-chain.cmd")) {
162
+ & safe-chain.cmd $OriginalCmd @Arguments
163
+ }
164
+ elseif (Test-CommandAvailable "safe-chain") {
118
165
  & safe-chain $OriginalCmd @Arguments
119
166
  }
120
167
  else {