@xagents_in/unmask 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cappybara contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # UNMASK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ See what actually shipped.
4
+
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.
9
+
10
+ Built for **Problem 04: Published Package vs Source Code Integrity Analyzer** at
11
+ Hacktoberfest 2026, Hack Day Sonipat. See [HACKATHON.md](HACKATHON.md).
12
+
13
+ ## Status
14
+
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 intended npm name is `@xagents_in/unmask`;
18
+ version `0.1.0` is configured for public npm publication. The default service is
19
+ `https://unmask-386156642190.asia-south1.run.app`. Publication, public access and final
20
+ model/runtime acceptance must still be verified; this configuration is not a
21
+ released-artifact claim.
22
+
23
+ The analyzer reads package contents as data. It does not install the inspected
24
+ package, run its scripts, follow archive links, or reproduce its build.
25
+ Source selection is a claim about correspondence, not proof of publisher trust.
26
+
27
+ ## Run locally
28
+
29
+ Use Node.js 24.22.2, pinned in `.nvmrc`:
30
+
31
+ ```bash
32
+ npm ci --ignore-scripts
33
+ npm run build
34
+ npm test
35
+ npm run check
36
+ npm run check:package
37
+ ```
38
+
39
+ Check an exact public npm version through your configured UNMASK service:
40
+
41
+ ```bash
42
+ node dist/cli/main.mjs check chalk@6.0.1 --out /tmp/unmask-chalk-report
43
+ ```
44
+
45
+ Choose a fresh output directory for each check. The command writes `report.json`
46
+ and `report.html` and refuses to replace existing report files. Public-package
47
+ checks send only the exact package spec to the hosted service; the service owns
48
+ acquisition and model policy. `UNMASK_SERVER_URL` overrides the default service.
49
+ It must be an HTTPS origin, or a loopback HTTP origin for local testing. Hosted
50
+ failure never falls back to local acquisition.
51
+ See [CLI configuration and data flow](docs/cli.md).
52
+
53
+ To analyze local archives without network acquisition:
54
+
55
+ ```bash
56
+ 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
57
+ ```
58
+
59
+ `--source-dir ''` explicitly selects the repository root. For a monorepo, supply
60
+ the package's directory relative to that root. Omit all source options for an
61
+ artifact-only report. An optional `--claim name@version` checks an explicit
62
+ identity claim against the artifact manifest.
63
+
64
+ Exit codes: `0` means analysis completed; `2` means analysis was incomplete or
65
+ input acquisition failed; `3` means a default review-policy finding; `1` means an
66
+ internal error. Findings remain visible when incomplete analysis takes priority.
67
+ Exit `0` never means a package is safe.
68
+
69
+ Start the website locally:
70
+
71
+ ```bash
72
+ UNMASK_MODEL=off HOST=127.0.0.1 PORT=3000 node dist/server/main.mjs
73
+ ```
74
+
75
+ Visit `http://127.0.0.1:3000`. The website accepts exact public npm versions,
76
+ queues analysis, and serves the same report format as the CLI. It accepts no
77
+ uploads. Its development worker limits and temporary-file cleanup do not
78
+ constitute an operating-system security sandbox.
79
+
80
+ ## Inspection limits
81
+
82
+ - File correspondence uses hashes and bounded byte comparisons. Transpilation,
83
+ minification, sourcemaps and declared output locations do not prove equivalence.
84
+ - Syntax cues use a bounded JavaScript parser. TypeScript, JSX, unsupported syntax,
85
+ dynamic behavior and unretained content remain uninspected.
86
+ - Literal novelty means only "not found in inspected source". Missing or
87
+ insufficient source cannot establish full-source absence.
88
+ - Ambiguous source-package selection is incomplete. For example, multiple
89
+ same-name manifests in Axios source require an explicit local source directory;
90
+ the website currently has no such override.
91
+ - This candidate covers npm only. PyPI and continuous release monitoring are
92
+ not implemented.
93
+
94
+ ## Models and releases
95
+
96
+ Model output is advisory and cannot change deterministic policy findings. The
97
+ adapter has recorded-transport tests, but real endpoint availability, account
98
+ quotas and usefulness still require validation. Hosted CLI checks follow the
99
+ service's published model/data disclosure and never read a local provider key.
100
+ Only offline local checks use `--allow-model`; without that flag no local model
101
+ client is constructed. The flag alone does not configure an offline provider.
102
+ See [HACKATHON.md](HACKATHON.md) for the exact candidate and remaining AI gate.
103
+
104
+ `npm test` runs product and release-tooling tests, including a packed tarball
105
+ installed into a temporary directory and invoked through its real CLI binary.
106
+ `npm run check` checks formatting; it is not static type checking. Product tests
107
+ and local HTTP checks do not establish deployment readiness.
108
+
109
+ Release publication is enabled for the `0.1.0` candidate, subject to CI and runtime
110
+ release gates. The flow builds an immutable
111
+ versioned artifact in CI, then a deployer selects that release separately.
112
+ See [release prerequisites](docs/releases.md). npm access is not required for
113
+ local builds or testing.
114
+
115
+ ## Contributing
116
+
117
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for review, test and comment conventions.
118
+ Every remote change after the initial event commit goes through a PR. Repository
119
+ permissions and code-owner enforcement must be verified on GitHub; the
120
+ CODEOWNERS file alone does not enforce them.
121
+
122
+ ## License
123
+
124
+ [MIT](LICENSE).
@@ -0,0 +1,251 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { parsePackageSpec } from "../core/acquire.mjs";
3
+ import { LocalInputError } from "../core/pipeline.mjs";
4
+ import { validateReport } from "../core/report.mjs";
5
+
6
+ const DEFAULT_SERVER_URL = "https://unmask-386156642190.asia-south1.run.app";
7
+ const defaults = Object.freeze({
8
+ totalTimeoutMs: 600000,
9
+ requestTimeoutMs: 15000,
10
+ maxBodyBytes: 64 * 1024 * 1024,
11
+ pollMs: 3000,
12
+ });
13
+ const failure = (code, message) => new LocalInputError(code, message);
14
+ function serverOrigin(value) {
15
+ if (!value)
16
+ throw failure(
17
+ "hosted-configuration",
18
+ "UNMASK_SERVER_URL must be a nonempty service origin.",
19
+ );
20
+ let url;
21
+ try {
22
+ url = new URL(value);
23
+ } catch {
24
+ throw failure(
25
+ "hosted-configuration",
26
+ "UNMASK_SERVER_URL must be a service origin.",
27
+ );
28
+ }
29
+ if (
30
+ typeof value !== "string" ||
31
+ value.length > 2048 ||
32
+ url.username ||
33
+ url.password ||
34
+ url.search ||
35
+ url.hash ||
36
+ url.pathname !== "/" ||
37
+ !(
38
+ url.protocol === "https:" ||
39
+ (url.protocol === "http:" &&
40
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
41
+ )
42
+ )
43
+ throw failure(
44
+ "hosted-configuration",
45
+ "Use an HTTPS service origin, or loopback HTTP for local testing, without credentials, path, query or fragment.",
46
+ );
47
+ return url.origin;
48
+ }
49
+ async function discard(response) {
50
+ try {
51
+ await response.body?.cancel();
52
+ } catch {}
53
+ }
54
+ async function readJson(response, maxBytes) {
55
+ if (
56
+ !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
57
+ response.headers.get("content-type") ?? "",
58
+ )
59
+ )
60
+ throw failure("hosted-response", "The hosted service did not return JSON.");
61
+ const declared = response.headers.get("content-length");
62
+ if (
63
+ declared !== null &&
64
+ (!/^\d+$/.test(declared) || Number(declared) > maxBytes)
65
+ )
66
+ throw failure(
67
+ "hosted-response",
68
+ "The hosted response exceeds the body limit.",
69
+ );
70
+ let size = 0;
71
+ const chunks = [];
72
+ for await (const chunk of response.body ?? []) {
73
+ size += chunk.byteLength;
74
+ if (size > maxBytes)
75
+ throw failure(
76
+ "hosted-response",
77
+ "The hosted response exceeds the body limit.",
78
+ );
79
+ chunks.push(chunk);
80
+ }
81
+ try {
82
+ return JSON.parse(
83
+ new TextDecoder("utf-8", { fatal: true }).decode(
84
+ Buffer.concat(chunks, size),
85
+ ),
86
+ );
87
+ } catch {
88
+ throw failure("hosted-response", "The hosted response is not valid JSON.");
89
+ }
90
+ }
91
+ export async function checkHosted(
92
+ spec,
93
+ {
94
+ serverUrl = DEFAULT_SERVER_URL,
95
+ fetchImpl = globalThis.fetch,
96
+ signal,
97
+ limits: overrides = {},
98
+ } = {},
99
+ ) {
100
+ const claim = parsePackageSpec(spec);
101
+ const origin = serverOrigin(serverUrl);
102
+ const limits = { ...defaults, ...overrides };
103
+ if (
104
+ Object.keys(limits).some(
105
+ (key) =>
106
+ !Object.hasOwn(defaults, key) ||
107
+ !Number.isSafeInteger(limits[key]) ||
108
+ limits[key] < 1 ||
109
+ limits[key] > defaults[key],
110
+ )
111
+ )
112
+ throw failure(
113
+ "hosted-configuration",
114
+ "Hosted transport limits are invalid.",
115
+ );
116
+ const controller = new AbortController();
117
+ const totalTimer = setTimeout(
118
+ () => controller.abort(),
119
+ limits.totalTimeoutMs,
120
+ );
121
+ const lifetime = signal
122
+ ? AbortSignal.any([signal, controller.signal])
123
+ : controller.signal;
124
+ async function request(url, options, consume) {
125
+ const attempt = new AbortController();
126
+ const timer = setTimeout(() => attempt.abort(), limits.requestTimeoutMs);
127
+ let response;
128
+ try {
129
+ response = await fetchImpl(url, {
130
+ ...options,
131
+ redirect: "manual",
132
+ credentials: "omit",
133
+ referrerPolicy: "no-referrer",
134
+ signal: AbortSignal.any([lifetime, attempt.signal]),
135
+ });
136
+ if (
137
+ response.redirected ||
138
+ (response.url && new URL(response.url).origin !== origin)
139
+ )
140
+ throw failure(
141
+ "hosted-response",
142
+ "The hosted service returned an unexpected redirect.",
143
+ );
144
+ return await consume(response);
145
+ } finally {
146
+ clearTimeout(timer);
147
+ if (response) await discard(response);
148
+ }
149
+ }
150
+ try {
151
+ const reportUrl = await request(
152
+ origin + "/check",
153
+ {
154
+ method: "POST",
155
+ headers: {
156
+ "content-type": "application/x-www-form-urlencoded",
157
+ accept: "application/json",
158
+ },
159
+ body: new URLSearchParams({ package: spec }),
160
+ },
161
+ async (response) => {
162
+ if (response.status !== 303)
163
+ throw failure(
164
+ "hosted-submit-failed",
165
+ `The hosted service did not accept the check (HTTP ${response.status}). No local fallback was attempted.`,
166
+ );
167
+ const location = response.headers.get("location");
168
+ let url;
169
+ try {
170
+ url = new URL(location, origin);
171
+ } catch {}
172
+ if (
173
+ !location ||
174
+ location.length > 2048 ||
175
+ !url ||
176
+ url.origin !== origin ||
177
+ url.username ||
178
+ url.password ||
179
+ url.search ||
180
+ url.hash ||
181
+ !/^\/r\/[a-f0-9]{32}$/.test(url.pathname)
182
+ )
183
+ throw failure(
184
+ "hosted-response",
185
+ "The hosted service returned an invalid report location.",
186
+ );
187
+ return url.href + ".json";
188
+ },
189
+ );
190
+ const id = new URL(reportUrl).pathname.slice(3, -5);
191
+ for (;;) {
192
+ const result = await request(
193
+ reportUrl,
194
+ { headers: { accept: "application/json" } },
195
+ async (response) => {
196
+ if (![200, 202].includes(response.status))
197
+ throw failure(
198
+ "hosted-report-failed",
199
+ `The hosted report is unavailable (HTTP ${response.status}). No local fallback was attempted.`,
200
+ );
201
+ const value = await readJson(response, limits.maxBodyBytes);
202
+ if (response.status === 202) {
203
+ if (
204
+ value?.id !== id ||
205
+ !["queued", "running"].includes(value.status)
206
+ )
207
+ throw failure(
208
+ "hosted-response",
209
+ "The hosted service returned an invalid pending report.",
210
+ );
211
+ return null;
212
+ }
213
+ try {
214
+ validateReport(value);
215
+ if (
216
+ value.inputs.claim?.name !== claim.name ||
217
+ value.inputs.claim?.version !== claim.version ||
218
+ ![0, 2, 3].includes(value.policy.exitCode)
219
+ )
220
+ throw new TypeError();
221
+ } catch {
222
+ throw failure(
223
+ "hosted-report-invalid",
224
+ "The hosted report failed schema, identity or deterministic policy validation.",
225
+ );
226
+ }
227
+ return value;
228
+ },
229
+ );
230
+ if (result) return result;
231
+ await delay(limits.pollMs, undefined, { signal: lifetime });
232
+ }
233
+ } catch (error) {
234
+ if (error instanceof LocalInputError) throw error;
235
+ if (
236
+ lifetime.aborted ||
237
+ error?.name === "AbortError" ||
238
+ error?.name === "TimeoutError"
239
+ )
240
+ throw failure(
241
+ "hosted-timeout",
242
+ "The hosted check timed out or was cancelled. No local fallback was attempted.",
243
+ );
244
+ throw failure(
245
+ "hosted-unavailable",
246
+ "The hosted service could not be reached or its response could not be read. No local fallback was attempted.",
247
+ );
248
+ } finally {
249
+ clearTimeout(totalTimer);
250
+ }
251
+ }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, open, unlink } from "node:fs/promises";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { analyzeLocal, LocalInputError } from "../core/pipeline.mjs";
7
+ import { renderReport } from "../viewer/render.mjs";
8
+ import { parsePackageSpec, AcquisitionError } from "../core/acquire.mjs";
9
+ import { addConfiguredModel } from "../core/online.mjs";
10
+ import { modelClientFromEnv } from "../model/config.mjs";
11
+ import { checkHosted } from "./hosted.mjs";
12
+
13
+ const names = {
14
+ "--tarball": "tarballPath",
15
+ "--source-archive": "sourceArchivePath",
16
+ "--commit": "commit",
17
+ "--source-root": "sourceRoot",
18
+ "--source-dir": "sourceDir",
19
+ "--cache-dir": "cacheDir",
20
+ "--out": "out",
21
+ "--claim": "claim",
22
+ };
23
+ function exactClaim(spec) {
24
+ try {
25
+ return parsePackageSpec(spec);
26
+ } catch (error) {
27
+ if (error instanceof AcquisitionError)
28
+ throw new LocalInputError(error.code, error.message);
29
+ throw error;
30
+ }
31
+ }
32
+ function parse(argv) {
33
+ if (argv[0] !== "check")
34
+ throw new LocalInputError(
35
+ "usage",
36
+ "Use check name@version or an explicit local tarball with --offline",
37
+ );
38
+ const options = {};
39
+ let start = 1;
40
+ if (argv[1] && !argv[1].startsWith("--")) {
41
+ exactClaim(argv[1]);
42
+ options.registrySpec = argv[1];
43
+ start = 2;
44
+ }
45
+ const seen = new Set();
46
+ for (let index = start; index < argv.length; index++) {
47
+ const key = argv[index];
48
+ if (seen.has(key))
49
+ throw new LocalInputError(
50
+ "duplicate-option",
51
+ "Each option may be supplied only once",
52
+ );
53
+ seen.add(key);
54
+ if (key === "--offline") {
55
+ options.offline = true;
56
+ continue;
57
+ }
58
+ if (key === "--allow-model") {
59
+ options.allowModel = true;
60
+ continue;
61
+ }
62
+ if (!Object.hasOwn(names, key))
63
+ throw new LocalInputError(
64
+ "unknown-option",
65
+ "Only the documented check options are supported",
66
+ );
67
+ const value = argv[++index];
68
+ if (
69
+ value === undefined ||
70
+ value.startsWith("--") ||
71
+ (!value && key !== "--source-dir")
72
+ )
73
+ throw new LocalInputError(
74
+ "missing-value",
75
+ "An option is missing its value",
76
+ );
77
+ options[names[key]] = value;
78
+ }
79
+ if (!options.out)
80
+ throw new LocalInputError("usage", "Check requires --out DIR");
81
+ if (options.registrySpec) {
82
+ if (
83
+ options.offline ||
84
+ options.allowModel ||
85
+ [
86
+ "tarballPath",
87
+ "sourceArchivePath",
88
+ "commit",
89
+ "sourceRoot",
90
+ "sourceDir",
91
+ "claim",
92
+ ].some((key) => Object.hasOwn(options, key))
93
+ )
94
+ throw new LocalInputError(
95
+ "conflicting-mode",
96
+ "Hosted checks cannot be combined with local, offline or --allow-model options",
97
+ );
98
+ } else if (!options.offline)
99
+ throw new LocalInputError(
100
+ "usage",
101
+ "Local tarball check requires --offline",
102
+ );
103
+ if (!options.registrySpec && !options.cacheDir)
104
+ throw new LocalInputError(
105
+ "usage",
106
+ "Offline check requires --cache-dir DIR and --out DIR",
107
+ );
108
+ if (options.claim !== undefined) options.claim = exactClaim(options.claim);
109
+ return options;
110
+ }
111
+ async function writeReports(directory, report) {
112
+ const json = JSON.stringify(report, null, 2) + "\n";
113
+ const html = renderReport(report);
114
+ await mkdir(directory, { recursive: true, mode: 0o700 });
115
+ const created = [];
116
+ const handles = [];
117
+ try {
118
+ for (const filename of ["report.json", "report.html"]) {
119
+ const destination = path.join(directory, filename);
120
+ const handle = await open(destination, "wx", 0o600);
121
+ handles.push(handle);
122
+ created.push(destination);
123
+ }
124
+ await handles[0].writeFile(json);
125
+ await handles[1].writeFile(html);
126
+ } catch (error) {
127
+ await Promise.allSettled(handles.map((handle) => handle.close()));
128
+ handles.length = 0;
129
+ await Promise.allSettled(created.map((filename) => unlink(filename)));
130
+ if (typeof error.code === "string" && /^E[A-Z]+$/.test(error.code))
131
+ throw new LocalInputError(
132
+ "output-write-failed",
133
+ "Output requires a writable directory without existing report files",
134
+ );
135
+ throw error;
136
+ } finally {
137
+ await Promise.allSettled(handles.map((handle) => handle.close()));
138
+ }
139
+ }
140
+
141
+ async function configuredModel(allowModel, runtime) {
142
+ if (!allowModel) return undefined;
143
+ if (Object.hasOwn(runtime, "model")) return runtime.model ?? null;
144
+ try {
145
+ const factory = runtime.modelFactory ?? modelClientFromEnv;
146
+ const client = await factory(runtime.env ?? process.env);
147
+ if (client === undefined || client === null) return null;
148
+ if (typeof client.explain !== "function")
149
+ throw new TypeError("Invalid model client");
150
+ return (report) =>
151
+ client.explain(report, { allowLocal: true, signal: runtime.signal });
152
+ } catch {
153
+ process.stderr.write(
154
+ "model-configuration-invalid: Model configuration is invalid; deterministic analysis will continue.\n",
155
+ );
156
+ return async () => ({
157
+ status: "unavailable",
158
+ reason: "configuration-invalid",
159
+ });
160
+ }
161
+ }
162
+
163
+ export async function main(argv = process.argv.slice(2), runtime = {}) {
164
+ try {
165
+ const { out, offline, allowModel, registrySpec, ...options } = parse(argv);
166
+ let report;
167
+ if (registrySpec)
168
+ report = await checkHosted(registrySpec, {
169
+ serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
170
+ fetchImpl: runtime.fetchImpl,
171
+ signal: runtime.signal,
172
+ limits: runtime.hostedLimits,
173
+ });
174
+ else {
175
+ const model = await configuredModel(allowModel, runtime);
176
+ report = await analyzeLocal({ ...options, limits: runtime.limits });
177
+ if (allowModel) report = await addConfiguredModel(report, model);
178
+ if (!allowModel)
179
+ report = {
180
+ ...report,
181
+ model: { status: "not-sent", reason: "consent-required" },
182
+ };
183
+ }
184
+ await writeReports(out, report);
185
+ process.stdout.write(`Report written to ${path.resolve(out)}\n`);
186
+ return report.policy.exitCode;
187
+ } catch (error) {
188
+ if (error instanceof LocalInputError) {
189
+ process.stderr.write(`${error.code}: ${error.message}\n`);
190
+ return 2;
191
+ }
192
+ process.stderr.write(
193
+ "Internal analysis error; no completed result is claimed.\n",
194
+ );
195
+ return 1;
196
+ }
197
+ }
198
+
199
+ function isEntrypoint(filename) {
200
+ if (!filename) return false;
201
+ try {
202
+ return (
203
+ realpathSync(fileURLToPath(import.meta.url)) === realpathSync(filename)
204
+ );
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ if (isEntrypoint(process.argv[1])) process.exitCode = await main();