proof-of-commitment 1.6.0 → 1.8.0

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.
Files changed (3) hide show
  1. package/README.md +264 -68
  2. package/index.js +296 -82
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -1,78 +1,58 @@
1
- # proof-of-commitment
1
+ # Proof of Commitment
2
2
 
3
- > Supply chain risk scorer for npm, PyPI, Cargo (Rust), and Go modules. Behavioral signals that can't be faked.
3
+ [![Commitment Score](https://poc-backend.amdal-dev.workers.dev/badge/npm/proof-of-commitment)](https://getcommit.dev/audit?packages=proof-of-commitment)
4
4
 
5
- ```
6
- npx proof-of-commitment axios zod chalk
7
- ```
5
+ > **Stars lie. Behavioral signals don't.**
8
6
 
9
- ```
10
- ──────────────────────────────────────────────────────────────────────────
11
- Package Risk Score Publishers Downloads Age
12
- ──────────────────────────────────────────────────────────────────────────
13
- axios 🔴 CRITICAL 89 1 102.0M/wk 11.6y
14
- ↳ 30+ GitHub contributors — publish-access concentration risk despite active community
15
- └ longevity=25 momentum=25 releases=20 publishers=4 github=15
16
- zod 🔴 CRITICAL 83 1 154.0M/wk 6.1y
17
- ↳ 30+ GitHub contributors — publish-access concentration risk despite active community
18
- └ longevity=25 momentum=25 releases=18 publishers=4 github=11
19
- chalk 🔴 CRITICAL 75 1 414.6M/wk 12.7y
20
- ↳ 30+ GitHub contributors — publish-access concentration risk despite active community
21
- └ longevity=25 momentum=22 releases=13 publishers=4 github=11
22
- ──────────────────────────────────────────────────────────────────────────
23
-
24
- ⚠ 3 CRITICAL packages found.
25
- CRITICAL = sole npm publisher + >10M weekly downloads (publish-access concentration risk)
26
- Full breakdown: https://getcommit.dev/audit?packages=axios,zod,chalk
27
- ```
7
+ An MCP server and web tool that scores npm packages, PyPI packages, Rust crates, Go modules, and GitHub repos on **behavioral commitment** — signals that are harder to fake than stars, READMEs, or download counts.
28
8
 
29
- ## What this does
9
+ ## The supply chain problem
30
10
 
31
- `npm audit` finds *known* CVEs vulnerabilities already catalogued in a database. This scores *structural risk before it becomes a CVE*.
11
+ 26 of the 91 npm packages with >10M weekly downloads have a **single npm publisher**. Together they account for over 3 billion downloads per week. `npm audit` doesn't surface this. Stars don't either.
32
12
 
33
- The axios attack on April 1st, 2026: `npm audit` showed zero issues beforehand. Proof of Commitment flagged axios as CRITICAL (1 npm publisher, 96M downloads/week) — the exact publish-access concentration profile that made it a high-value target.
13
+ Four packages in a typical Node.js project are CRITICAL right now:
14
+ - **chalk** — 413M downloads/week, **1 npm publisher**
15
+ - **zod** — 163M downloads/week, **1 npm publisher** (30+ GitHub contributors)
16
+ - **lodash** — 145M downloads/week, **1 npm publisher**
17
+ - **axios** — 99M downloads/week, **1 npm publisher** (attacked March 30, 2026)
34
18
 
35
- **Score dimensions:**
36
- - **Longevity** (25 pts) years in production
37
- - **Download Momentum** (25 pts) weekly download trend
38
- - **Release Consistency** (20 pts) days since last release
39
- - **Publisher Depth** (15 pts) — npm publish-access holders
40
- - **GitHub Backing** (15 pts) — organization/team support
19
+ They won't appear in your `package.json` either — but these are in almost every project:
20
+ - **minimatch** 562M downloads/week, **1 npm publisher**
21
+ - **glob** 333M downloads/week, **1 npm publisher**
22
+ - **cross-spawn** 190M downloads/week, **1 npm publisher**
41
23
 
42
- **CRITICAL** = sole npm publisher + >10M weekly downloads (publish-access concentration risk)
24
+ Behavioral signals surface this. Stars and READMEs don't.
43
25
 
44
- ## Usage
26
+ ## Try it now
45
27
 
28
+ **Terminal (zero install):**
46
29
  ```bash
47
- # Score npm packages
48
- npx proof-of-commitment axios zod chalk lodash express
49
-
50
- # Score PyPI packages
51
- npx proof-of-commitment --pypi litellm langchain requests numpy
52
-
53
- # Score Rust crates
30
+ npx proof-of-commitment axios zod chalk
31
+ # scan your own project:
32
+ npx proof-of-commitment --file package.json
33
+ # scan ALL transitive dependencies via lock file (finds the hidden CRITICAL packages):
34
+ npx proof-of-commitment --file package-lock.json # npm
35
+ npx proof-of-commitment --file yarn.lock # yarn
36
+ npx proof-of-commitment --file pnpm-lock.yaml # pnpm
37
+ # pnpm monorepo — scans all workspace packages, deduplicates:
38
+ npx proof-of-commitment --file pnpm-workspace.yaml # pnpm workspaces
39
+ # JSON output for CI/CD pipelines (exits 1 if CRITICAL found):
40
+ npx proof-of-commitment --file package-lock.json --json | jq '.criticalCount'
41
+ # PyPI too:
42
+ npx proof-of-commitment --pypi litellm langchain requests
43
+ # Cargo (Rust) via CLI:
54
44
  npx proof-of-commitment --cargo serde tokio reqwest
55
-
56
- # Score Go modules (full module path required — host/owner/repo)
45
+ # Go modules via CLI (full module path required):
57
46
  npx proof-of-commitment --golang github.com/gin-gonic/gin golang.org/x/net
58
-
59
- # Auto-detect from manifest / lock / module file
60
- npx proof-of-commitment --file package.json # npm
61
- npx proof-of-commitment --file package-lock.json # full transitive
62
- npx proof-of-commitment --file requirements.txt # PyPI
63
- npx proof-of-commitment --file Cargo.toml # Rust direct deps
64
- npx proof-of-commitment --file go.mod # Go direct + indirect
65
- npx proof-of-commitment --file go.sum # Go full transitive set
66
-
67
- # Short alias
68
- npx poc axios zod chalk
47
+ # Or scan a go.mod / go.sum file directly:
48
+ npx proof-of-commitment --file go.mod
49
+ npx proof-of-commitment --file go.sum # full transitive set
69
50
  ```
70
51
 
71
- **Go modules note:** Go has no centralized download counter and no publisher registry. Scoring is GitHub-primary: longevity, release cadence, GitHub contributor count, OpenSSF Scorecard, and stars (popularity proxy). The "publishers" column in Go output shows GitHub push-access contributors — the closest equivalent to npm publishers.
52
+ **Web demo (no install):** [getcommit.dev/audit](https://getcommit.dev/audit) paste your packages, see risk scores in seconds.
72
53
 
73
- ## Zero-install MCP server (for Claude, Cursor, Windsurf)
54
+ **MCP server (zero install):**
74
55
 
75
- Add to your AI tool's config:
76
56
  ```json
77
57
  {
78
58
  "mcpServers": {
@@ -84,20 +64,236 @@ Add to your AI tool's config:
84
64
  }
85
65
  ```
86
66
 
87
- Then ask: *"Audit the dependencies in my package.json"* or *"What's the risk profile of vercel/ai?"*
67
+ Add to Claude Desktop, Cursor, Windsurf, or any MCP-compatible AI tool. Then ask:
68
+
69
+ > "Audit my package.json for supply chain risk"
70
+ > "Score axios, zod, chalk, lodash — which is highest risk?"
71
+ > "Is vercel/ai actively maintained?"
88
72
 
89
73
  ## GitHub Action
90
74
 
91
- Posts audit results directly on your PR:
75
+ Add supply chain auditing to any CI pipeline in 30 seconds — auto-detects packages from `package.json` or `requirements.txt`, **posts results as a PR comment**, writes to GitHub Step Summary, and optionally fails on CRITICAL packages.
76
+
77
+ Use the dedicated action at [piiiico/commit-action](https://github.com/piiiico/commit-action):
78
+
92
79
  ```yaml
93
- - uses: piiiico/proof-of-commitment@main
94
- with:
95
- fail-on-critical: false
96
- comment-on-pr: true
80
+ # .github/workflows/supply-chain.yml
81
+ name: Supply Chain Audit
82
+ on:
83
+ pull_request:
84
+ paths: ['package.json', 'package-lock.json', 'bun.lock']
85
+
86
+ jobs:
87
+ audit:
88
+ runs-on: ubuntu-latest
89
+ permissions:
90
+ pull-requests: write
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ - uses: piiiico/commit-action@v1
94
+ with:
95
+ fail-on-critical: true # blocks merges on CRITICAL packages
96
+ comment-on-pr: true # posts results as a PR comment
97
+ ```
98
+
99
+ When `comment-on-pr: true` (default), the action automatically posts the audit table as a comment on the pull request — and **updates the same comment** on re-run, so you don't get comment spam. Reviewers see the risk table without leaving the PR.
100
+
101
+ **Inputs:**
102
+
103
+ | Input | Default | Description |
104
+ |-------|---------|-------------|
105
+ | `packages` | _(auto)_ | Comma-separated package names (auto-detected from `package.json`/`requirements.txt` if not set) |
106
+ | `packages-file` | _(auto)_ | Path to `package.json` or `requirements.txt` (default: auto-detect in workspace root) |
107
+ | `fail-on-critical` | `true` | Fail the workflow if CRITICAL packages are found |
108
+ | `max-packages` | `20` | Max packages to audit when auto-detecting |
109
+ | `include-dev-dependencies` | `false` | Include `devDependencies` from `package.json` |
110
+ | `comment-on-pr` | `true` | Post audit results as a PR comment (requires `pull-requests: write` permission) |
111
+ | `api-key` | _(none)_ | [Commit Pro](https://getcommit.dev/pricing) API key — enables batch requests and 10K requests/month |
112
+ | `api-url` | _(prod)_ | Override API endpoint (useful for self-hosting) |
113
+
114
+ **Outputs:** `has-critical`, `critical-count`, `audit-summary` (markdown table, also written to Step Summary).
115
+
116
+ **Free vs Pro:** Without an API key, packages are audited one at a time (with delays to respect rate limits). With a Pro API key, all packages are audited in a single batch request — faster and with higher monthly limits.
117
+
118
+ Example PR comment / Step Summary output:
119
+
120
+ ```
121
+ | Package | Risk | Score | Publishers | Downloads/wk | Age |
122
+ |---------|-------------|-------|------------|--------------|-------|
123
+ | chalk | 🔴 CRITICAL | 75 | 1 | 380M | 12.7y |
124
+ | zod | 🔴 CRITICAL | 83 | 1 | 133M | 6.1y |
125
+ | axios | 🔴 CRITICAL | 89 | 1 | 93M | 11.6y |
126
+ ```
127
+
128
+ ## README Badges
129
+
130
+ Add a Commit Trust badge to any npm package you maintain or depend on:
131
+
132
+ ```markdown
133
+ ![Commit Trust](https://poc-backend.amdal-dev.workers.dev/badge/YOUR-PACKAGE)
134
+ ```
135
+
136
+ Examples:
137
+
138
+ | Package | Badge URL |
139
+ |---------|-----------|
140
+ | chalk | `![Commit Trust](https://poc-backend.amdal-dev.workers.dev/badge/chalk)` |
141
+ | react | `![Commit Trust](https://poc-backend.amdal-dev.workers.dev/badge/react)` |
142
+ | express | `![Commit Trust](https://poc-backend.amdal-dev.workers.dev/badge/express)` |
143
+ | @babel/core | `![Commit Trust](https://poc-backend.amdal-dev.workers.dev/badge/@babel/core)` |
144
+
145
+ Grades: 🟢 OK (75+) · 🟠 WARNING (40–74) · 🔴 CRITICAL (<40 or sole npm publisher with 10M+ weekly downloads)
146
+
147
+ Badges are cached 1 hour. No API key needed.
148
+
149
+ Also supports PyPI, Cargo, Go modules, and the full ecosystem-specific format:
150
+
151
+ ```markdown
152
+ ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/npm/YOUR-PACKAGE)
153
+ ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/pypi/YOUR-PACKAGE)
154
+ ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/cargo/YOUR-CRATE)
155
+ ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/golang/github.com/owner/repo)
97
156
  ```
98
157
 
99
- ## Links
158
+ ## REST API
100
159
 
101
- - **Web demo:** https://getcommit.dev/audit
102
- - **Live watchlist:** https://getcommit.dev/watchlist (top 25 npm packages by structural risk)
103
- - **GitHub:** https://github.com/piiiico/proof-of-commitment
160
+ No API key. No install.
161
+
162
+ ```bash
163
+ curl https://poc-backend.amdal-dev.workers.dev/api/audit \
164
+ -X POST \
165
+ -H "Content-Type: application/json" \
166
+ -d '{"packages": ["axios", "zod", "chalk", "lodash", "express"]}'
167
+ ```
168
+
169
+ ```json
170
+ {
171
+ "count": 5,
172
+ "results": [
173
+ {
174
+ "name": "chalk",
175
+ "ecosystem": "npm",
176
+ "score": 75,
177
+ "maintainers": 1,
178
+ "weeklyDownloads": 398397580,
179
+ "ageYears": 12.7,
180
+ "trend": "stable",
181
+ "riskFlags": ["CRITICAL"],
182
+ "scorecardScore": 3.6, // null if no GitHub repo
183
+ "hasDangerousWorkflow": false // null if no Scorecard data
184
+ },
185
+ ...
186
+ ]
187
+ }
188
+ ```
189
+
190
+ ## 9 MCP tools
191
+
192
+ | Tool | Description |
193
+ |------|-------------|
194
+ | `audit_dependencies` | Batch risk audit for up to 20 npm/PyPI/Cargo/Go packages |
195
+ | `lookup_npm_package` | Single npm package behavioral profile |
196
+ | `lookup_pypi_package` | Single PyPI package behavioral profile |
197
+ | `lookup_cargo_crate` | Single Rust crate behavioral profile (crates.io) |
198
+ | `lookup_go_module` | Single Go module behavioral profile (proxy.golang.org + GitHub) |
199
+ | `lookup_github_repo` | GitHub repo commitment score (longevity, commit frequency, contributor depth) |
200
+ | `lookup_business` | Norwegian business register — operating years, employees, financials |
201
+ | `lookup_business_by_org` | Same, by org number |
202
+ | `query_commitment` | Browser extension behavioral data (unique verified visitors, repeat rate) |
203
+
204
+ ## What the score measures
205
+
206
+ Each package is scored 0–100 across:
207
+
208
+ - **Longevity** — How long has the package existed? Abandoned packages get reactivated for attacks.
209
+ - **Publisher depth** — Single npm publisher + millions of weekly downloads = the attack surface LiteLLM exploited. (Publisher = person with npm publish access, distinct from GitHub contributors.)
210
+ - **Release consistency** — Regular releases signal active oversight. Long gaps = vulnerability accumulation.
211
+ - **Download trend** — Growing packages attract more scrutiny (and attacks). Stable = lower profile.
212
+ - **OpenSSF Scorecard** — Process security (code review enforcement, branch protection, CI/CD safety). Separate from behavioral signals. High Scorecard ≠ safe from credential theft attacks.
213
+
214
+ > Both axios (8.1/10 Scorecard) and chalk (3.6/10 Scorecard) score CRITICAL on behavioral signals. They measure different attack surfaces — Scorecard catches process gaps, behavioral signals catch publisher concentration.
215
+
216
+ **Risk flags:**
217
+ - `CRITICAL` — single npm publisher + >10M weekly downloads (exact LiteLLM/axios attack profile)
218
+ - `HIGH` — package <1yr old + rapid adoption
219
+ - `WARN` — no release in 12+ months
220
+
221
+ ## Real data points
222
+
223
+ ```
224
+ # packages you know about:
225
+ chalk — score 75, 1 publisher, 413M/week ⚑ CRITICAL
226
+ zod — score 86, 1 publisher, 163M/week ⚑ CRITICAL (30+ GitHub contributors)
227
+ lodash — score 81, 1 publisher, 145M/week ⚑ CRITICAL
228
+ axios — score 86, 1 publisher, 99M/week ⚑ CRITICAL (attacked Mar 30 2026)
229
+ express — score 90, 5 publishers, 95M/week
230
+
231
+ # packages probably not in your package.json, definitely in your lock file:
232
+ minimatch — score 78, 1 publisher, 562M/week ⚑ CRITICAL
233
+ glob — score 80, 1 publisher, 333M/week ⚑ CRITICAL
234
+ cross-spawn — score 72, 1 publisher, 190M/week ⚑ CRITICAL
235
+
236
+ # post-attack:
237
+ litellm — score 74, 1 publisher ⚑ CRITICAL (supply chain attack Mar 2026)
238
+
239
+ # Rust crates (new in v1.3.0):
240
+ serde — score 78, 1 owner, 13M/week ⚑ CRITICAL (dtolnay sole owner)
241
+ tokio — score 89, 2 owners, 10M/week
242
+ reqwest — score 85, 1 owner, 8M/week ⚑ HIGH
243
+ ```
244
+
245
+ ## Why behavioral signals
246
+
247
+ The LiteLLM attack (March 2026) and axios attack (March 30, 2026) followed the same pattern: stolen credentials → malicious package pushed → 97M+ machines exposed. Both packages scored CRITICAL by these metrics *before* the attacks.
248
+
249
+ Declarative signals (stars, README quality, CI badges) don't capture this risk. Behavioral commitment does.
250
+
251
+ ## Stack
252
+
253
+ | Layer | Technology |
254
+ |-------|-----------|
255
+ | Backend | Cloudflare Workers + D1 |
256
+ | MCP | Model Context Protocol SDK |
257
+ | Data | npm registry, PyPI, crates.io, proxy.golang.org, deps.dev, GitHub API, Brønnøysund (NO) |
258
+ | Landing | Astro + Cloudflare Pages |
259
+
260
+ ## Roadmap
261
+
262
+ Planned, not promised. The project is early-stage — contributions welcome on any of these.
263
+
264
+ | Feature | Status | Notes |
265
+ |---------|--------|-------|
266
+ | **Cargo (Rust) registry support** | ✅ Live | MCP tool, REST API, badge endpoint — `ecosystem: "cargo"` |
267
+ | **Go modules support** | ✅ Live | proxy.golang.org + deps.dev + GitHub-primary scoring — `ecosystem: "golang"` |
268
+ | **Score breakdown visualization** | Planned | Chart component for the 5 dimensions on getcommit.dev/audit |
269
+ | **`--json` flag for CLI** | ✅ Live | `npx proof-of-commitment --file package-lock.json --json \| jq '.criticalCount'` |
270
+ | **pnpm workspace monorepo support** | ✅ Live | `--file pnpm-workspace.yaml` or auto-detected from `pnpm-lock.yaml` |
271
+ | **Historical score tracking** | Planned | Trend charts — was this package getting riskier over time? |
272
+ | **Org-level dashboards** | Planned | Aggregate risk view across all repos in a GitHub org |
273
+
274
+ See [open issues](https://github.com/piiiico/proof-of-commitment/issues) for things you can help with today.
275
+
276
+ ## The broader vision
277
+
278
+ Supply chain auditing is the first tool. The underlying primitive is a **commitment graph** — behavioral signals that replace content-based trust across any domain.
279
+
280
+ When content is free to fake (reviews, stars, READMEs), commitment becomes the signal. A publisher who has shipped 847 releases over 12 years is a different kind of commitment than one who published once in 2023.
281
+
282
+ The same logic applies to websites, businesses, and AI agents. Two card networks have independently named this gap: Mastercard Verifiable Intent §9.2 explicitly lists behavioral trust as "not covered." Visa TAP identifies agents without answering whether to trust them.
283
+
284
+ Proof of Commitment is the trust layer they're pointing at.
285
+
286
+ → [getcommit.dev](https://getcommit.dev)
287
+
288
+ ## Run locally
289
+
290
+ ```bash
291
+ bun install
292
+ bun run dev:backend # local server with SQLite
293
+ bun run test:e2e # E2E test with mock World ID
294
+ ```
295
+
296
+ Deploy:
297
+ ```bash
298
+ bun run deploy # deploys to Cloudflare Workers
299
+ ```
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proof-of-commitment CLI
4
- * Scores npm/PyPI packages on behavioral commitment signals.
3
+ * proof-of-commitment CLI v1.8.0
4
+ * Scores npm/PyPI/Cargo/Go packages on behavioral commitment signals.
5
5
  * Usage: npx proof-of-commitment [packages...] [options]
6
6
  */
7
7
 
@@ -20,6 +20,7 @@ const c = {
20
20
  white: '\x1b[37m',
21
21
  bgRed: '\x1b[41m',
22
22
  bgYellow: '\x1b[43m',
23
+ magenta: '\x1b[35m',
23
24
  };
24
25
 
25
26
  const NO_COLOR = process.env.NO_COLOR || !process.stdout.isTTY;
@@ -29,15 +30,20 @@ function clr(code, text) {
29
30
  return `${code}${text}${c.reset}`;
30
31
  }
31
32
 
33
+ /** Check if riskFlags array contains a CRITICAL-level flag (handles both "CRITICAL" and "CRITICAL: ..." formats) */
34
+ function hasCritical(flags) {
35
+ return flags && flags.some(f => typeof f === 'string' && f.startsWith('CRITICAL'));
36
+ }
37
+
32
38
  function riskColor(flags, score) {
33
- if (flags && flags.includes('CRITICAL')) return c.red + c.bold;
39
+ if (hasCritical(flags)) return c.red + c.bold;
34
40
  if (score < 40) return c.yellow + c.bold;
35
41
  if (score < 60) return c.yellow;
36
42
  return c.green;
37
43
  }
38
44
 
39
45
  function riskLabel(flags, score) {
40
- if (flags && flags.includes('CRITICAL')) return '🔴 CRITICAL';
46
+ if (hasCritical(flags)) return '🔴 CRITICAL';
41
47
  if (score < 40) return '🟠 HIGH';
42
48
  if (score < 60) return '🟡 MODERATE';
43
49
  if (score < 75) return '🟡 GOOD';
@@ -57,20 +63,28 @@ function padEnd(str, len) {
57
63
  }
58
64
 
59
65
  function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
66
+ const isNpm = !results[0] || results[0].ecosystem !== 'golang';
60
67
  const COL = {
61
- name: 20, risk: 14, score: 7, maintainers: 12, downloads: 12, age: 8,
68
+ name: 20, risk: 14, score: 7, maintainers: 12, downloads: 12, age: 8, provenance: 10,
62
69
  };
63
70
 
64
- const header = [
71
+ const headerParts = [
65
72
  padEnd(clr(c.bold, 'Package'), COL.name),
66
73
  padEnd(clr(c.bold, 'Risk'), COL.risk),
67
74
  padEnd(clr(c.bold, 'Score'), COL.score),
68
75
  padEnd(clr(c.bold, 'Publishers'), COL.maintainers),
69
76
  padEnd(clr(c.bold, 'Downloads'), COL.downloads),
70
77
  padEnd(clr(c.bold, 'Age'), COL.age),
71
- ].join(' ');
78
+ ];
79
+
80
+ // Show Provenance column for npm packages
81
+ if (isNpm) {
82
+ headerParts.push(padEnd(clr(c.bold, 'Provenance'), COL.provenance));
83
+ }
72
84
 
73
- const divider = ''.repeat(COL.name + COL.risk + COL.score + COL.maintainers + COL.downloads + COL.age + 10);
85
+ const header = headerParts.join(' ');
86
+ const divWidth = COL.name + COL.risk + COL.score + COL.maintainers + COL.downloads + COL.age + (isNpm ? COL.provenance + 2 : 0) + 10;
87
+ const divider = '─'.repeat(divWidth);
74
88
 
75
89
  console.log('\n' + divider);
76
90
  if (lockfile && totalScanned && results.length < totalScanned) {
@@ -81,41 +95,46 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
81
95
  console.log(divider);
82
96
 
83
97
  let criticalInDisplay = 0;
98
+ let provenanceCount = 0;
84
99
 
85
100
  for (const pkg of results) {
86
101
  const rc = riskColor(pkg.riskFlags, pkg.score);
87
102
  const label = riskLabel(pkg.riskFlags, pkg.score);
88
- if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL')) criticalInDisplay++;
103
+ if (hasCritical(pkg.riskFlags)) criticalInDisplay++;
104
+ if (pkg.hasProvenance) provenanceCount++;
89
105
 
90
- // Go modules have no download data — show "—" instead. The maintainers
91
- // column for Go shows GitHub contributor count (the closest equivalent
92
- // to publish access since Go has no centralized publisher concept).
106
+ // Go modules have no download data
93
107
  const isGo = pkg.ecosystem === 'golang';
94
- const dlDisplay = isGo
95
- ? ''
96
- : fmtDownloads(pkg.weeklyDownloads || 0);
97
- const maintDisplay = pkg.maintainers === 35
98
- ? '30+'
99
- : String(pkg.maintainers || '?');
100
-
101
- const row = [
108
+ const dlDisplay = isGo ? '—' : fmtDownloads(pkg.weeklyDownloads || 0);
109
+ const maintDisplay = pkg.maintainers === 35 ? '30+' : String(pkg.maintainers || '?');
110
+
111
+ // Provenance indicator
112
+ const provDisplay = pkg.hasProvenance
113
+ ? clr(c.green, '🔐 verified')
114
+ : clr(c.dim, '—');
115
+
116
+ const rowParts = [
102
117
  padEnd(pkg.name, COL.name),
103
118
  padEnd(clr(rc, label), COL.risk),
104
119
  padEnd(String(pkg.score), COL.score),
105
120
  padEnd(maintDisplay, COL.maintainers),
106
121
  padEnd(dlDisplay, COL.downloads),
107
122
  padEnd((pkg.ageYears || '?').toString().replace(/(\.\d).*/, '$1') + 'y', COL.age),
108
- ].join(' ');
123
+ ];
124
+
125
+ if (isNpm) {
126
+ rowParts.push(padEnd(provDisplay, COL.provenance));
127
+ }
109
128
 
110
- console.log(row);
129
+ console.log(rowParts.join(' '));
111
130
 
112
131
  // Show GitHub contributor context for CRITICAL packages with active communities
113
- if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL') && pkg.githubContributors && pkg.githubContributors > 1) {
132
+ if (hasCritical(pkg.riskFlags) && pkg.githubContributors && pkg.githubContributors > 1) {
114
133
  const ghCount = pkg.githubContributors === 35 ? '30+' : pkg.githubContributors;
115
134
  console.log(clr(c.dim, ` ↳ ${ghCount} GitHub contributors — publish-access concentration risk despite active community`));
116
135
  }
117
136
 
118
- // Score breakdown if available — keys differ across ecosystems
137
+ // Score breakdown if available
119
138
  if (pkg.scoreBreakdown) {
120
139
  const b = pkg.scoreBreakdown;
121
140
  const breakdown = isGo
@@ -125,7 +144,8 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
125
144
  )
126
145
  : clr(c.dim,
127
146
  ` └ longevity=${b.longevity} momentum=${b.downloadMomentum} ` +
128
- `releases=${b.releaseConsistency} publishers=${b.maintainerDepth} github=${b.githubBacking}`
147
+ `releases=${b.releaseConsistency} publishers=${b.maintainerDepth} github=${b.githubBacking}` +
148
+ (b.trustedPublishing ? ` provenance=${b.trustedPublishing}` : '')
129
149
  );
130
150
  console.log(breakdown);
131
151
  }
@@ -138,22 +158,27 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
138
158
  const suffix = totalScanned ? ` (in ${totalScanned} packages scanned)` : '';
139
159
  console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL package${effectiveCritical > 1 ? 's' : ''} found${suffix}.`));
140
160
  console.log(clr(c.dim, ' CRITICAL = sole npm publisher + >10M weekly downloads (publish-access concentration risk)'));
161
+ if (provenanceCount > 0 && provenanceCount < results.length) {
162
+ console.log(clr(c.cyan, ` 🔐 ${provenanceCount}/${results.length} use Trusted Publishing (OIDC provenance) — partial mitigation`));
163
+ }
141
164
  } else {
142
165
  const suffix = totalScanned ? ` (${totalScanned} packages scanned)` : '';
143
166
  console.log('\n' + clr(c.green, `✓ No CRITICAL packages found${suffix}.`));
144
167
  }
145
168
 
146
- // Always show the web URL with top critical packages first
169
+ // Footer with web link + CI integration CTA
147
170
  const topPkgs = results.slice(0, 10).map(r => r.name).join(',');
148
- console.log(clr(c.dim, `\n🔗 Web view: ${WEB}?packages=${encodeURIComponent(topPkgs)}`));
171
+ console.log(clr(c.cyan, `\n 🔗 Full report: ${WEB}?packages=${encodeURIComponent(topPkgs)}`));
172
+ console.log(clr(c.cyan, ` 🤖 GitHub Action: github.com/piiiico/commit-action — block CRITICAL packages in CI`));
149
173
  console.log();
150
174
  }
151
175
 
152
176
  function printHelp() {
153
177
  console.log(`
154
- ${clr(c.bold, 'proof-of-commitment')} — supply chain risk scorer
178
+ ${clr(c.bold, 'proof-of-commitment')} v1.8.0 — supply chain risk scorer
155
179
 
156
180
  ${clr(c.bold, 'Usage:')}
181
+ npx proof-of-commitment Auto-detect manifest in current dir
157
182
  npx proof-of-commitment [packages...] Score npm packages
158
183
  npx proof-of-commitment --pypi [pkgs...] Score PyPI packages
159
184
  npx proof-of-commitment --cargo [crates...] Score Rust crates
@@ -168,55 +193,88 @@ ${clr(c.bold, 'Usage:')}
168
193
  npx proof-of-commitment --file go.sum Audit Go full transitive set
169
194
 
170
195
  ${clr(c.bold, 'Options:')}
171
- --json Output results as JSON (exits 1 if any CRITICAL found — useful in CI)
172
- --pypi Score PyPI packages instead of npm
173
- --cargo Score Rust crates from crates.io
174
- --golang Score Go modules from proxy.golang.org (use full path: github.com/owner/repo)
175
- --file, -f Read packages from package.json, lock file, requirements.txt, Cargo.toml, or go.mod/go.sum
196
+ --json Output results as JSON
197
+ --fail-on=<level> Exit 1 when findings meet the threshold. Levels:
198
+ critical any CRITICAL package (publish-access concentration)
199
+ risky any CRITICAL or HIGH (score < 40) package
200
+ none always exit 0
201
+ Defaults: 'critical' in CI (env CI=true) and for --json output;
202
+ 'none' for interactive table output (backward-compatible).
203
+ --pypi Score PyPI packages instead of npm
204
+ --cargo Score Rust crates from crates.io
205
+ --golang Score Go modules from proxy.golang.org (use full path: github.com/owner/repo)
206
+ --file, -f Read packages from package.json, lock file, requirements.txt, Cargo.toml, or go.mod/go.sum
207
+
208
+ ${clr(c.bold, 'Auto-detect (no args):')}
209
+ Running 'npx proof-of-commitment' with no arguments scans the most-recently-modified
210
+ manifest in the current directory. Detection order (highest transitive coverage first):
211
+ npm: package-lock.json · yarn.lock · pnpm-lock.yaml · pnpm-workspace.yaml · package.json
212
+ pypi: requirements.txt
213
+ cargo: Cargo.toml
214
+ golang: go.sum · go.mod
215
+ When multiple ecosystems are present, the file with the most recent mtime wins.
176
216
 
177
217
  ${clr(c.bold, 'Examples:')}
218
+ npx proof-of-commitment # scans cwd manifest
178
219
  npx proof-of-commitment axios zod chalk
179
220
  npx proof-of-commitment --pypi litellm langchain requests
180
221
  npx proof-of-commitment --cargo serde tokio reqwest
181
222
  npx proof-of-commitment --golang github.com/gin-gonic/gin golang.org/x/net
182
- npx proof-of-commitment --file package-lock.json # scans ALL transitive deps
183
- npx proof-of-commitment --file go.sum # scans full Go module graph
223
+ npx proof-of-commitment --file package-lock.json # scans ALL transitive deps
224
+ npx proof-of-commitment --file go.sum # scans full Go module graph
184
225
  npx proof-of-commitment axios chalk --json | jq '.criticalCount'
226
+ npx proof-of-commitment --fail-on=critical # CI-friendly hard gate
227
+
228
+ ${clr(c.bold, 'CI integration (GitHub Actions):')}
229
+ # .github/workflows/supply-chain.yml
230
+ jobs:
231
+ audit:
232
+ runs-on: ubuntu-latest
233
+ steps:
234
+ - uses: actions/checkout@v4
235
+ - uses: actions/setup-node@v4
236
+ with: { node-version: '20' }
237
+ - run: npx -y proof-of-commitment --fail-on=critical
238
+
239
+ # Block PRs when a dependency hits CRITICAL.
240
+ # Use --fail-on=risky to also block HIGH-risk (score < 40) packages.
241
+ # Alternative: piiiico/commit-action@v1 (annotated PR checks).
185
242
 
186
243
  ${clr(c.bold, 'Score meaning:')}
187
- 🔴 CRITICAL Sole npm publisher + >10M downloads/wk (publish-access concentration risk)
244
+ 🔴 CRITICAL Sole publisher + >10M downloads/wk (publish-access concentration risk)
188
245
  🟠 HIGH Score < 40
189
246
  🟡 MODERATE Score 40–59
190
247
  🟡 GOOD Score 60–74
191
248
  🟢 HEALTHY Score 75+
192
249
 
193
- ${clr(c.bold, 'Score dimensions (npm/PyPI/Cargo):')} longevity · download momentum · release consistency · publisher depth · GitHub backing
194
- ${clr(c.bold, 'Score dimensions (Go):')} longevity · release consistency · maintainer depth · GitHub backing · stars (Go has no centralized download counter)
250
+ ${clr(c.bold, 'Provenance (npm):')}
251
+ 🔐 verified Package uses Trusted Publishing (OIDC provenance from CI not a human credential)
252
+ — No provenance attestation detected
253
+
254
+ ${clr(c.bold, 'Score dimensions (npm/PyPI/Cargo):')} longevity · download momentum · release consistency · publisher depth · GitHub backing · provenance
255
+ ${clr(c.bold, 'Score dimensions (Go):')} longevity · release consistency · maintainer depth · GitHub backing · stars
256
+
257
+ ${clr(c.bold, 'MCP:')} Add to Claude Desktop / Cursor for AI-assisted auditing — see homepage.
195
258
 
196
259
  ${clr(c.bold, 'Web:')} ${WEB}
197
- ${clr(c.bold, 'MCP:')} ${clr(c.dim, 'Add to Claude Desktop / Cursor for AI-assisted auditing')}
198
260
  `);
199
261
  }
200
262
 
201
263
  /**
202
264
  * Parse package-lock.json (npm lockfileVersion 2 or 3)
203
- * Returns all package names found in the lock file.
204
265
  */
205
266
  function parseLockNpm(content) {
206
267
  const lock = JSON.parse(content);
207
268
  const pkgs = new Set();
208
269
 
209
270
  if (lock.packages) {
210
- // lockfileVersion 2+: keys are "node_modules/pkg" or "node_modules/@scope/pkg"
211
271
  for (const key of Object.keys(lock.packages)) {
212
- if (!key || key === '') continue; // root package
213
- // Strip "node_modules/" prefix, handle nested paths like "node_modules/foo/node_modules/bar"
272
+ if (!key || key === '') continue;
214
273
  const parts = key.split('node_modules/');
215
274
  const pkgPath = parts[parts.length - 1];
216
275
  if (pkgPath) pkgs.add(pkgPath);
217
276
  }
218
277
  } else if (lock.dependencies) {
219
- // lockfileVersion 1: flat dependencies object
220
278
  for (const name of Object.keys(lock.dependencies)) {
221
279
  pkgs.add(name);
222
280
  }
@@ -227,11 +285,9 @@ function parseLockNpm(content) {
227
285
 
228
286
  /**
229
287
  * Parse yarn.lock (v1 format)
230
- * Returns all unique package names.
231
288
  */
232
289
  function parseLockYarn(content) {
233
290
  const pkgs = new Set();
234
- // Each block starts with "name@version:" or "name@range1, name@range2:"
235
291
  const headerRe = /^"?(@?[^@\s"]+)@/gm;
236
292
  let match;
237
293
  while ((match = headerRe.exec(content)) !== null) {
@@ -242,11 +298,9 @@ function parseLockYarn(content) {
242
298
 
243
299
  /**
244
300
  * Parse pnpm-lock.yaml (v6+)
245
- * Returns all unique package names.
246
301
  */
247
302
  function parseLockPnpm(content) {
248
303
  const pkgs = new Set();
249
- // packages section has entries like " /chalk@5.3.0:" or " chalk@5.3.0:"
250
304
  const pkgRe = /^\s+\/?(@?[^@\s/]+(?:\/[^@\s]+)?)@/gm;
251
305
  let match;
252
306
  while ((match = pkgRe.exec(content)) !== null) {
@@ -255,12 +309,74 @@ function parseLockPnpm(content) {
255
309
  return [...pkgs];
256
310
  }
257
311
 
312
+ /**
313
+ * Parse pnpm-workspace.yaml — find all workspace packages and aggregate their deps.
314
+ * Expects format:
315
+ * packages:
316
+ * - "packages/*"
317
+ * - "apps/*"
318
+ */
319
+ async function parsePnpmWorkspace(content, filePath) {
320
+ const fs = await import('fs');
321
+ const path = await import('path');
322
+ const dir = path.dirname(filePath);
323
+ const pkgs = new Set();
324
+
325
+ // Extract glob patterns from YAML
326
+ const patterns = [];
327
+ const lines = content.split('\n');
328
+ let inPackages = false;
329
+ for (const raw of lines) {
330
+ const line = raw.trim();
331
+ if (line === 'packages:') { inPackages = true; continue; }
332
+ if (inPackages && line.startsWith('-')) {
333
+ const pattern = line.replace(/^-\s*["']?/, '').replace(/["']?\s*$/, '');
334
+ patterns.push(pattern);
335
+ } else if (inPackages && !line.startsWith('#') && line !== '') {
336
+ break;
337
+ }
338
+ }
339
+
340
+ // For each pattern, look for package.json files
341
+ for (const pattern of patterns) {
342
+ const globDir = path.join(dir, pattern.replace('/*', '').replace('/**', ''));
343
+ try {
344
+ const entries = fs.readdirSync(globDir, { withFileTypes: true });
345
+ for (const entry of entries) {
346
+ if (!entry.isDirectory()) continue;
347
+ const pkgJsonPath = path.join(globDir, entry.name, 'package.json');
348
+ try {
349
+ const pkgContent = fs.readFileSync(pkgJsonPath, 'utf-8');
350
+ const pkg = JSON.parse(pkgContent);
351
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
352
+ for (const name of Object.keys(deps)) pkgs.add(name);
353
+ } catch {}
354
+ }
355
+ } catch {}
356
+ }
357
+
358
+ // Also check root package.json
359
+ try {
360
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
361
+ const deps = { ...rootPkg.dependencies, ...rootPkg.devDependencies };
362
+ for (const name of Object.keys(deps)) pkgs.add(name);
363
+ } catch {}
364
+
365
+ return [...pkgs];
366
+ }
367
+
258
368
  async function readPackagesFromFile(filePath) {
259
369
  const fs = await import('fs');
260
370
  const path = await import('path');
261
371
  const content = fs.readFileSync(filePath, 'utf-8');
262
372
  const basename = path.basename(filePath).toLowerCase();
263
373
 
374
+ // pnpm-workspace.yaml
375
+ if (basename === 'pnpm-workspace.yaml' || basename === 'pnpm-workspace.yml') {
376
+ const pkgs = await parsePnpmWorkspace(content, filePath);
377
+ return { packages: pkgs, ecosystem: 'npm', lockfile: false, totalInFile: pkgs.length };
378
+ }
379
+
264
380
  // package-lock.json
265
381
  if (basename === 'package-lock.json') {
266
382
  const pkgs = parseLockNpm(content);
@@ -302,30 +418,27 @@ async function readPackagesFromFile(filePath) {
302
418
 
303
419
  // Cargo.toml
304
420
  if (basename === 'cargo.toml') {
305
- // Crude TOML parse — extract [dependencies] section keys
306
421
  const pkgs = parseCargoToml(content);
307
422
  return { packages: pkgs, ecosystem: 'cargo', lockfile: false };
308
423
  }
309
424
 
310
- // go.mod — Go module file
425
+ // go.mod
311
426
  if (basename === 'go.mod') {
312
427
  const pkgs = parseGoMod(content);
313
428
  return { packages: pkgs, ecosystem: 'golang', lockfile: false, totalInFile: pkgs.length };
314
429
  }
315
430
 
316
- // go.sum — Go module checksum file (lockfile-equivalent — captures full transitive set)
431
+ // go.sum
317
432
  if (basename === 'go.sum') {
318
433
  const pkgs = parseGoSum(content);
319
434
  return { packages: pkgs, ecosystem: 'golang', lockfile: true, totalInFile: pkgs.length };
320
435
  }
321
436
 
322
- throw new Error(`Unsupported file: ${basename}. Supported: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt, Cargo.toml, go.mod, go.sum`);
437
+ throw new Error(`Unsupported file: ${basename}. Supported: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, pnpm-workspace.yaml, requirements.txt, Cargo.toml, go.mod, go.sum`);
323
438
  }
324
439
 
325
440
  /**
326
- * Parse a Cargo.toml — extract direct deps from [dependencies] / [dev-dependencies].
327
- * Crude — only handles the common "name = version" / "name = { ... }" lines under
328
- * [dependencies] / [dev-dependencies]. Doesn't expand workspace members.
441
+ * Parse Cargo.toml
329
442
  */
330
443
  function parseCargoToml(content) {
331
444
  const pkgs = new Set();
@@ -345,13 +458,7 @@ function parseCargoToml(content) {
345
458
  }
346
459
 
347
460
  /**
348
- * Parse a go.mod file — extract direct + indirect dependencies from require blocks.
349
- * Format:
350
- * require (
351
- * github.com/gin-gonic/gin v1.9.1
352
- * golang.org/x/net v0.20.0 // indirect
353
- * )
354
- * require github.com/foo/bar v1.0.0
461
+ * Parse go.mod
355
462
  */
356
463
  function parseGoMod(content) {
357
464
  const pkgs = new Set();
@@ -362,7 +469,6 @@ function parseGoMod(content) {
362
469
  const line = raw.trim();
363
470
  if (!line || line.startsWith('//')) continue;
364
471
 
365
- // Block-form require: "require ("
366
472
  if (/^require\s*\(\s*$/.test(line)) {
367
473
  inRequireBlock = true;
368
474
  continue;
@@ -372,14 +478,12 @@ function parseGoMod(content) {
372
478
  continue;
373
479
  }
374
480
 
375
- // Inside a block: "<modulepath> <version>" optionally followed by comments
376
481
  if (inRequireBlock) {
377
482
  const match = line.match(/^([^\s]+)\s+v[^\s]+/);
378
483
  if (match) pkgs.add(match[1]);
379
484
  continue;
380
485
  }
381
486
 
382
- // Single-line require: "require <modulepath> <version>"
383
487
  const single = line.match(/^require\s+([^\s]+)\s+v[^\s]+/);
384
488
  if (single) pkgs.add(single[1]);
385
489
  }
@@ -388,8 +492,7 @@ function parseGoMod(content) {
388
492
  }
389
493
 
390
494
  /**
391
- * Parse a go.sum file — module path is the first column, version + suffix follow.
392
- * Each module appears multiple times (once per artifact); dedupe.
495
+ * Parse go.sum
393
496
  */
394
497
  function parseGoSum(content) {
395
498
  const pkgs = new Set();
@@ -402,9 +505,56 @@ function parseGoSum(content) {
402
505
  return [...pkgs];
403
506
  }
404
507
 
508
+ /**
509
+ * Auto-detect the most authoritative manifest in the current directory.
510
+ *
511
+ * Candidate set (ordered within ecosystem by transitive coverage — first preferred):
512
+ * npm: package-lock.json, yarn.lock, pnpm-lock.yaml, pnpm-workspace.yaml, package.json
513
+ * pypi: requirements.txt
514
+ * cargo: Cargo.toml
515
+ * golang: go.sum, go.mod
516
+ *
517
+ * Selection: among files that exist, prefer the one with the most recent mtime.
518
+ * Ties (same mtime) resolved by the candidate list order above.
519
+ * Returns the basename of the chosen file, or null if no manifest is present.
520
+ */
521
+ async function autodetectManifest(cwd) {
522
+ const fs = await import('fs');
523
+ const path = await import('path');
524
+
525
+ const candidates = [
526
+ 'package-lock.json',
527
+ 'yarn.lock',
528
+ 'pnpm-lock.yaml',
529
+ 'pnpm-lock.yml',
530
+ 'pnpm-workspace.yaml',
531
+ 'pnpm-workspace.yml',
532
+ 'package.json',
533
+ 'requirements.txt',
534
+ 'Cargo.toml',
535
+ 'go.sum',
536
+ 'go.mod',
537
+ ];
538
+
539
+ const found = [];
540
+ for (let idx = 0; idx < candidates.length; idx++) {
541
+ const name = candidates[idx];
542
+ const full = path.join(cwd, name);
543
+ try {
544
+ const stat = fs.statSync(full);
545
+ if (stat.isFile()) found.push({ name, mtime: stat.mtimeMs, order: idx });
546
+ } catch {}
547
+ }
548
+
549
+ if (found.length === 0) return null;
550
+
551
+ // Sort: newest mtime first; ties resolved by candidate-list order.
552
+ found.sort((a, b) => (b.mtime - a.mtime) || (a.order - b.order));
553
+ return found[0].name;
554
+ }
555
+
405
556
  /**
406
557
  * Audit packages in batches of 20, in parallel.
407
- * Returns all results sorted by risk score (highest risk first).
408
558
  */
409
559
  async function auditBatched(packages, ecosystem, { onProgress } = {}) {
410
560
  const BATCH_SIZE = 20;
@@ -434,10 +584,10 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
434
584
 
435
585
  const all = results.flat();
436
586
 
437
- // Sort: CRITICAL first, then by score ascending (lower score = higher risk)
587
+ // Sort: CRITICAL first, then by score ascending
438
588
  all.sort((a, b) => {
439
- const aCrit = a.riskFlags?.includes('CRITICAL') ? 1 : 0;
440
- const bCrit = b.riskFlags?.includes('CRITICAL') ? 1 : 0;
589
+ const aCrit = hasCritical(a.riskFlags) ? 1 : 0;
590
+ const bCrit = hasCritical(b.riskFlags) ? 1 : 0;
441
591
  if (aCrit !== bCrit) return bCrit - aCrit;
442
592
  return (a.score || 100) - (b.score || 100);
443
593
  });
@@ -445,10 +595,25 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
445
595
  return all;
446
596
  }
447
597
 
598
+ /** Parse --fail-on=<level>. Returns one of 'critical' | 'risky' | 'none'. */
599
+ function parseFailOn(raw) {
600
+ const v = String(raw || '').toLowerCase();
601
+ if (v === 'critical' || v === 'risky' || v === 'none') return v;
602
+ throw new Error(`Invalid --fail-on value: '${raw}'. Expected: critical, risky, or none.`);
603
+ }
604
+
605
+ /** Decide exit code given results + fail-on threshold. */
606
+ function shouldFail(results, failOn) {
607
+ if (failOn === 'none') return false;
608
+ if (failOn === 'critical') return results.some(r => hasCritical(r.riskFlags));
609
+ if (failOn === 'risky') return results.some(r => hasCritical(r.riskFlags) || (typeof r.score === 'number' && r.score < 40));
610
+ return false;
611
+ }
612
+
448
613
  async function main() {
449
614
  const args = process.argv.slice(2);
450
615
 
451
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
616
+ if (args.includes('--help') || args.includes('-h')) {
452
617
  printHelp();
453
618
  process.exit(0);
454
619
  }
@@ -459,6 +624,8 @@ async function main() {
459
624
  let isLockfile = false;
460
625
  let totalInFile = 0;
461
626
  let jsonOutput = false;
627
+ // null means "default later" — depends on output mode and CI env.
628
+ let failOn = null;
462
629
 
463
630
  let i = 0;
464
631
  while (i < args.length) {
@@ -468,6 +635,16 @@ async function main() {
468
635
  else if (a === '--cargo') { ecosystem = 'cargo'; i++; }
469
636
  else if (a === '--golang' || a === '--go') { ecosystem = 'golang'; i++; }
470
637
  else if (a === '--json') { jsonOutput = true; i++; }
638
+ else if (a.startsWith('--fail-on=')) {
639
+ try { failOn = parseFailOn(a.slice('--fail-on='.length)); }
640
+ catch (err) { console.error(err.message); process.exit(2); }
641
+ i++;
642
+ }
643
+ else if (a === '--fail-on') {
644
+ try { failOn = parseFailOn(args[++i]); }
645
+ catch (err) { console.error(err.message); process.exit(2); }
646
+ i++;
647
+ }
471
648
  else if (a === '--file' || a === '-f') {
472
649
  filePath = args[++i];
473
650
  i++;
@@ -479,6 +656,20 @@ async function main() {
479
656
  else { packages.push(a); i++; }
480
657
  }
481
658
 
659
+ // Zero-arg auto-detect: if no positional packages and no --file, look for a manifest in cwd.
660
+ if (!filePath && packages.length === 0) {
661
+ const detected = await autodetectManifest(process.cwd());
662
+ if (detected) {
663
+ filePath = detected;
664
+ if (!jsonOutput) console.log(clr(c.dim, `Auto-detected manifest: ${detected}`));
665
+ } else {
666
+ // No positional packages, no --file, and no manifest in cwd → print help.
667
+ // This preserves the prior "bare invocation" UX rather than failing silently.
668
+ printHelp();
669
+ process.exit(0);
670
+ }
671
+ }
672
+
482
673
  if (filePath) {
483
674
  try {
484
675
  const result = await readPackagesFromFile(filePath);
@@ -498,12 +689,23 @@ async function main() {
498
689
  process.exit(1);
499
690
  }
500
691
 
692
+ // Resolve fail-on default.
693
+ // - User passed --fail-on=X → use X (already set).
694
+ // - CI env (CI=true or =1) → 'critical' (hard gate by default in CI).
695
+ // - --json output (no CI) → 'critical' (preserves v1.7.x behavior).
696
+ // - interactive table output → 'none' (backward-compatible for casual users).
697
+ if (failOn === null) {
698
+ const ciEnv = process.env.CI;
699
+ const inCI = ciEnv === 'true' || ciEnv === '1';
700
+ if (inCI || jsonOutput) failOn = 'critical';
701
+ else failOn = 'none';
702
+ }
703
+
501
704
  const t0 = Date.now();
502
705
 
503
706
  let allResults;
504
707
 
505
708
  if (packages.length <= 20) {
506
- // Single batch — existing behavior
507
709
  if (!jsonOutput) process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
508
710
 
509
711
  try {
@@ -527,7 +729,6 @@ async function main() {
527
729
  if (!jsonOutput) process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
528
730
 
529
731
  } else {
530
- // Multi-batch for lock files
531
732
  const batches = Math.ceil(packages.length / 20);
532
733
  if (!jsonOutput) process.stdout.write(clr(c.dim, `Scanning ${packages.length} packages (${batches} batches in parallel)...`));
533
734
 
@@ -550,28 +751,34 @@ async function main() {
550
751
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
551
752
  if (!jsonOutput) process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
552
753
 
553
- // JSON output: emit all results with summary
554
754
  if (jsonOutput) {
555
- const criticalCount = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
755
+ const criticalCount = allResults.filter(r => hasCritical(r.riskFlags)).length;
756
+ const provenanceCount = allResults.filter(r => r.hasProvenance).length;
556
757
  console.log(JSON.stringify({
557
758
  totalScanned: allResults.length,
558
759
  criticalCount,
760
+ provenanceCount,
761
+ failOn,
559
762
  results: allResults,
560
763
  }, null, 2));
561
- process.exit(criticalCount > 0 ? 1 : 0);
764
+ process.exit(shouldFail(allResults, failOn) ? 1 : 0);
562
765
  }
563
766
 
564
- // For lock files: show top 25 highest-risk packages
767
+ // Lock files: show top 25 highest-risk
565
768
  const MAX_DISPLAY = 25;
566
769
  const displayed = allResults.slice(0, MAX_DISPLAY);
567
- const criticalTotal = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
770
+ const criticalTotal = allResults.filter(r => hasCritical(r.riskFlags)).length;
568
771
  printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
772
+ if (shouldFail(allResults, failOn)) {
773
+ console.error(clr(c.red + c.bold, `\n✗ --fail-on=${failOn} threshold met. Exit 1.`));
774
+ process.exit(1);
775
+ }
569
776
  return;
570
777
  }
571
778
 
572
779
  if (!allResults || allResults.length === 0) {
573
780
  if (jsonOutput) {
574
- console.log(JSON.stringify({ totalScanned: 0, criticalCount: 0, results: [] }, null, 2));
781
+ console.log(JSON.stringify({ totalScanned: 0, criticalCount: 0, provenanceCount: 0, failOn, results: [] }, null, 2));
575
782
  } else {
576
783
  console.log('No results returned. Check package names and try again.');
577
784
  }
@@ -579,16 +786,23 @@ async function main() {
579
786
  }
580
787
 
581
788
  if (jsonOutput) {
582
- const criticalCount = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
789
+ const criticalCount = allResults.filter(r => hasCritical(r.riskFlags)).length;
790
+ const provenanceCount = allResults.filter(r => r.hasProvenance).length;
583
791
  console.log(JSON.stringify({
584
792
  totalScanned: allResults.length,
585
793
  criticalCount,
794
+ provenanceCount,
795
+ failOn,
586
796
  results: allResults,
587
797
  }, null, 2));
588
- process.exit(criticalCount > 0 ? 1 : 0);
798
+ process.exit(shouldFail(allResults, failOn) ? 1 : 0);
589
799
  }
590
800
 
591
801
  printTable(allResults);
802
+ if (shouldFail(allResults, failOn)) {
803
+ console.error(clr(c.red + c.bold, `✗ --fail-on=${failOn} threshold met. Exit 1.`));
804
+ process.exit(1);
805
+ }
592
806
  }
593
807
 
594
808
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Supply chain risk scorer for npm, PyPI, Cargo, and Go packages — behavioral signals that can't be faked",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,9 @@
28
28
  "behavioral",
29
29
  "commitment",
30
30
  "maintainer",
31
- "publisher"
31
+ "publisher",
32
+ "provenance",
33
+ "trusted-publishing"
32
34
  ],
33
35
  "author": "piiiico",
34
36
  "license": "MIT",