@xagents_in/unmask 0.1.2 → 0.1.3

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
@@ -1,31 +1,171 @@
1
+ <div align="center">
2
+
1
3
  # UNMASK
2
4
 
3
- See what actually shipped.
5
+ ### See what actually shipped.
6
+
7
+ **The code you review on GitHub is not the code you install from npm.**<br>
8
+ UNMASK compares the two, byte for byte, and shows the evidence for every difference.
9
+
10
+ [![npm](https://img.shields.io/npm/v/@xagents_in/unmask?color=dafa9c&labelColor=0f0f0f&label=npm)](https://www.npmjs.com/package/@xagents_in/unmask)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-dafa9c?labelColor=0f0f0f)](LICENSE)
12
+ [![Node 24.22.2](https://img.shields.io/badge/node-24.22.2-dafa9c?labelColor=0f0f0f)](.nvmrc)
13
+ [![Hacktoberfest 2026](https://img.shields.io/badge/Hacktoberfest%202026-Problem%2004-dafa9c?labelColor=0f0f0f)](HACKATHON.md)
14
+
15
+ [**Try the website**](https://unmask-web-386156642190.asia-south1.run.app) ·
16
+ [Analysis API](https://unmask-386156642190.asia-south1.run.app/version) ·
17
+ [npm package](https://www.npmjs.com/package/@xagents_in/unmask) ·
18
+ [Engineering docs](docs/README.md)
19
+
20
+ </div>
21
+
22
+ ---
23
+
24
+ ## The problem in 30 seconds
25
+
26
+ Developers review source code. Machines install the **published tarball**. Nothing
27
+ forces the two to match: npm checks the tarball's hash, never its source.
28
+
29
+ Attackers know this. Some of the worst supply-chain attacks hid their payload
30
+ **only in the published package**:
31
+
32
+ | Year | Package | What shipped that was never committed |
33
+ | ---- | ----------------------- | ------------------------------------------ |
34
+ | 2018 | `flatmap-stream 0.1.1` | A payload only in the minified npm file |
35
+ | 2018 | `eslint-scope 3.7.2` | A `postinstall` that stole `.npmrc` tokens |
36
+ | 2024 | `xz-utils 5.6.0` | A backdoor only in the release tarballs |
37
+ | 2025 | `@ctrl/tinycolor 4.1.1` | A `bundle.js` added by the Shai-Hulud worm |
38
+
39
+ Existing tools answer other questions. Vulnerability databases ask _"is this
40
+ version known bad?"_. Behaviour scanners ask _"does it look malicious?"_. npm
41
+ provenance asks _"where was it built?"_. **UNMASK asks: "what shipped that is not in
42
+ the source, and where exactly?"**
43
+
44
+ ## Try it in one minute
45
+
46
+ **On the web:** open the [website](https://unmask-web-386156642190.asia-south1.run.app),
47
+ type an exact release such as `chalk@6.0.1`, and press **check it**.
48
+
49
+ **From a terminal:**
50
+
51
+ ```bash
52
+ npx @xagents_in/unmask check chalk@6.0.1 --out ./unmask-report
53
+ ```
54
+
55
+ You get `report.json` and an escaped `report.html` in `./unmask-report`. Only the
56
+ package name and version leave your machine; the hosted service does the rest.
57
+
58
+ ## What a check returns
59
+
60
+ A real report for **`zod@4.6.7`**, from the live service:
61
+
62
+ | What UNMASK found | Result |
63
+ | ---------------------------------------- | ------------------------------------------------ |
64
+ | Published files compared with the source | **840**, against the commit the package claims |
65
+ | Byte-identical to the source | **335** |
66
+ | In the package but not in the source | **505**, mostly build output, each one listed |
67
+ | AI explanations | **3**, every quotation checked against the bytes |
68
+ | Policy result | Exit `0`: no review rule fired. Never "safe" |
69
+
70
+ Alongside the report, `/r/<id>.details.json` gives a package profile: declared
71
+ entry points, direct dependencies, install scripts, static capabilities,
72
+ provenance, coverage, and the [OSV](https://osv.dev) advisories for that exact
73
+ version. This view is merged on `main` but not yet on the live service.
4
74
 
5
- UNMASK compares an npm package's published bytes with a selected source archive.
6
- It produces a JSON report and an HTML evidence viewer: matching files, changed
7
- bytes, artifact-only files, uninspected content, and observations to review.
8
- A completed report is not a safety verdict.
75
+ Every file lands in exactly one state, and every claim points to a byte range in a
76
+ hashed file:
9
77
 
10
- Built for **Problem 04: Published Package vs Source Code Integrity Analyzer** at
11
- Hacktoberfest 2026, Hack Day Sonipat. See [HACKATHON.md](HACKATHON.md).
78
+ | State | Meaning |
79
+ | ---------------- | ------------------------------------------------------------- |
80
+ | `byte-identical` | Same size and SHA-256 as its source file |
81
+ | `changed` | A source twin exists, the bytes differ, and the span is shown |
82
+ | `artifact-only` | In the package, nowhere in the selected source |
83
+ | `not-compared` | UNMASK could not judge, and says why. Nothing is hidden |
12
84
 
13
- ## Status
85
+ ## How it works
14
86
 
15
- The local candidate includes an npm CLI, a shared analyzer, a website with a
16
- bounded job queue, and an optional hosted Gemma adapter. It uses plain ESM
17
- JavaScript on Node.js 24.22.2. The npm package is `@xagents_in/unmask`.
18
- Release `0.1.0` was published and verified; the current source candidate is
19
- `0.1.1`, whose release and deployment acceptance are pending. The default service is
20
- `https://unmask-386156642190.asia-south1.run.app`. See the dated
21
- [deployment evidence](docs/README.md) for observed runtime versions and commits;
22
- source configuration does not establish acceptance of a new release.
87
+ ```mermaid
88
+ flowchart TD
89
+ A["Exact release<br/>name@version"] --> B["Fetch the npm tarball<br/>and the claimed commit"]
90
+ B --> C["Unpack both as data<br/>nothing is run"]
91
+ C --> D["Compare every file<br/>byte for byte"]
92
+ D --> E["Read manifests<br/>and syntax cues"]
93
+ E --> F["Apply fixed rules<br/>exit 0 / 2 / 3"]
94
+ F --> G["Report: JSON and<br/>escaped HTML"]
95
+ F -. "only if something differs" .-> H["AI explains the evidence<br/>quotes checked"]
96
+ H -.-> G
97
+ ```
98
+
99
+ 1. **Name an exact release.** Tags, ranges and URLs are refused, so every result can
100
+ be reproduced.
101
+ 2. **Fetch both, run neither.** The tarball comes from `registry.npmjs.org` and the
102
+ source from GitHub at the full commit the package claims. No install script,
103
+ build or test ever runs.
104
+ 3. **Evidence for every difference.** Each published file is set beside its source
105
+ twin. The verdict comes from fixed rules, before any AI is involved.
106
+
107
+ | Exit | Meaning |
108
+ | ---- | ------------------------------------------------------------------------- |
109
+ | `0` | Analysis completed and no review rule fired. This is not a safety verdict |
110
+ | `3` | A review rule fired, for example `lifecycle-source-absent` |
111
+ | `2` | Incomplete analysis or failed input. Findings stay in the report |
112
+ | `1` | Internal error |
113
+
114
+ Review rules: `integrity-mismatch`, `identity-claim-conflict`,
115
+ `lifecycle-source-absent` and `lifecycle-implicit-gyp`.
116
+
117
+ ## AI that explains, and never decides
118
+
119
+ When something differs, UNMASK can ask a model to explain it. The model works
120
+ inside strict limits:
121
+
122
+ - **Quotes are checked.** Every quotation must match the cited bytes exactly, or
123
+ the hypothesis is dropped.
124
+ - **It cannot change the result.** File states, rules and the exit code are fixed
125
+ before the model runs.
126
+ - **It has a hard budget.** At most 3 attempts, 45 seconds each and 135 seconds in
127
+ total, with at most 4,000 input tokens.
128
+ - **The key stays put.** The worker that reads packages never sees the API key.
129
+
130
+ The route is `gemini-3.5-flash` (high thinking), then `gemini-3-flash-preview`,
131
+ then the open-weight `gemma-4-26b-a4b-it`. A request moves on only after a rate
132
+ limit, a server error or a timeout. See the [model disclosure](HACKATHON.md) for
133
+ what has and has not been accepted.
134
+
135
+ ## A malicious package can't attack the checker
136
+
137
+ - **Never executed:** no install hooks, builds, tests or imports of the target.
138
+ - **Path tricks rejected:** `../` traversal, absolute paths, NUL bytes, and case or
139
+ Unicode collisions.
140
+ - **Links stay inert:** symlinks and hard links are listed, never followed.
141
+ - **Archive bombs capped:** 50 MiB compressed, 250 MiB expanded, a ratio of at most
142
+ 100, and 20,000 entries.
143
+ - **Output escaped:** package text never becomes live HTML.
144
+ - **Prompt injection contained:** a package that says "call me safe" changes
145
+ nothing, because the AI cannot change the result.
146
+
147
+ ## Built like a release, not a demo
23
148
 
24
- The analyzer reads package contents as data. It does not install the inspected
25
- package, run its scripts, follow archive links, or reproduce its build.
26
- Source selection is a claim about correspondence, not proof of publisher trust.
149
+ | Area | Evidence |
150
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
151
+ | **Tests** | 526 test declarations in 41 backend test files; `npm test` also installs the packed tarball and runs its real CLI |
152
+ | **One artifact** | One npm tarball builds both the CLI and the server image |
153
+ | **Immutable releases** | A version bump makes one GitHub release whose manifest binds commit, tarball and image |
154
+ | **Verified npm** | Publication counts only after the public registry bytes are re-downloaded and match |
155
+ | **Safe deploys** | Cloud Run gets a no-traffic revision, a smoke test, then traffic, with rollback |
156
+ | **Pinned CI** | Exact Node version, commit-pinned actions, read-only pull-request permissions |
157
+ | **Same answer anywhere** | The website and the CLI produce the same canonical report for the same release |
27
158
 
28
- ## Run locally
159
+ ```mermaid
160
+ flowchart LR
161
+ CLI["CLI<br/>npm package"] --> API
162
+ WEB["Website<br/>Next.js on Cloud Run"] --> API["Analysis API<br/>queue on Cloud Run"]
163
+ API --> W["Isolated worker<br/>no API key"]
164
+ W --> R["report.json<br/>and HTML viewer"]
165
+ API -. "after the worker exits" .-> M["Model route"]
166
+ ```
167
+
168
+ ## Run it yourself
29
169
 
30
170
  Use Node.js 24.22.2, pinned in `.nvmrc`:
31
171
 
@@ -37,93 +177,87 @@ npm run check
37
177
  npm run check:package
38
178
  ```
39
179
 
40
- Check an exact public npm version through your configured UNMASK service:
180
+ **Check a published release** through the hosted service:
41
181
 
42
182
  ```bash
43
183
  node dist/cli/main.mjs check chalk@6.0.1 --out /tmp/unmask-chalk-report
44
184
  ```
45
185
 
46
- Choose a fresh output directory for each check. The command writes `report.json`
47
- and `report.html` and refuses to replace existing report files. Public-package
48
- checks send only the exact package spec to the hosted service; the service owns
49
- acquisition and model policy. `UNMASK_SERVER_URL` overrides the default service.
50
- It must be an HTTPS origin, or a loopback HTTP origin for local testing. Hosted
51
- failure never falls back to local acquisition.
52
- See [CLI configuration and data flow](docs/cli.md).
186
+ Choose a fresh output folder for each check; existing report files are never
187
+ replaced. `UNMASK_SERVER_URL` selects another service: an HTTPS origin, or a
188
+ loopback HTTP origin for local testing. A hosted failure never falls back to local
189
+ acquisition. See [CLI configuration and data flow](docs/cli.md).
53
190
 
54
- To analyze local archives without network acquisition:
191
+ **Check a local tarball offline**, for example a copy you suspect was tampered with:
55
192
 
56
193
  ```bash
57
194
  node dist/cli/main.mjs check --offline --tarball /path/to/package.tgz --source-archive /path/to/source.tar.gz --commit FULL_40_CHARACTER_LOWERCASE_COMMIT_SHA --source-root ARCHIVE_TOP_LEVEL_DIRECTORY --source-dir '' --cache-dir /tmp/unmask-local-cache --out /tmp/unmask-local-report
58
195
  ```
59
196
 
60
- `--source-dir ''` explicitly selects the repository root. For a monorepo, supply
61
- the package's directory relative to that root. Omit all source options for an
62
- artifact-only report. An optional `--claim name@version` checks an explicit
63
- identity claim against the artifact manifest.
64
-
65
- Exit codes: `0` means analysis completed; `2` means analysis was incomplete or
66
- input acquisition failed; `3` means a default review-policy finding; `1` means an
67
- internal error. Findings remain visible when incomplete analysis takes priority.
68
- Exit `0` never means a package is safe.
197
+ `--source-dir ''` selects the repository root; for a monorepo, give the package's
198
+ folder instead. Leave out all source options for an artifact-only report.
199
+ `--claim name@version` checks an identity claim against the package manifest.
200
+ Excerpts leave the machine only if you add `--allow-model` and configure a provider.
69
201
 
70
- Start the website locally:
202
+ **Run the website locally:**
71
203
 
72
204
  ```bash
73
205
  UNMASK_MODEL=off HOST=127.0.0.1 PORT=3000 node dist/server/main.mjs
74
206
  ```
75
207
 
76
- Visit `http://127.0.0.1:3000`. The website accepts exact public npm versions,
77
- queues analysis, and serves the same report format as the CLI. It accepts no
78
- uploads. Its development worker limits and temporary-file cleanup do not
79
- constitute an operating-system security sandbox.
80
-
81
- The Next.js frontend in [`web/`](web/README.md) can sit in front of that server.
82
- Start it with `UNMASK_API_URL=http://127.0.0.1:3000`; see its README.
83
-
84
- ## Inspection limits
85
-
86
- - File correspondence uses hashes and bounded byte comparisons. Transpilation,
87
- minification, sourcemaps and declared output locations do not prove equivalence.
88
- - Syntax cues use a bounded JavaScript parser. TypeScript, JSX, unsupported syntax,
89
- dynamic behavior and unretained content remain uninspected.
90
- - Literal novelty means only "not found in inspected source". Missing or
91
- insufficient source cannot establish full-source absence.
92
- - Ambiguous source-package selection is incomplete. For example, multiple
93
- same-name manifests in Axios source require an explicit local source directory;
94
- the website currently has no such override.
95
- - This candidate covers npm only. PyPI and continuous release monitoring are
96
- not implemented.
97
-
98
- ## Models and releases
99
-
100
- Model output is advisory and cannot change deterministic policy findings. The
101
- adapter has recorded-transport tests, but real endpoint availability, account
102
- quotas and usefulness still require validation. Hosted CLI checks follow the
103
- service's published model/data disclosure and never read a local provider key.
104
- Only offline local checks use `--allow-model`; without that flag no local model
105
- client is constructed. The flag alone does not configure an offline provider.
106
- See [HACKATHON.md](HACKATHON.md) for the exact candidate and remaining AI gate.
107
-
108
- `npm test` runs product and release-tooling tests, including a packed tarball
109
- installed into a temporary directory and invoked through its real CLI binary.
110
- `npm run check` checks formatting; it is not static type checking. Product tests
111
- and local HTTP checks do not establish deployment readiness.
112
-
113
- Release publication is enabled for the `0.1.1` source candidate, subject to CI
114
- and runtime release gates. A completed immutable release with verified npm
115
- publication triggers API deployment in the same workflow. Successful API
116
- verification then triggers website deployment for that exact release commit.
117
- See [release prerequisites](docs/releases.md). npm access is not required for
118
- local builds or testing.
208
+ Then visit `http://127.0.0.1:3000`. The Next.js frontend in [`web/`](web/README.md)
209
+ can sit in front of it with `UNMASK_API_URL=http://127.0.0.1:3000`.
210
+
211
+ ## What UNMASK does not claim
212
+
213
+ Being clear about limits is part of the product.
214
+
215
+ - **Matching code can still be malicious.** If the bad code is in the source too,
216
+ a comparison cannot catch it.
217
+ - **Build output is noisy.** Bundled and transpiled files have no source twin;
218
+ sourcemaps and declared output locations do not prove equivalence.
219
+ - **Syntax cues are bounded.** TypeScript, JSX, unsupported syntax and dynamic
220
+ behaviour stay uninspected. "Novel" means only "not found in the inspected
221
+ source".
222
+ - **Ambiguous sources stay incomplete.** If a repository has two manifests with the
223
+ package's name (axios does), the CLI needs `--source-dir`; the website has no
224
+ override yet.
225
+ - **The AI is advisory.** A checked quotation proves where the model looked, not
226
+ that its reasoning is right. Open-weight acceptance of the Gemma tier is still
227
+ pending.
228
+ - **npm only, for now.** PyPI and continuous release monitoring are not
229
+ implemented.
230
+
231
+ ## Read more
232
+
233
+ | Page | What it covers |
234
+ | ---------------------------------------------------- | --------------------------------------------------------- |
235
+ | [HACKATHON.md](HACKATHON.md) | Team, problem statement and the exact AI model disclosure |
236
+ | [Engineering docs](docs/README.md) | Live services, model evidence and the documentation map |
237
+ | [Architecture](docs/architecture.md) | Flows, process boundaries and job states |
238
+ | [Contracts](docs/contracts.md) | Inputs, report shape, exit policy and HTTP behaviour |
239
+ | [CLI](docs/cli.md) | Hosted and offline CLI configuration and data flow |
240
+ | [Releases](docs/releases.md) | Release gates and artifact verification |
241
+ | [Cloud Run deployment](docs/cloud-run-deployment.md) | How a release reaches production |
242
+ | [Website](web/README.md) | The Next.js site and its connection to the analyzer |
243
+
244
+ ## Team Cappybara
245
+
246
+ Built in one night at **Hacktoberfest 2026, Hack Day Sonipat**, for
247
+ **Problem 04: Published Package vs Source Code Integrity Analyzer**.
248
+
249
+ | Member | Focus |
250
+ | ------------------ | ---------------------------------------------- |
251
+ | Pranay Vishwakarma | Team lead, website |
252
+ | Vaiditya Tanwar | Analyzer, AI route, CI, release and deployment |
253
+ | Vishesh Rao | CLI and npm release |
254
+ | Sayan Bhattacharya | Project report, QA and documentation |
119
255
 
120
256
  ## Contributing
121
257
 
122
258
  See [CONTRIBUTING.md](CONTRIBUTING.md) for review, test and comment conventions.
123
- Every remote change after the initial event commit goes through a PR. Repository
124
- permissions and code-owner enforcement must be verified on GitHub; the
125
- CODEOWNERS file alone does not enforce them.
259
+ Every change after the initial event commit goes through a pull request.
126
260
 
127
261
  ## License
128
262
 
129
- [MIT](LICENSE).
263
+ [MIT](LICENSE)
@@ -0,0 +1,218 @@
1
+ import { parsePackageSpec } from "../core/acquire.mjs";
2
+ import { LocalInputError } from "../core/pipeline.mjs";
3
+ import { checkHosted } from "./hosted.mjs";
4
+
5
+ const failure = (code, message) => new LocalInputError(code, message);
6
+ const MAX_ITEMS = 25;
7
+ const MAX_INPUT_BYTES = 16384;
8
+ const terminalValue = (value) =>
9
+ JSON.stringify(value).replace(
10
+ /[\u007f-\u009f\u2028\u2029]/gu,
11
+ (character) =>
12
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
13
+ );
14
+
15
+ async function readInput(stdin, signal) {
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), 5000);
18
+ const lifetime = signal
19
+ ? AbortSignal.any([signal, controller.signal])
20
+ : controller.signal;
21
+ let onAbort;
22
+ try {
23
+ const aborted = new Promise((_resolve, reject) => {
24
+ onAbort = () =>
25
+ reject(
26
+ failure(
27
+ "batch-input-cancelled",
28
+ "Batch stdin timed out or was cancelled before EOF.",
29
+ ),
30
+ );
31
+ lifetime.addEventListener("abort", onAbort, { once: true });
32
+ if (lifetime.aborted) onAbort();
33
+ });
34
+ const reading = (async () => {
35
+ const chunks = [];
36
+ let size = 0;
37
+ for await (const chunk of stdin) {
38
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
39
+ if (!(bytes instanceof Uint8Array))
40
+ throw failure("batch-input", "Batch stdin must contain UTF-8 text.");
41
+ size += bytes.byteLength;
42
+ if (size > MAX_INPUT_BYTES)
43
+ throw failure("batch-input-limit", "Batch stdin exceeds 16 KiB.");
44
+ chunks.push(bytes);
45
+ }
46
+ try {
47
+ return new TextDecoder("utf-8", { fatal: true }).decode(
48
+ Buffer.concat(chunks, size),
49
+ );
50
+ } catch {
51
+ throw failure(
52
+ "batch-input",
53
+ "Batch stdin must contain valid UTF-8 text.",
54
+ );
55
+ }
56
+ })();
57
+ return await Promise.race([reading, aborted]);
58
+ } catch (error) {
59
+ if (error instanceof LocalInputError) throw error;
60
+ throw failure("batch-input", "Batch stdin could not be read.");
61
+ } finally {
62
+ clearTimeout(timer);
63
+ lifetime.removeEventListener("abort", onAbort);
64
+ if (lifetime.aborted) stdin.destroy?.();
65
+ }
66
+ }
67
+
68
+ async function inputs(argv, runtime) {
69
+ const specs = [];
70
+ const seen = new Set();
71
+ for (const arg of argv) {
72
+ if (arg.startsWith("--")) {
73
+ if (!["--stdin", "--json"].includes(arg) || seen.has(arg))
74
+ throw failure(
75
+ "usage",
76
+ "Use batch exact-spec... [--stdin] [--json], with each option once.",
77
+ );
78
+ seen.add(arg);
79
+ } else specs.push(arg);
80
+ }
81
+ if (specs.length > MAX_ITEMS || specs.some((spec) => spec.length > 512))
82
+ throw failure(
83
+ "batch-input-limit",
84
+ "Batch accepts at most 25 inputs of at most 512 characters each.",
85
+ );
86
+ if (seen.has("--stdin")) {
87
+ const text = await readInput(
88
+ runtime.stdin ?? process.stdin,
89
+ runtime.signal,
90
+ );
91
+ specs.push(...text.split(/\s+/u).filter(Boolean));
92
+ }
93
+ if (!specs.length)
94
+ throw failure(
95
+ "usage",
96
+ "Batch requires explicit exact specs from args or --stdin.",
97
+ );
98
+ if (specs.length > MAX_ITEMS || specs.some((spec) => spec.length > 512))
99
+ throw failure(
100
+ "batch-input-limit",
101
+ "Batch accepts at most 25 inputs of at most 512 characters each.",
102
+ );
103
+ return { specs, json: seen.has("--json") };
104
+ }
105
+
106
+ export async function runBatch(argv, runtime = {}) {
107
+ const { specs, json } = await inputs(argv, runtime);
108
+ const unique = new Map();
109
+ for (const [index, spec] of specs.entries()) {
110
+ if (!unique.has(spec)) unique.set(spec, { spec, inputIndexes: [] });
111
+ unique.get(spec).inputIndexes.push(index);
112
+ }
113
+ const controller = new AbortController();
114
+ const timer = setTimeout(() => controller.abort(), 600000);
115
+ const lifetime = runtime.signal
116
+ ? AbortSignal.any([runtime.signal, controller.signal])
117
+ : controller.signal;
118
+ const items = [];
119
+ let paused = false;
120
+ try {
121
+ for (const item of unique.values()) {
122
+ const result = { ...item, occurrences: item.inputIndexes.length };
123
+ try {
124
+ parsePackageSpec(item.spec);
125
+ } catch {
126
+ items.push({
127
+ ...result,
128
+ status: "error",
129
+ error: {
130
+ code: "invalid-package-spec",
131
+ message:
132
+ "Use an exact public npm package name@version; ranges, tags and URLs are unsupported.",
133
+ },
134
+ });
135
+ continue;
136
+ }
137
+ if (lifetime.aborted || paused) {
138
+ items.push({
139
+ ...result,
140
+ status: "not-checked",
141
+ error: {
142
+ code: lifetime.aborted
143
+ ? "batch-cancelled"
144
+ : "batch-submission-paused",
145
+ message: "No hosted request was submitted for this item.",
146
+ },
147
+ });
148
+ continue;
149
+ }
150
+ try {
151
+ const report = await checkHosted(item.spec, {
152
+ serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
153
+ fetchImpl: runtime.fetchImpl,
154
+ signal: lifetime,
155
+ limits: runtime.hostedLimits,
156
+ });
157
+ items.push({
158
+ ...result,
159
+ status: "completed",
160
+ policyExitCode: report.policy.exitCode,
161
+ findingCount: report.policy.findings.length,
162
+ model: {
163
+ status: String(report.model?.status ?? "unavailable").slice(0, 80),
164
+ },
165
+ });
166
+ } catch (error) {
167
+ const known = error instanceof LocalInputError;
168
+ items.push({
169
+ ...result,
170
+ status: "error",
171
+ error: {
172
+ code: known ? error.code : "batch-check-failed",
173
+ message: known
174
+ ? error.message
175
+ : "Check failed; no completed result is claimed.",
176
+ },
177
+ });
178
+ if (
179
+ ["hosted-submit-failed", "hosted-report-failed"].includes(error?.code)
180
+ )
181
+ paused = true;
182
+ }
183
+ }
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
187
+ const result = {
188
+ schemaVersion: "1",
189
+ requestedCount: specs.length,
190
+ uniqueCount: items.length,
191
+ duplicateCount: specs.length - items.length,
192
+ completedCount: items.filter((item) => item.status === "completed").length,
193
+ errorCount: items.filter((item) => item.status === "error").length,
194
+ notCheckedCount: items.filter((item) => item.status === "not-checked")
195
+ .length,
196
+ items,
197
+ };
198
+ const exitCode =
199
+ result.errorCount || result.notCheckedCount
200
+ ? 2
201
+ : items.some((item) => item.policyExitCode === 3)
202
+ ? 3
203
+ : items.some((item) => item.policyExitCode === 2)
204
+ ? 2
205
+ : 0;
206
+ const stdout = runtime.stdout ?? process.stdout;
207
+ if (json) stdout.write(JSON.stringify({ ...result, exitCode }) + "\n");
208
+ else {
209
+ for (const item of items)
210
+ stdout.write(
211
+ `${terminalValue(item.spec)} x${item.occurrences}: ${item.status}${item.status === "completed" ? `; policy exit ${item.policyExitCode}; findings ${item.findingCount}; AI status ${terminalValue(item.model.status)}` : `; ${item.error.code}`}\n`,
212
+ );
213
+ stdout.write(
214
+ `Batch: ${result.completedCount} completed, ${result.errorCount} errors, ${result.notCheckedCount} not checked; ${result.duplicateCount} duplicate inputs coalesced.\nNo findings do not establish safety.\n`,
215
+ );
216
+ }
217
+ return exitCode;
218
+ }
package/dist/cli/main.mjs CHANGED
@@ -9,6 +9,8 @@ import { parsePackageSpec, AcquisitionError } from "../core/acquire.mjs";
9
9
  import { addConfiguredModel } from "../core/online.mjs";
10
10
  import { modelClientFromEnv } from "../model/config.mjs";
11
11
  import { checkHosted } from "./hosted.mjs";
12
+ import { runScan } from "./scan.mjs";
13
+ import { runBatch } from "./batch.mjs";
12
14
 
13
15
  const names = {
14
16
  "--tarball": "tarballPath",
@@ -162,6 +164,8 @@ async function configuredModel(allowModel, runtime) {
162
164
 
163
165
  export async function main(argv = process.argv.slice(2), runtime = {}) {
164
166
  try {
167
+ if (argv[0] === "scan") return await runScan(argv.slice(1), runtime);
168
+ if (argv[0] === "batch") return await runBatch(argv.slice(1), runtime);
165
169
  const { out, offline, allowModel, registrySpec, ...options } = parse(argv);
166
170
  let report;
167
171
  if (registrySpec)