superdev-cli 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/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Complete-history confidentiality scanner. Where scan.mjs covers the working
|
|
4
|
+
* tree and the git index, this covers ALL of git history: every reachable object
|
|
5
|
+
* (blob, commit, annotated tag), every historical path a blob ever had, commit
|
|
6
|
+
* and tag MESSAGES, and symlink target blobs (a symlink is a blob whose content
|
|
7
|
+
* is its target string, so byte-scanning every blob covers it).
|
|
8
|
+
*
|
|
9
|
+
* Guarantees:
|
|
10
|
+
* - Never silently skips: an unreadable/oversize object yields a SCAN-UNREADABLE
|
|
11
|
+
* P0 finding (fail closed), never a silent pass.
|
|
12
|
+
* - Reuses the exact scanner rules and external denylist (no policy drift).
|
|
13
|
+
* - Byte-scans binary blobs (denylist / abs-home path / non-allowlisted email /
|
|
14
|
+
* key markers survive an embedded NUL); text blobs get the full content scan.
|
|
15
|
+
* - History is immutable: a historical binary-in-a-text-extension blob (e.g. the
|
|
16
|
+
* pre-fix ingest.mjs) is STILL byte-scanned; only its BIN-TEXT *classification*
|
|
17
|
+
* finding may be suppressed via an exact (object-id, path) exception, which is
|
|
18
|
+
* documented and never suppresses a denylist/path/email/key result.
|
|
19
|
+
* - Denylist values are never echoed.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 clean, 1 findings, 2 usage/environment error.
|
|
22
|
+
*/
|
|
23
|
+
import { parseArgs } from "node:util";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { classifyExtension, bufferIsBinary, loadDenylist, scanBytes, scanContent, redactText, loadAllowBinary } from "./scan.mjs";
|
|
28
|
+
|
|
29
|
+
const USAGE = `Usage: node scan-history.mjs --root <repo> [--denylist <file>] [--allow-binary <file>] [--history-allow <file>] [--json] [--out <file>]
|
|
30
|
+
Scans every reachable git object, path, and commit/tag message across ALL history.`;
|
|
31
|
+
const MAX_BLOB = 512 * 1024 * 1024; // bounded read; overflow fails closed, never silently skips
|
|
32
|
+
|
|
33
|
+
function git(root, args, opts = {}) {
|
|
34
|
+
return execFileSync("git", ["-C", root, ...args], { maxBuffer: MAX_BLOB, ...opts });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Load the (oid, path) BIN-TEXT exceptions: one "oid path" per line, # comments. */
|
|
38
|
+
export function loadHistoryAllow(file) {
|
|
39
|
+
if (!file || !fs.existsSync(file)) return new Set();
|
|
40
|
+
return new Set(
|
|
41
|
+
fs.readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Map every blob oid to the set of historical paths it ever appeared under. */
|
|
46
|
+
function blobPaths(root) {
|
|
47
|
+
const out = git(root, ["rev-list", "--all", "--objects"], { encoding: "utf8" });
|
|
48
|
+
const map = new Map();
|
|
49
|
+
for (const line of out.split("\n")) {
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
const sp = line.indexOf(" ");
|
|
52
|
+
if (sp === -1) continue; // commits/tags have no path
|
|
53
|
+
const oid = line.slice(0, sp), p = line.slice(sp + 1);
|
|
54
|
+
if (!map.has(oid)) map.set(oid, new Set());
|
|
55
|
+
map.get(oid).add(p);
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The set of REACHABLE object ids (from all refs, including annotated tag
|
|
61
|
+
* objects). Dangling/unreachable objects - e.g. a transient staged blob that was
|
|
62
|
+
* edited before commit and will be garbage-collected - are not part of the
|
|
63
|
+
* history that can be published, so they are out of scope. */
|
|
64
|
+
function reachableOids(root) {
|
|
65
|
+
const set = new Set();
|
|
66
|
+
for (const line of git(root, ["rev-list", "--all", "--objects"], { encoding: "utf8" }).split("\n")) {
|
|
67
|
+
if (line) set.add(line.split(" ")[0]);
|
|
68
|
+
}
|
|
69
|
+
for (const line of git(root, ["for-each-ref", "--format=%(objecttype) %(objectname)", "refs/tags"], { encoding: "utf8" }).split("\n")) {
|
|
70
|
+
if (line.startsWith("tag ")) set.add(line.slice(4).trim()); // annotated tag objects carry a message
|
|
71
|
+
}
|
|
72
|
+
return set;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every REACHABLE object, with type. */
|
|
76
|
+
function allObjects(root) {
|
|
77
|
+
const reachable = reachableOids(root);
|
|
78
|
+
const out = git(root, ["cat-file", "--batch-all-objects", "--unordered", "--batch-check=%(objecttype) %(objectname)"], { encoding: "utf8" });
|
|
79
|
+
return out.split("\n").filter(Boolean).map((l) => { const [type, oid] = l.split(" "); return { type, oid }; }).filter((o) => reachable.has(o.oid));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Message body of a commit/tag object (everything after the first blank line). */
|
|
83
|
+
function objectMessage(root, oid) {
|
|
84
|
+
const raw = git(root, ["cat-file", oid.startsWith("tag:") ? "tag" : "commit", oid.replace(/^tag:/, "")], { encoding: "utf8" });
|
|
85
|
+
const blank = raw.indexOf("\n\n");
|
|
86
|
+
return blank === -1 ? "" : raw.slice(blank + 2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function runScanHistory({ root, denylistPath, allowBinaryPath, historyAllowPath, allowBinary }) {
|
|
90
|
+
// Confirm this is a git repository up front; a non-repo is an environment error.
|
|
91
|
+
try { git(root, ["rev-parse", "--git-dir"], { encoding: "utf8" }); }
|
|
92
|
+
catch { throw new Error("not a git repository (or git unavailable)"); }
|
|
93
|
+
|
|
94
|
+
const denylist = denylistPath ? loadDenylist(denylistPath) : [];
|
|
95
|
+
const allow = allowBinary ?? loadAllowBinary(allowBinaryPath);
|
|
96
|
+
const historyAllow = loadHistoryAllow(historyAllowPath);
|
|
97
|
+
const findings = [];
|
|
98
|
+
const paths = blobPaths(root);
|
|
99
|
+
const objects = allObjects(root);
|
|
100
|
+
let blobs = 0, commits = 0, tags = 0;
|
|
101
|
+
|
|
102
|
+
for (const { type, oid } of objects) {
|
|
103
|
+
if (type === "commit") {
|
|
104
|
+
commits++;
|
|
105
|
+
// Author/committer emails live in the header (not the message body) and are
|
|
106
|
+
// scanned as ordinary allowlisted emails; the message body is scanned for
|
|
107
|
+
// denylist entries, absolute home paths, non-allowlisted emails, and keys.
|
|
108
|
+
let msg;
|
|
109
|
+
try { msg = objectMessage(root, oid); }
|
|
110
|
+
catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `commit:${oid}`, line: 0, detail: `commit unreadable (${e.code ?? "err"}) - fail closed` }); continue; }
|
|
111
|
+
findings.push(...scanBytes(`commit-message:${oid}`, Buffer.from(msg, "utf8"), denylist));
|
|
112
|
+
} else if (type === "tag") {
|
|
113
|
+
tags++;
|
|
114
|
+
let msg;
|
|
115
|
+
try { msg = objectMessage(root, `tag:${oid}`); }
|
|
116
|
+
catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `tag:${oid}`, line: 0, detail: `tag unreadable (${e.code ?? "err"}) - fail closed` }); continue; }
|
|
117
|
+
findings.push(...scanBytes(`tag-message:${oid}`, Buffer.from(msg, "utf8"), denylist));
|
|
118
|
+
} else if (type === "blob") {
|
|
119
|
+
blobs++;
|
|
120
|
+
let buf;
|
|
121
|
+
try { buf = git(root, ["cat-file", "blob", oid]); }
|
|
122
|
+
catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `blob:${oid}`, line: 0, detail: `blob unscannable (${e.code ?? "err"}) - fail closed` }); continue; }
|
|
123
|
+
const names = [...(paths.get(oid) ?? [`blob:${oid}`])];
|
|
124
|
+
const label = names[0];
|
|
125
|
+
const binary = bufferIsBinary(buf);
|
|
126
|
+
// Content scan once per blob (same bytes regardless of path).
|
|
127
|
+
if (binary) findings.push(...scanBytes(label, buf, denylist).map((f) => ({ ...f, oid })));
|
|
128
|
+
else findings.push(...scanContent(label, buf.toString("utf8"), denylist).map((f) => ({ ...f, oid })));
|
|
129
|
+
// Classification per historical path (extension depends on the name).
|
|
130
|
+
if (binary) for (const name of names) {
|
|
131
|
+
const cls = classifyExtension(name.split(path.sep).join("/"));
|
|
132
|
+
if (cls === "text") {
|
|
133
|
+
if (!historyAllow.has(`${oid} ${name}`)) // documented, object-scoped exception; content still byte-scanned above
|
|
134
|
+
findings.push({ ruleId: "BIN-TEXT", severity: "P0", file: name, line: 0, oid, detail: "historical text-expected file is binary (leak-scan blind spot)" });
|
|
135
|
+
} else if (cls === "unknown" && !allow.has(name)) {
|
|
136
|
+
findings.push({ ruleId: "BIN-UNCLASSIFIED", severity: "P0", file: name, line: 0, oid, detail: "historical unclassified binary" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const safe = findings.map((f) => ({ ...f, file: redactText(f.file, denylist) }));
|
|
143
|
+
const counts = { P0: findings.filter((f) => f.severity === "P0").length, P1: findings.filter((f) => f.severity === "P1").length };
|
|
144
|
+
return { version: 1, mode: "history", root: redactText(path.resolve(root), denylist), stats: { objects: objects.length, blobs, commits, tags }, rulesLoaded: { denylist: denylist.length }, counts, findings: safe };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
let args;
|
|
149
|
+
try {
|
|
150
|
+
args = parseArgs({ options: { root: { type: "string", default: "." }, denylist: { type: "string" }, "allow-binary": { type: "string" }, "history-allow": { type: "string" }, json: { type: "boolean", default: false }, out: { type: "string" }, help: { type: "boolean", default: false } } }).values;
|
|
151
|
+
} catch (e) { console.error(String(e.message ?? e)); console.error(USAGE); process.exit(2); }
|
|
152
|
+
if (args.help) { console.log(USAGE); process.exit(0); }
|
|
153
|
+
let report;
|
|
154
|
+
try {
|
|
155
|
+
report = runScanHistory({ root: args.root, denylistPath: args.denylist, allowBinaryPath: args["allow-binary"], historyAllowPath: args["history-allow"] });
|
|
156
|
+
} catch (e) { console.error(String(e.message ?? e)); process.exit(2); }
|
|
157
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
158
|
+
else console.log(`history scan - ${report.findings.length} finding(s), P0=${report.counts.P0} P1=${report.counts.P1} over ${report.stats.objects} objects (${report.stats.blobs} blobs, ${report.stats.commits} commits, ${report.stats.tags} tags)` + report.findings.map((f) => `\n [${f.severity}] ${f.ruleId} ${f.file}${f.oid ? ` @${f.oid.slice(0, 10)}` : ""}`).join(""));
|
|
159
|
+
if (args.out) fs.writeFileSync(args.out, JSON.stringify(report, null, 2) + "\n");
|
|
160
|
+
process.exit(report.counts.P0 + report.counts.P1 > 0 ? 1 : 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${fs.realpathSync(process.argv[1])}`).href) main();
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Privacy/leak scanner. See references/confidentiality.md for the policy.
|
|
4
|
+
*
|
|
5
|
+
* Contract:
|
|
6
|
+
* - reads only; writes nothing unless --out is explicitly provided
|
|
7
|
+
* - denylist entries are NEVER echoed into output (findings carry rule ids only)
|
|
8
|
+
* - machine-readable JSON on stdout with --json
|
|
9
|
+
* - exit 0 clean, 1 findings, 2 usage error
|
|
10
|
+
*
|
|
11
|
+
* Binary policy (fail-closed): binary files are NEVER silently skipped.
|
|
12
|
+
* - A file with a text-expected extension that is binary is a P0 finding
|
|
13
|
+
* (BIN-TEXT) - this closes the NUL-byte blind spot.
|
|
14
|
+
* - Every binary file (whatever its extension) is byte-scanned for denylist
|
|
15
|
+
* entries and byte-searchable P0 rules (paths/emails/keys), matched over a
|
|
16
|
+
* latin1 decode so ASCII byte sequences are found across embedded NULs.
|
|
17
|
+
* - A binary file that is neither text-expected nor a known/allowlisted asset
|
|
18
|
+
* is a P0 finding (BIN-UNCLASSIFIED). Legitimate binary assets pass only via
|
|
19
|
+
* a known-binary extension or the documented allowlist file.
|
|
20
|
+
*/
|
|
21
|
+
import { parseArgs } from "node:util";
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
const USAGE = `Usage: node scan.mjs [options]
|
|
28
|
+
--denylist <path> external denylist file (one case-insensitive substring per line, # comments)
|
|
29
|
+
--root <path> directory to scan (default: current directory)
|
|
30
|
+
--staged scan only files staged in git (contents from the index)
|
|
31
|
+
--allow-binary <p> newline list of repo-relative legitimate binary asset paths
|
|
32
|
+
--json machine-readable JSON on stdout
|
|
33
|
+
--out <path> also write the report to this exact path (never writes otherwise)
|
|
34
|
+
--help show this help`;
|
|
35
|
+
|
|
36
|
+
const SKIP_DIRS = new Set([
|
|
37
|
+
".git", "node_modules", ".impeccable", ".superdev",
|
|
38
|
+
".next", ".turbo", ".cache", "coverage",
|
|
39
|
+
]);
|
|
40
|
+
// Extensions whose files MUST be text - a binary one is a P0 leak-vector (BIN-TEXT).
|
|
41
|
+
export const TEXT_EXTENSIONS = new Set([
|
|
42
|
+
".mjs", ".cjs", ".js", ".ts", ".mts", ".cts", ".jsx", ".tsx", ".vue", ".svelte",
|
|
43
|
+
".json", ".jsonc", ".md", ".markdown", ".yaml", ".yml", ".sh", ".bash", ".txt",
|
|
44
|
+
".html", ".css", ".xml", ".toml", ".ini", ".cfg", ".conf", ".env", ".gitignore",
|
|
45
|
+
".editorconfig", ".sql", ".py", ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp",
|
|
46
|
+
]);
|
|
47
|
+
// Extensions that are legitimately binary assets - still byte-scanned, never BIN-TEXT.
|
|
48
|
+
export const KNOWN_BINARY_EXTENSIONS = new Set([
|
|
49
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".woff", ".woff2", ".ttf",
|
|
50
|
+
".otf", ".eot", ".zip", ".gz", ".tar", ".wasm", ".mp3", ".mp4", ".mov", ".bin",
|
|
51
|
+
]);
|
|
52
|
+
export function classifyExtension(relPosix) {
|
|
53
|
+
const ext = relPosix.slice(relPosix.lastIndexOf("."));
|
|
54
|
+
if (TEXT_EXTENSIONS.has(ext)) return "text";
|
|
55
|
+
if (KNOWN_BINARY_EXTENSIONS.has(ext)) return "known-binary";
|
|
56
|
+
return "unknown";
|
|
57
|
+
}
|
|
58
|
+
export function bufferIsBinary(buf) {
|
|
59
|
+
const n = Math.min(buf.length, 8000);
|
|
60
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const EMAIL_ALLOWED = /@(?:[a-z0-9.-]*\.)?(?:example\.(?:com|org|net)|anthropic\.com)$|\.(?:invalid|test|example|localhost)$/i;
|
|
64
|
+
const RULES = {
|
|
65
|
+
ABS_HOME: { id: "ABS-HOME", severity: "P0", re: /\/(?:Users|home)\/[A-Za-z0-9_-]+\//g },
|
|
66
|
+
EMAIL: { id: "EMAIL", severity: "P0", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
|
|
67
|
+
KEY: { id: "KEY", severity: "P0", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
68
|
+
HIENT: { id: "HIENT", severity: "P1", re: /[A-Za-z0-9+/=_-]{40,}/g },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Integrity hashes are the purpose of dependency lockfiles. Treating every
|
|
72
|
+
// registry checksum as a possible secret makes the scanner unusable on a real
|
|
73
|
+
// brownfield project (a normal pnpm lockfile can produce hundreds of findings).
|
|
74
|
+
// Only the HIENT heuristic is skipped: denylist matches, absolute paths, emails,
|
|
75
|
+
// private-key markers, binary classification, and env-file rules still apply.
|
|
76
|
+
const DEPENDENCY_LOCKFILES = new Set([
|
|
77
|
+
"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-lock.yml",
|
|
78
|
+
"yarn.lock", "bun.lock", "composer.lock", "Cargo.lock", "Gemfile.lock",
|
|
79
|
+
"poetry.lock", "Pipfile.lock", "uv.lock", "go.sum", "mix.lock", "Podfile.lock",
|
|
80
|
+
"Package.resolved",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The compiled control center, skipped for the same reason and no broader one.
|
|
85
|
+
* It is minified build output, so it is one enormous line of mangled
|
|
86
|
+
* identifiers that trips an entropy heuristic by construction, and its source
|
|
87
|
+
* under `ui/` is scanned in full. Skipping the heuristic here keeps the release
|
|
88
|
+
* gate meaningful; a scanner that always reports one finding is one people stop
|
|
89
|
+
* reading.
|
|
90
|
+
*
|
|
91
|
+
* As with the lockfiles above, only HIENT is skipped. The denylist, absolute
|
|
92
|
+
* paths, emails, private-key markers and binary classification still judge this
|
|
93
|
+
* file, because minification would carry a real leak through unchanged.
|
|
94
|
+
*/
|
|
95
|
+
const DERIVED_BUNDLES = new Set(["src/service/assets/control-center.html"]);
|
|
96
|
+
|
|
97
|
+
export function highEntropyHeuristicApplies(relFile) {
|
|
98
|
+
const posix = relFile.replaceAll("\\", "/");
|
|
99
|
+
if (DERIVED_BUNDLES.has(posix)) return false;
|
|
100
|
+
return !DEPENDENCY_LOCKFILES.has(path.posix.basename(posix));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadDenylist(file) {
|
|
104
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
|
105
|
+
return lines
|
|
106
|
+
.map((l) => l.trim())
|
|
107
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
108
|
+
.map((pattern, i) => ({ id: `DL-${i + 1}`, pattern: pattern.toLowerCase() }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shannonEntropy(s) {
|
|
112
|
+
const freq = {};
|
|
113
|
+
for (const ch of s) freq[ch] = (freq[ch] ?? 0) + 1;
|
|
114
|
+
return -Object.values(freq).reduce((acc, n) => {
|
|
115
|
+
const p = n / s.length;
|
|
116
|
+
return acc + p * Math.log2(p);
|
|
117
|
+
}, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isBinary(buf) {
|
|
121
|
+
const n = Math.min(buf.length, 8000);
|
|
122
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Byte-searchable P0 rules over a latin1 decode (1:1 byte↔char for ASCII), so
|
|
127
|
+
* denylist entries, paths, emails, and key markers are caught even inside a
|
|
128
|
+
* binary blob or after an embedded NUL. Never echoes matched values. */
|
|
129
|
+
export function scanBytes(relFile, buf, denylist) {
|
|
130
|
+
const findings = [];
|
|
131
|
+
const latin1 = buf.toString("latin1");
|
|
132
|
+
const lower = latin1.toLowerCase();
|
|
133
|
+
for (const { id, pattern } of denylist) {
|
|
134
|
+
if (lower.includes(pattern)) findings.push({ ruleId: id, severity: "P0", file: relFile, line: 0 });
|
|
135
|
+
}
|
|
136
|
+
if (RULES.ABS_HOME.re.test(latin1)) findings.push({ ruleId: RULES.ABS_HOME.id, severity: "P0", file: relFile, line: 0 });
|
|
137
|
+
RULES.ABS_HOME.re.lastIndex = 0;
|
|
138
|
+
for (const m of latin1.matchAll(RULES.EMAIL.re)) {
|
|
139
|
+
if (!EMAIL_ALLOWED.test(m[0])) findings.push({ ruleId: RULES.EMAIL.id, severity: "P0", file: relFile, line: 0 });
|
|
140
|
+
}
|
|
141
|
+
if (RULES.KEY.re.test(latin1)) findings.push({ ruleId: RULES.KEY.id, severity: "P0", file: relFile, line: 0 });
|
|
142
|
+
RULES.KEY.re.lastIndex = 0;
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function scanContent(relFile, text, denylist) {
|
|
147
|
+
const findings = [];
|
|
148
|
+
const lines = text.split("\n");
|
|
149
|
+
const lowerLines = lines.map((l) => l.toLowerCase());
|
|
150
|
+
const scanHighEntropy = highEntropyHeuristicApplies(relFile);
|
|
151
|
+
|
|
152
|
+
for (const { id, pattern } of denylist) {
|
|
153
|
+
lowerLines.forEach((l, i) => {
|
|
154
|
+
if (l.includes(pattern)) findings.push({ ruleId: id, severity: "P0", file: relFile, line: i + 1 });
|
|
155
|
+
});
|
|
156
|
+
if (relFile.toLowerCase().includes(pattern))
|
|
157
|
+
findings.push({ ruleId: id, severity: "P0", file: relFile, line: 0 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.forEach((l, i) => {
|
|
161
|
+
if (RULES.ABS_HOME.re.test(l)) findings.push({ ruleId: RULES.ABS_HOME.id, severity: "P0", file: relFile, line: i + 1 });
|
|
162
|
+
RULES.ABS_HOME.re.lastIndex = 0;
|
|
163
|
+
|
|
164
|
+
for (const m of l.matchAll(RULES.EMAIL.re)) {
|
|
165
|
+
if (!EMAIL_ALLOWED.test(m[0])) findings.push({ ruleId: RULES.EMAIL.id, severity: "P0", file: relFile, line: i + 1 });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (RULES.KEY.re.test(l)) findings.push({ ruleId: RULES.KEY.id, severity: "P0", file: relFile, line: i + 1 });
|
|
169
|
+
RULES.KEY.re.lastIndex = 0;
|
|
170
|
+
|
|
171
|
+
if (scanHighEntropy) {
|
|
172
|
+
for (const m of l.matchAll(RULES.HIENT.re)) {
|
|
173
|
+
// Entropy heuristic; thresholds change only alongside fixture updates proving the change.
|
|
174
|
+
if (m[0].length >= 40 && shannonEntropy(m[0]) > 4.5 && /[A-Z]/.test(m[0]) && /[a-z]/.test(m[0]) && /[0-9]/.test(m[0])) {
|
|
175
|
+
findings.push({ ruleId: RULES.HIENT.id, severity: "P1", file: relFile, line: i + 1 });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (/(^|\/)\.env(\.|rc$|$)/.test(relFile)) findings.push({ ruleId: "ENVFILE", severity: "P0", file: relFile, line: 0 });
|
|
182
|
+
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function* walk(dir, root) {
|
|
187
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
188
|
+
const full = path.join(dir, entry.name);
|
|
189
|
+
if (entry.isSymbolicLink()) {
|
|
190
|
+
// Never skip silently: a symlink target can carry an absolute home path,
|
|
191
|
+
// username, or internal host. Yield it so its target is scanned.
|
|
192
|
+
yield { rel: path.relative(root, full), symlink: true };
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (entry.isDirectory()) {
|
|
196
|
+
if (!SKIP_DIRS.has(entry.name)) yield* walk(full, root);
|
|
197
|
+
} else if (entry.isFile()) {
|
|
198
|
+
yield path.relative(root, full);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stagedFiles(root) {
|
|
204
|
+
const out = execFileSync("git", ["-C", root, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], { encoding: "utf8" });
|
|
205
|
+
return out.split("\0").filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A repository scan covers exactly what can enter version control: tracked and
|
|
210
|
+
* non-ignored untracked files. Build caches contain machine paths, integrity
|
|
211
|
+
* hashes, compiled third-party code, and binary archives; walking them produces
|
|
212
|
+
* noise while saying nothing about what the repository can publish.
|
|
213
|
+
*
|
|
214
|
+
* Fall back to the conservative filesystem walk for non-git directories and
|
|
215
|
+
* subdirectory roots, which keeps standalone fixture/scaffold scanning useful.
|
|
216
|
+
*/
|
|
217
|
+
function treeEntries(root) {
|
|
218
|
+
try {
|
|
219
|
+
const quietGit = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] };
|
|
220
|
+
const top = execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], quietGit).trim();
|
|
221
|
+
if (fs.realpathSync(top) !== fs.realpathSync(root)) throw new Error("root is not repository top");
|
|
222
|
+
const out = execFileSync("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], quietGit);
|
|
223
|
+
return out.split("\0").filter(Boolean).flatMap((rel) => {
|
|
224
|
+
const full = path.join(root, rel);
|
|
225
|
+
if (!fs.existsSync(full)) return []; // deleted tracked content cannot leak from the tree
|
|
226
|
+
return [{ rel, symlink: fs.lstatSync(full).isSymbolicLink() }];
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
return [...walk(root, root)].map((e) => (typeof e === "string" ? { rel: e } : e));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Denylist entries must never appear in output - including inside file paths.
|
|
234
|
+
* Uses a write cursor so an inserted `[DL-n]` marker is never re-scanned
|
|
235
|
+
* (a denylist entry like "dl" could otherwise match its own marker and loop). */
|
|
236
|
+
export function redactText(text, denylist) {
|
|
237
|
+
let out = text;
|
|
238
|
+
for (const { id, pattern } of denylist) {
|
|
239
|
+
let result = "";
|
|
240
|
+
let from = 0;
|
|
241
|
+
const lower = out.toLowerCase();
|
|
242
|
+
let idx;
|
|
243
|
+
while ((idx = lower.indexOf(pattern, from)) !== -1) {
|
|
244
|
+
result += out.slice(from, idx) + `[${id}]`;
|
|
245
|
+
from = idx + pattern.length;
|
|
246
|
+
}
|
|
247
|
+
out = result + out.slice(from);
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function loadAllowBinary(file) {
|
|
253
|
+
if (!file) return new Set();
|
|
254
|
+
return new Set(
|
|
255
|
+
fs.readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function runScan({ root, staged, denylistPath, allowBinaryPath, allowBinary }) {
|
|
260
|
+
const denylist = denylistPath ? loadDenylist(denylistPath) : [];
|
|
261
|
+
const allow = allowBinary ?? loadAllowBinary(allowBinaryPath);
|
|
262
|
+
const entries = staged ? stagedFiles(root).map((rel) => ({ rel })) : treeEntries(root);
|
|
263
|
+
const findings = [];
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const rel = entry.rel;
|
|
266
|
+
const relPosix = rel.split(path.sep).join("/");
|
|
267
|
+
const full = path.join(root, rel);
|
|
268
|
+
|
|
269
|
+
// Symlinks (tree mode): scan the target STRING for leaks, never follow.
|
|
270
|
+
if (entry.symlink) {
|
|
271
|
+
let target = "";
|
|
272
|
+
try {
|
|
273
|
+
target = fs.readlinkSync(full);
|
|
274
|
+
} catch {
|
|
275
|
+
findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: relPosix, line: 0, detail: "symlink unreadable - fail closed" });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
findings.push(...scanBytes(relPosix, Buffer.from(target, "utf8"), denylist));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let buf;
|
|
283
|
+
try {
|
|
284
|
+
buf = staged
|
|
285
|
+
? execFileSync("git", ["-C", root, "show", `:${rel}`], { maxBuffer: 512 * 1024 * 1024 })
|
|
286
|
+
: fs.readFileSync(full);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// Fail closed: an unreadable/oversize file blocks the gate instead of
|
|
289
|
+
// being silently skipped (the pre-commit staged mode especially).
|
|
290
|
+
findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: relPosix, line: 0, detail: `unscannable (${e.code ?? "read error"}) - fail closed` });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (isBinary(buf)) {
|
|
294
|
+
// Never silently skip: byte-scan for leaks, and classify the binary.
|
|
295
|
+
findings.push(...scanBytes(relPosix, buf, denylist));
|
|
296
|
+
const cls = classifyExtension(relPosix);
|
|
297
|
+
if (cls === "text")
|
|
298
|
+
findings.push({ ruleId: "BIN-TEXT", severity: "P0", file: relPosix, line: 0, detail: "text-expected file is binary (leak-scan blind spot)" });
|
|
299
|
+
else if (cls === "unknown" && !allow.has(relPosix))
|
|
300
|
+
findings.push({ ruleId: "BIN-UNCLASSIFIED", severity: "P0", file: relPosix, line: 0, detail: "unclassified binary; add to --allow-binary if a legitimate asset" });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
findings.push(...scanContent(relPosix, buf.toString("utf8"), denylist));
|
|
304
|
+
}
|
|
305
|
+
const safeFindings = findings.map((f) => ({ ...f, file: redactText(f.file, denylist) }));
|
|
306
|
+
const counts = { P0: findings.filter((f) => f.severity === "P0").length, P1: findings.filter((f) => f.severity === "P1").length };
|
|
307
|
+
return { version: 1, root: redactText(path.resolve(root), denylist), mode: staged ? "staged" : "tree", rulesLoaded: { denylist: denylist.length, builtin: Object.keys(RULES).length + 2 }, counts, findings: safeFindings };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function formatHuman(report) {
|
|
311
|
+
const lines = [`privacy scan (${report.mode}) - ${report.findings.length} finding(s), P0=${report.counts.P0} P1=${report.counts.P1}`];
|
|
312
|
+
for (const f of report.findings) lines.push(` [${f.severity}] ${f.ruleId} ${f.file}${f.line ? ":" + f.line : ""}`);
|
|
313
|
+
if (!report.findings.length) lines.push(" clean");
|
|
314
|
+
return lines.join("\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function main() {
|
|
318
|
+
let args;
|
|
319
|
+
try {
|
|
320
|
+
args = parseArgs({
|
|
321
|
+
options: {
|
|
322
|
+
denylist: { type: "string" },
|
|
323
|
+
root: { type: "string", default: "." },
|
|
324
|
+
staged: { type: "boolean", default: false },
|
|
325
|
+
"allow-binary": { type: "string" },
|
|
326
|
+
json: { type: "boolean", default: false },
|
|
327
|
+
out: { type: "string" },
|
|
328
|
+
help: { type: "boolean", default: false },
|
|
329
|
+
},
|
|
330
|
+
allowPositionals: false,
|
|
331
|
+
}).values;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.error(String(err.message ?? err));
|
|
334
|
+
console.error(USAGE);
|
|
335
|
+
process.exit(2);
|
|
336
|
+
}
|
|
337
|
+
if (args.help) {
|
|
338
|
+
console.log(USAGE);
|
|
339
|
+
process.exit(0);
|
|
340
|
+
}
|
|
341
|
+
if (!fs.existsSync(args.root) || !fs.statSync(args.root).isDirectory()) {
|
|
342
|
+
console.error(`root is not a directory: ${args.root}`);
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
345
|
+
if (args.denylist && !fs.existsSync(args.denylist)) {
|
|
346
|
+
console.error(`denylist file not found`);
|
|
347
|
+
process.exit(2);
|
|
348
|
+
}
|
|
349
|
+
if (args["allow-binary"] && !fs.existsSync(args["allow-binary"])) {
|
|
350
|
+
console.error(`allow-binary file not found`);
|
|
351
|
+
process.exit(2);
|
|
352
|
+
}
|
|
353
|
+
const report = runScan({ root: args.root, staged: args.staged, denylistPath: args.denylist, allowBinaryPath: args["allow-binary"] });
|
|
354
|
+
const output = args.json ? JSON.stringify(report, null, 2) : formatHuman(report);
|
|
355
|
+
console.log(output);
|
|
356
|
+
if (args.out) {
|
|
357
|
+
const outDir = path.dirname(path.resolve(args.out));
|
|
358
|
+
const tmpOut = path.join(outDir, `.tmp-report-${process.pid}`);
|
|
359
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
360
|
+
fs.writeFileSync(tmpOut, JSON.stringify(report, null, 2) + "\n");
|
|
361
|
+
fs.renameSync(tmpOut, path.resolve(args.out));
|
|
362
|
+
}
|
|
363
|
+
process.exit(report.findings.length ? 1 : 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Main-module guard: realpath both sides - Node resolves symlinks (e.g. /tmp
|
|
367
|
+
// on macOS) when building import.meta.url for the entry module.
|
|
368
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) main();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Deterministic validators
|
|
2
|
+
|
|
3
|
+
**The rule that separates a validator from a test.** A validator inspects an
|
|
4
|
+
artifact that exists in the repository, or one it constructs deterministically
|
|
5
|
+
in a temp directory within the same run and deletes afterwards. It has no
|
|
6
|
+
committed fixtures, no test framework, no phase labels, and no simulated
|
|
7
|
+
product. It must FAIL rather than skip when its input is absent.
|
|
8
|
+
|
|
9
|
+
That last sentence is the whole point. A gate that turns itself off when the
|
|
10
|
+
thing it guards goes missing is worse than no gate, because it reports success
|
|
11
|
+
while checking nothing.
|
|
12
|
+
|
|
13
|
+
This repository ships no automated test suite for the plugin itself. Quality is
|
|
14
|
+
proven here, against real artifacts, and by building a real product with the
|
|
15
|
+
tool. Products built with Superdev do get tests; Superdev does not test a
|
|
16
|
+
simulated copy of itself. `no-tests.mjs` is the guard that stops the suite
|
|
17
|
+
growing back.
|
|
18
|
+
|
|
19
|
+
## Running
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm run validate # the repository in the working directory
|
|
23
|
+
node scripts/validate/validate-all.mjs --root <path>
|
|
24
|
+
node scripts/validate/validate-all.mjs --only migrations,imports
|
|
25
|
+
node scripts/validate/validate-all.mjs --json --out report.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` no findings, `1` findings, `2` usage error.
|
|
29
|
+
|
|
30
|
+
`--out` writes exactly the machine-readable report, atomically, and nothing is
|
|
31
|
+
written without it.
|
|
32
|
+
|
|
33
|
+
## Interface
|
|
34
|
+
|
|
35
|
+
Every validator is a standalone module exporting `run(root)`:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
{ name: "migrations", findings: [{ code, severity, path, message }] }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `code` is stable and greppable (`MG-007`), so a finding can be referred to.
|
|
42
|
+
- `severity` is `error` or `warning`. Both are findings and both exit non-zero;
|
|
43
|
+
the distinction says how urgent it is, not whether it counts.
|
|
44
|
+
- `path` is repo-relative with forward slashes, optionally `:line` or
|
|
45
|
+
`:line:column`. An absolute machine path never appears in output.
|
|
46
|
+
- `message` says what is wrong and, where it is not obvious, what to do.
|
|
47
|
+
|
|
48
|
+
A validator that throws is reported as a `VA-001` finding against itself. A
|
|
49
|
+
broken validator never reads as a passing run.
|
|
50
|
+
|
|
51
|
+
## The validators
|
|
52
|
+
|
|
53
|
+
| Module | Checks | Fails when its input is absent |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `manifests.mjs` | Claude, Codex and marketplace manifests plus `hooks.json`: valid JSON, required fields, kebab name, no absolute or escaping paths | a manifest is missing |
|
|
56
|
+
| `skills.mjs` | `skills/*/SKILL.md`: frontmatter parses, name matches directory, description present and under 1024 characters, body under 500 lines, no working-directory-dependent commands, every named file resolves | `skills/` is missing or empty |
|
|
57
|
+
| `docs-templates.mjs` | Docs templates and fragments stay neutral: no vendor, latency, cloud region, compliance regime or concrete environment variable | the templates or fragments directory is missing |
|
|
58
|
+
| `migrations.mjs` | Builds a throwaway database in a temp directory, applies the full ordered set forward, runs integrity and foreign key checks, asserts the applied list equals the checked-in files, proves the task mapping constraint and the append-only triggers fire and that a mapped task still passes, then deletes it | no migration files are found |
|
|
59
|
+
| `markdown.mjs` | Generated Markdown under `talks/` carries a generation marker, its body still hashes to the recorded hash, and every relative link resolves | `talks/` is missing or holds no Markdown |
|
|
60
|
+
| `style.mjs` | No em dash character and no emoji in project-owned content | there are no project-owned text files |
|
|
61
|
+
| `privacy.mjs` | No absolute home paths, no secret-shaped strings, no private project identifiers | there are no project-owned text files |
|
|
62
|
+
| `imports.mjs` | Every module under `src/` and `scripts/` imports only node builtins, relative paths, or the one allowlisted runtime dependency, which only `src/db/connect.mjs` may import | neither directory exists |
|
|
63
|
+
| `dependencies.mjs` | `package.json` declares only allowlisted runtime dependencies, pins them exactly, declares the Node floor, and carries no dev toolchain | `package.json` is missing |
|
|
64
|
+
| `no-tests.mjs` | No `*.test.mjs`, no test or fixture tree, no test runner script or framework dependency | never; a missing root is itself the finding |
|
|
65
|
+
| `footprint.mjs` | No file-per-record operational tracking: no file named after a task, subtask, assignment, transition, evidence item, activity event, session, agent, branch, memory entry, sync cursor or conflict, and no directory named after one that has filled with data documents | never; an empty root is itself the finding |
|
|
66
|
+
|
|
67
|
+
## Private identifiers
|
|
68
|
+
|
|
69
|
+
The denylist of private project identifiers lives outside this repository by
|
|
70
|
+
design and is supplied at run time:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
SUPERDEV_DENYLIST=<path outside the repo> npm run validate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Entries are never echoed into output. Findings carry a rule id and a location
|
|
77
|
+
only, because printing the matched value would be the leak the scan exists to
|
|
78
|
+
prevent. When the variable points at a file that does not exist, `privacy.mjs`
|
|
79
|
+
reports it rather than passing: a check that silently did not run must not read
|
|
80
|
+
as a check that passed.
|
|
81
|
+
|
|
82
|
+
The screening rules themselves come from `src/model/screening.mjs`, the same
|
|
83
|
+
module the storage boundary uses, so a string the database would refuse cannot
|
|
84
|
+
arrive through a file instead.
|
|
85
|
+
|
|
86
|
+
## Adding one
|
|
87
|
+
|
|
88
|
+
Write `scripts/validate/<name>.mjs` exporting `name` and `run(root)`, then add
|
|
89
|
+
it to `VALIDATORS` in `validate-all.mjs`. Declaration order is report order.
|
|
90
|
+
|
|
91
|
+
Before adding it, check it against the rule at the top of this file. If it needs
|
|
92
|
+
a fixture, it is a test. If it simulates product behavior, it is a test. If it
|
|
93
|
+
passes quietly when the artifact it inspects is not there, it is worse than a
|
|
94
|
+
test.
|