@cosmicdrift/kumiko-guards 0.363.0 → 0.364.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.
@@ -0,0 +1,182 @@
1
+ // Security check: `bun audit` over the lockfile of ONE repo.
2
+ //
3
+ // Single-repo by design: audits the bun.lock in the scanned repo's root. A repo without its
4
+ // own bun.lock falls back to the nearest ancestor's (a parent workspace), announced loudly.
5
+ // Critical/high advisories block unless covered by a short-lived exception; so does any
6
+ // exception expiring within EXPIRY_BLOCK_DAYS. Exceptions merge the bundled
7
+ // dependency-security-exceptions.json with an optional file of the same name in the repo root
8
+ // (and in the ancestor holding the lockfile, when that is not the repo root).
9
+ import { existsSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { parseDependencyCli } from "./_lib/dependency-cli";
12
+ import { expiryEndMs, findExpiringSoon, loadExceptionEntries } from "./_lib/dependency-exceptions";
13
+ import {
14
+ type AuditAdvisory,
15
+ evaluateSecurity,
16
+ parseAuditReport,
17
+ type RawAuditReport,
18
+ runBunAudit,
19
+ type SecurityEvaluation,
20
+ type SecurityException,
21
+ } from "./_lib/security-audit";
22
+
23
+ export const SECURITY_EXCEPTIONS_FILE = "dependency-security-exceptions.json";
24
+
25
+ // Block a PR while there is still runway to act, not on the day the advisory re-blocks;
26
+ // the 30-day console warning is invisible in green CI logs.
27
+ export const EXPIRY_BLOCK_DAYS = 14;
28
+
29
+ export function loadSecurityExceptions(
30
+ roots: readonly string[],
31
+ bundledDir?: string,
32
+ ): SecurityException[] {
33
+ return loadExceptionEntries({
34
+ filename: SECURITY_EXCEPTIONS_FILE,
35
+ roots,
36
+ bundledDir,
37
+ parse: (field) => ({
38
+ advisory: field("advisory"),
39
+ package: field("package"),
40
+ reason: field("reason"),
41
+ reviewedBy: field("reviewedBy"),
42
+ expires: field("expires"),
43
+ }),
44
+ }).filter((exception) => {
45
+ const hasDate = expiryEndMs(exception.expires) !== undefined;
46
+ if (!hasDate) {
47
+ console.warn(
48
+ ` WARN: security exception ${exception.advisory} dropped, expires "${exception.expires}" is not a YYYY-MM-DD date. Set expires to a date like 2026-12-31.`,
49
+ );
50
+ }
51
+ return hasDate;
52
+ });
53
+ }
54
+
55
+ /** Directory whose bun.lock `bun audit` runs against: the repo root, else the nearest ancestor with one. */
56
+ export function findLockfileDir(repoRoot: string): string | undefined {
57
+ for (let dir = repoRoot; ; dir = dirname(dir)) {
58
+ if (existsSync(join(dir, "bun.lock"))) return dir;
59
+ if (dirname(dir) === dir) return undefined;
60
+ }
61
+ }
62
+
63
+ export function shouldBlock(
64
+ evaluation: Pick<SecurityEvaluation, "blocking">,
65
+ expiringUrgent: readonly SecurityException[],
66
+ ): boolean {
67
+ return evaluation.blocking.length > 0 || expiringUrgent.length > 0;
68
+ }
69
+
70
+ function printReport(
71
+ evaluation: SecurityEvaluation,
72
+ exceptions: readonly SecurityException[],
73
+ expiringUrgent: readonly SecurityException[],
74
+ nowMs: number,
75
+ ): void {
76
+ const { counts, blocking, warnings, suppressed } = evaluation;
77
+ const total = counts.critical + counts.high + counts.moderate + counts.low + counts.info;
78
+ console.log(
79
+ ` ${total} advisories — critical: ${counts.critical} high: ${counts.high} moderate: ${counts.moderate} low: ${counts.low}`,
80
+ );
81
+
82
+ if (blocking.length > 0) {
83
+ console.error("\n BLOCKING vulnerabilities:\n");
84
+ for (const adv of blocking) {
85
+ console.error(
86
+ ` [${adv.severity.toUpperCase()}] ${adv.package} ${adv.advisoryId} ${adv.title}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ if (warnings.length > 0) {
92
+ console.log("\n WARN (moderate, not blocking):");
93
+ for (const adv of warnings) {
94
+ console.log(` [MODERATE] ${adv.package} ${adv.advisoryId} ${adv.title}`);
95
+ }
96
+ }
97
+
98
+ if (suppressed.length > 0) {
99
+ console.log(`\n Suppressed via exception (${suppressed.length}):`);
100
+ for (const adv of suppressed.slice(0, 10)) {
101
+ console.log(` [${adv.severity}] ${adv.package} ${adv.advisoryId}`);
102
+ }
103
+ if (suppressed.length > 10) console.log(` … and ${suppressed.length - 10} more`);
104
+ }
105
+
106
+ if (expiringUrgent.length > 0) {
107
+ console.error(
108
+ `\n BLOCKING: security exceptions expire within ${EXPIRY_BLOCK_DAYS} days — bump the dependency or re-review the exception now:`,
109
+ );
110
+ for (const ex of expiringUrgent) {
111
+ console.error(` ${ex.package} ${ex.advisory} expires=${ex.expires}`);
112
+ }
113
+ }
114
+
115
+ const urgentKeys = new Set(expiringUrgent.map((ex) => `${ex.package}|${ex.advisory}`));
116
+ const expiringSoon = findExpiringSoon(exceptions, 30, nowMs).filter(
117
+ (ex) => !urgentKeys.has(`${ex.package}|${ex.advisory}`),
118
+ );
119
+ if (expiringSoon.length > 0) {
120
+ console.log("\n WARN: security exceptions expire within 30 days — re-review:");
121
+ for (const ex of expiringSoon) {
122
+ console.log(` ${ex.package} ${ex.advisory} expires=${ex.expires}`);
123
+ }
124
+ }
125
+
126
+ if (shouldBlock(evaluation, expiringUrgent)) {
127
+ console.error(
128
+ `\n Fix dependencies or add short-lived exceptions in ${SECURITY_EXCEPTIONS_FILE} in the repo root\n`,
129
+ );
130
+ }
131
+ }
132
+
133
+ export async function runSecurityCheck(options: {
134
+ readonly repoRoot: string;
135
+ readonly reportOnly: boolean;
136
+ readonly runAudit?: (lockfileDir: string) => Promise<RawAuditReport>;
137
+ readonly exceptions?: readonly SecurityException[];
138
+ readonly nowMs?: number;
139
+ }): Promise<number> {
140
+ const nowMs = options.nowMs ?? Date.now();
141
+ const lockfileDir = findLockfileDir(options.repoRoot);
142
+ if (lockfileDir === undefined) {
143
+ console.error(
144
+ ` security: no bun.lock found in ${options.repoRoot} or any ancestor — nothing to audit. Run bun install to create bun.lock.`,
145
+ );
146
+ return 1;
147
+ }
148
+ if (lockfileDir !== options.repoRoot) {
149
+ console.warn(
150
+ ` WARN: ${options.repoRoot} has no bun.lock — auditing ${lockfileDir}/bun.lock instead (it may cover more than this repo).`,
151
+ );
152
+ }
153
+
154
+ const exceptions = options.exceptions ?? loadSecurityExceptions([options.repoRoot, lockfileDir]);
155
+ console.log(`Security audit: scanning ${lockfileDir} …`);
156
+ const raw = await (options.runAudit ?? runBunAudit)(lockfileDir);
157
+ const advisories: AuditAdvisory[] = parseAuditReport(raw);
158
+
159
+ const evaluation = evaluateSecurity(advisories, exceptions, nowMs);
160
+ const expiringUrgent = findExpiringSoon(exceptions, EXPIRY_BLOCK_DAYS, nowMs);
161
+ printReport(evaluation, exceptions, expiringUrgent, nowMs);
162
+
163
+ if (options.reportOnly) return 0;
164
+ return shouldBlock(evaluation, expiringUrgent) ? 1 : 0;
165
+ }
166
+
167
+ export async function runSecurityCli(
168
+ argv: readonly string[],
169
+ cwd: string = process.cwd(),
170
+ ): Promise<number> {
171
+ const invocation = parseDependencyCli("security", argv, cwd);
172
+ if (!invocation.ok) return invocation.exitCode;
173
+ try {
174
+ return await runSecurityCheck({
175
+ repoRoot: invocation.repoRoot,
176
+ reportOnly: invocation.reportOnly,
177
+ });
178
+ } catch (err) {
179
+ console.error("Security check failed:", err);
180
+ return 1;
181
+ }
182
+ }
@@ -109,7 +109,7 @@ export const check: RepoCheck = {
109
109
  violations.push({
110
110
  file: "bun.lock",
111
111
  line: 1,
112
- message: `bun.lock could not be parsed (${root.name}): ${lock.detail}`,
112
+ message: `bun.lock could not be parsed (${root.name}): ${lock.detail}. Regenerate bun.lock with bun install.`,
113
113
  });
114
114
  continue;
115
115
  }
package/src/cli.ts CHANGED
@@ -4,7 +4,11 @@
4
4
  // runners already use in their own `if (import.meta.main)` blocks — including
5
5
  // flags, which each runner's own *Cli function validates and applies so this
6
6
  // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
+ import { DEPENDENCY_CHECK_FLAGS } from "./_lib/dependency-cli";
7
8
  import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
9
+ import { runLicensesCli } from "./check-licenses";
10
+ import { runOutdatedCli } from "./check-outdated";
11
+ import { runSecurityCli } from "./check-security";
8
12
  import { COMMENT_LANG_FLAGS, runCommentLangCli } from "./guard-comment-lang";
9
13
  import {
10
14
  PUBLIC_EXPORTS_CHANGELOG_FLAGS,
@@ -20,6 +24,9 @@ const SUBCOMMANDS = [
20
24
  "checks",
21
25
  "comment-lang",
22
26
  "public-exports-changelog",
27
+ "licenses",
28
+ "security",
29
+ "outdated",
23
30
  "list",
24
31
  ] as const;
25
32
  type Subcommand = (typeof SUBCOMMANDS)[number];
@@ -30,6 +37,9 @@ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
30
37
  checks: REPO_CHECK_FLAGS,
31
38
  "comment-lang": [...COMMENT_LANG_FLAGS, "--base=<ref>"],
32
39
  "public-exports-changelog": PUBLIC_EXPORTS_CHANGELOG_FLAGS,
40
+ licenses: DEPENDENCY_CHECK_FLAGS,
41
+ security: DEPENDENCY_CHECK_FLAGS,
42
+ outdated: DEPENDENCY_CHECK_FLAGS,
33
43
  list: [],
34
44
  };
35
45
 
@@ -39,7 +49,7 @@ function isSubcommand(value: string): value is Subcommand {
39
49
 
40
50
  function printHelp(): void {
41
51
  console.log(
42
- "Usage: kumiko-guards [guards|ui|checks|comment-lang|public-exports-changelog|list] [flags]",
52
+ "Usage: kumiko-guards [guards|ui|checks|comment-lang|public-exports-changelog|licenses|security|outdated|list] [flags]",
43
53
  );
44
54
  console.log();
45
55
  for (const sub of SUBCOMMANDS) {
@@ -91,6 +101,17 @@ function printInventory(flags: readonly string[]): void {
91
101
  console.log(JSON.stringify(inventory));
92
102
  }
93
103
 
104
+ const STANDALONE_SUBCOMMANDS: Record<
105
+ string,
106
+ (flags: readonly string[]) => number | Promise<number>
107
+ > = {
108
+ "comment-lang": runCommentLangCli,
109
+ "public-exports-changelog": runPublicExportsChangelogCli,
110
+ licenses: (flags) => runLicensesCli(flags),
111
+ security: (flags) => runSecurityCli(flags),
112
+ outdated: (flags) => runOutdatedCli(flags),
113
+ };
114
+
94
115
  async function main(): Promise<void> {
95
116
  const { subcommand, flags } = parseInvocation(process.argv);
96
117
 
@@ -101,14 +122,10 @@ async function main(): Promise<void> {
101
122
  }
102
123
 
103
124
  // Not part of the no-subcommand run: comment-lang needs a baseline or a git base ref,
104
- // public-exports-changelog only applies to the kumiko-framework repo.
105
- if (subcommand === "comment-lang") {
106
- process.exit(runCommentLangCli(flags));
107
- }
108
-
109
- if (subcommand === "public-exports-changelog") {
110
- process.exit(await runPublicExportsChangelogCli(flags));
111
- }
125
+ // public-exports-changelog only applies to the kumiko-framework repo, and
126
+ // licenses/security/outdated call the npm registry or `bun audit` (network).
127
+ const standalone = subcommand === undefined ? undefined : STANDALONE_SUBCOMMANDS[subcommand];
128
+ if (standalone) process.exit(await standalone(flags));
112
129
 
113
130
  let failed = 0;
114
131
  if (subcommand === undefined || subcommand === "guards") {
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "Exceptions for check-licenses.ts. Each exception needs a reason, a reviewer and an expiry date.",
3
+ "exceptions": [
4
+ {
5
+ "package": "lightningcss",
6
+ "version": "*",
7
+ "license": "MPL-2.0",
8
+ "reason": "Transitive Dependency via Expo/Metro CSS-Tooling. MPL-2.0 ist file-level Copyleft — betrifft nur Modifikationen an lightningcss selbst, nicht unseren Code. Nur als Dependency genutzt, nicht modifiziert.",
9
+ "reviewedBy": "Marc, 2026-04-15",
10
+ "expires": "2027-04-15"
11
+ },
12
+ {
13
+ "package": "lightningcss-*",
14
+ "version": "*",
15
+ "license": "MPL-2.0",
16
+ "reason": "Platform-Binaries zu lightningcss (darwin-arm64/darwin-x64/linux-x64-gnu/linux-x64-musl/linux-arm64-gnu/linux-arm64-musl/linux-arm-gnueabihf/android-arm64/freebsd-x64/win32-x64-msvc/win32-arm64-msvc). Wildcard-Match — neue Plattform-Binary kommt automatisch durch ohne weiteren Eintrag.",
17
+ "reviewedBy": "Marc, 2026-04-26",
18
+ "expires": "2027-04-26"
19
+ },
20
+ {
21
+ "package": "spdx-exceptions",
22
+ "version": "*",
23
+ "license": "CC-BY-3.0",
24
+ "reason": "Transitive Dep via SPDX-License-Tooling selbst. CC-BY-3.0 deckt nur die SPDX-Exception-Daten (eine Liste von License-IDs), keine Code-Verteilung — wir nutzen das Package nur zur Laufzeit für Dep-Scanning, kein Re-Distribute.",
25
+ "reviewedBy": "Marc, 2026-05-01",
26
+ "expires": "2027-05-01"
27
+ },
28
+ {
29
+ "package": "@logdna/tail-file",
30
+ "version": "*",
31
+ "license": "SEE LICENSE IN LICENSE",
32
+ "reason": "License-File ist im Package selbst (LICENSE), npm-metadata zeigt nur den Pointer. Apache-2.0 ist die echte Lizenz (siehe node_modules/@logdna/tail-file/LICENSE). Transitive Dep.",
33
+ "reviewedBy": "Marc, 2026-05-01",
34
+ "expires": "2027-05-01"
35
+ },
36
+ {
37
+ "package": "spawndamnit",
38
+ "version": "*",
39
+ "license": "SEE LICENSE IN LICENSE",
40
+ "reason": "Transitive Dep via @changesets/cli. License-File im Package: MIT (Copyright James Kyle, Mitautor von Changesets). npm-metadata zeigt nur den Pointer. Pattern wie @logdna/tail-file.",
41
+ "reviewedBy": "Marc, 2026-05-06",
42
+ "expires": "2027-05-06"
43
+ },
44
+ {
45
+ "package": "@cosmicdriftgamestudio/*",
46
+ "version": "*",
47
+ "license": "UNLICENSED",
48
+ "reason": "Eigene private CDGS-Workspace-Packages (kumiko-guards, später kumiko-ai-*, kumiko-designer etc.). UNLICENSED = proprietary, kein Public-License-Risk weil intern in der Org bleibt (GH-Packages access:restricted).",
49
+ "reviewedBy": "Marc, 2026-05-06",
50
+ "expires": "2030-05-06"
51
+ },
52
+ {
53
+ "package": "@cosmicdrift/*",
54
+ "version": "*",
55
+ "license": "BUSL-1.1",
56
+ "reason": "Eigene Public-Workspace-Packages (kumiko-framework + 6 weitere). Wir sind der Licensor — BUSL-1.1 ist unser eigener Lizenz-Choice für die public NPM-Pakete. Der License-Check denied BUSL-1.1 als generic-Pattern (für Third-Party-Deps korrekt), aber für eigene Packages ist's der designierte Lizenz.",
57
+ "reviewedBy": "Marc, 2026-05-06",
58
+ "expires": "2030-05-06"
59
+ },
60
+ {
61
+ "package": "create-kumiko-app",
62
+ "version": "*",
63
+ "license": "BUSL-1.1",
64
+ "reason": "Unscoped Workspace-Paket — `bun create kumiko-app` resolved zu `bunx create-kumiko-app`, daher KEIN @cosmicdrift/-Scope möglich. Analog zur @cosmicdrift/*-Exception unser eigener Lizenz-Choice; eigene Eintrag weil Wildcard-Pattern unscoped Pakete nicht matched.",
65
+ "reviewedBy": "Marc, 2026-06-21",
66
+ "expires": "2030-06-21"
67
+ },
68
+ {
69
+ "package": "khroma",
70
+ "version": "*",
71
+ "license": "MIT",
72
+ "reason": "Transitive dep via mermaid (docs). npm metadata reports license=null; package LICENSE is MIT.",
73
+ "reviewedBy": "Marc, 2026-07-06",
74
+ "expires": "2027-07-06"
75
+ }
76
+ ]
77
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "Exceptions file for check-security.ts. Each exception needs reason, reviewer, and expiry (max ~30 days).",
3
+ "exceptions": []
4
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: the generated agent docs under `packages/framework/agents/` must
4
+ * match what `bun run gen:agent-docs` renders from the current sources
5
+ * (feature manifest, renderer-web exports, sample recipes), and every
6
+ * `agents/<file>.md` path the hand-written `packages/framework/AGENTS.md`
7
+ * points at must exist in that generated set.
8
+ *
9
+ * Only the framework repo itself has these files; consumer repos are
10
+ * `notApplicable`.
11
+ *
12
+ * Usage:
13
+ * bun guard-agent-docs-drift.ts
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { AGENT_DOCS_DIR, readAgentDocsOnDisk, renderAgentDocs } from "./_lib/agent-docs";
19
+ import {
20
+ type GuardViolation,
21
+ type RepoCheck,
22
+ type RepoCheckOutcome,
23
+ reportResults,
24
+ runRepoChecks,
25
+ } from "./_lib/guard-kit";
26
+ import type { RepoRoot } from "./_lib/roots";
27
+
28
+ const FRAMEWORK_PACKAGE_JSON_REL = "packages/framework/package.json";
29
+ const FRAMEWORK_PACKAGE_NAME = "@cosmicdrift/kumiko-framework";
30
+ const AGENTS_MD_REL = "packages/framework/AGENTS.md";
31
+ const AGENTS_REF_RE = /`agents\/([A-Za-z0-9._/-]+\.md)`/g;
32
+ const REGENERATE_HINT = "run `bun run gen:agent-docs` and commit the result";
33
+
34
+ function isFrameworkRepo(root: RepoRoot): boolean {
35
+ const packageJsonPath = join(root.absPath, FRAMEWORK_PACKAGE_JSON_REL);
36
+ if (!existsSync(packageJsonPath)) return false;
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
39
+ return (
40
+ typeof parsed === "object" &&
41
+ parsed !== null &&
42
+ "name" in parsed &&
43
+ parsed.name === FRAMEWORK_PACKAGE_NAME
44
+ );
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function firstDifferingLine(expected: string, actual: string): number {
51
+ const expectedLines = expected.split("\n");
52
+ const actualLines = actual.split("\n");
53
+ const length = Math.max(expectedLines.length, actualLines.length);
54
+ for (let index = 0; index < length; index++) {
55
+ if (expectedLines[index] !== actualLines[index]) return index + 1;
56
+ }
57
+ return 1;
58
+ }
59
+
60
+ function agentsMdReferenceViolations(
61
+ absPath: string,
62
+ generated: ReadonlyMap<string, string>,
63
+ ): GuardViolation[] {
64
+ const agentsMdPath = join(absPath, AGENTS_MD_REL);
65
+ if (!existsSync(agentsMdPath)) return [];
66
+ const violations: GuardViolation[] = [];
67
+ const lines = readFileSync(agentsMdPath, "utf-8").split("\n");
68
+ lines.forEach((text, index) => {
69
+ for (const match of text.matchAll(AGENTS_REF_RE)) {
70
+ const key = match[1];
71
+ if (key === undefined || generated.has(key)) continue;
72
+ violations.push({
73
+ file: AGENTS_MD_REL,
74
+ line: index + 1,
75
+ message: `references agents/${key}, which is not generated. Fix the reference or add the source (an example needs samples/recipes/<slug>/package.json).`,
76
+ });
77
+ }
78
+ });
79
+ return violations;
80
+ }
81
+
82
+ function rootViolations(absPath: string): { violations: GuardViolation[]; fileCount: number } {
83
+ const generated = renderAgentDocs(absPath);
84
+ const onDisk = readAgentDocsOnDisk(absPath);
85
+ const violations: GuardViolation[] = [];
86
+ for (const [rel, expected] of generated) {
87
+ const file = `${AGENT_DOCS_DIR}/${rel}`;
88
+ const actual = onDisk.get(rel);
89
+ if (actual === undefined) {
90
+ violations.push({
91
+ file,
92
+ line: 1,
93
+ message: `generated file is missing; ${REGENERATE_HINT}`,
94
+ });
95
+ } else if (actual !== expected) {
96
+ violations.push({
97
+ file,
98
+ line: firstDifferingLine(expected, actual),
99
+ message: `content differs from the generated output; ${REGENERATE_HINT}`,
100
+ });
101
+ }
102
+ }
103
+ for (const rel of onDisk.keys()) {
104
+ if (generated.has(rel)) continue;
105
+ violations.push({
106
+ file: `${AGENT_DOCS_DIR}/${rel}`,
107
+ line: 1,
108
+ message: `stale file no longer produced by the generator; ${REGENERATE_HINT}`,
109
+ });
110
+ }
111
+ violations.push(...agentsMdReferenceViolations(absPath, generated));
112
+ return { violations, fileCount: generated.size };
113
+ }
114
+
115
+ export const check: RepoCheck = {
116
+ name: "agent-docs-drift",
117
+ hint: "Run `bun run gen:agent-docs` and commit packages/framework/agents/.",
118
+ run(roots): RepoCheckOutcome {
119
+ const frameworkRoots = roots.filter(isFrameworkRepo);
120
+ if (frameworkRoots.length === 0) {
121
+ return { violations: [], matchedFiles: 0, notApplicable: true };
122
+ }
123
+ const violations: GuardViolation[] = [];
124
+ let matchedFiles = 0;
125
+ for (const root of frameworkRoots) {
126
+ const result = rootViolations(root.absPath);
127
+ violations.push(...result.violations);
128
+ matchedFiles += result.fileCount;
129
+ }
130
+ return { violations, matchedFiles, notApplicable: false };
131
+ },
132
+ };
133
+
134
+ if (import.meta.main) {
135
+ const failed = reportResults(await runRepoChecks([check]));
136
+ process.exit(failed > 0 ? 1 : 0);
137
+ }