nerdsystem 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -2
  3. package/dist/api.d.ts +58 -0
  4. package/dist/api.js +59 -0
  5. package/dist/commands/agents.d.ts +9 -0
  6. package/dist/commands/agents.js +45 -0
  7. package/dist/commands/auth.d.ts +7 -0
  8. package/dist/commands/auth.js +25 -0
  9. package/dist/commands/billing.d.ts +14 -0
  10. package/dist/commands/billing.js +42 -0
  11. package/dist/commands/builds.d.ts +12 -0
  12. package/dist/commands/builds.js +40 -0
  13. package/dist/commands/check.d.ts +20 -0
  14. package/dist/commands/check.js +145 -0
  15. package/dist/commands/db.d.ts +9 -0
  16. package/dist/commands/db.js +32 -0
  17. package/dist/commands/deploy.d.ts +27 -0
  18. package/dist/commands/deploy.js +119 -0
  19. package/dist/commands/dev.d.ts +3 -0
  20. package/dist/commands/dev.js +38 -0
  21. package/dist/commands/doctor.d.ts +4 -0
  22. package/dist/commands/doctor.js +42 -0
  23. package/dist/commands/github.d.ts +17 -0
  24. package/dist/commands/github.js +49 -0
  25. package/dist/commands/init.d.ts +15 -0
  26. package/dist/commands/init.js +107 -0
  27. package/dist/commands/login.d.ts +7 -0
  28. package/dist/commands/login.js +21 -0
  29. package/dist/commands/ops.d.ts +29 -0
  30. package/dist/commands/ops.js +113 -0
  31. package/dist/commands/org.d.ts +47 -0
  32. package/dist/commands/org.js +121 -0
  33. package/dist/commands/verify.d.ts +24 -0
  34. package/dist/commands/verify.js +50 -0
  35. package/dist/config.d.ts +7 -0
  36. package/dist/config.js +22 -0
  37. package/dist/gates/attempts.d.ts +18 -0
  38. package/dist/gates/attempts.js +38 -0
  39. package/dist/gates/code.d.ts +15 -0
  40. package/dist/gates/code.js +179 -0
  41. package/dist/gates/fs.d.ts +7 -0
  42. package/dist/gates/fs.js +35 -0
  43. package/dist/gates/index.d.ts +26 -0
  44. package/dist/gates/index.js +68 -0
  45. package/dist/gates/rules.d.ts +19 -0
  46. package/dist/gates/rules.js +58 -0
  47. package/dist/gates/secrets.d.ts +11 -0
  48. package/dist/gates/secrets.js +98 -0
  49. package/dist/gates/suppressions.d.ts +2 -0
  50. package/dist/gates/suppressions.js +57 -0
  51. package/dist/gates/text.d.ts +31 -0
  52. package/dist/gates/text.js +72 -0
  53. package/dist/gates/tools.d.ts +5 -0
  54. package/dist/gates/tools.js +69 -0
  55. package/dist/gates/types.d.ts +72 -0
  56. package/dist/gates/types.js +18 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +268 -0
  59. package/dist/project.d.ts +11 -0
  60. package/dist/project.js +51 -0
  61. package/dist/util.d.ts +21 -0
  62. package/dist/util.js +39 -0
  63. package/package.json +55 -3
  64. package/rules/semgrep.yml +77 -0
  65. package/template/.claude/settings.json +12 -0
  66. package/template/.claude/skills/nerdsystem/SKILL.md +123 -0
  67. package/template/.cursor/mcp.json +8 -0
  68. package/template/.cursor/rules/nerdsystem.mdc +124 -0
  69. package/template/.gemini/settings.json +8 -0
  70. package/template/.github/copilot-instructions.md +120 -0
  71. package/template/.github/dependabot.yml +14 -0
  72. package/template/.mcp.json +8 -0
  73. package/template/.vscode/mcp.json +9 -0
  74. package/template/AGENTS.md +120 -0
  75. package/template/CLAUDE.md +32 -0
  76. package/template/GEMINI.md +120 -0
  77. package/template/drizzle.config.ts +7 -0
  78. package/template/gitignore +7 -0
  79. package/template/index.html +12 -0
  80. package/template/migrations/0000_init.sql +7 -0
  81. package/template/nerd.json +5 -0
  82. package/template/package.json +33 -0
  83. package/template/scripts/sync-agents.mjs +28 -0
  84. package/template/src/client/App.tsx +182 -0
  85. package/template/src/client/api.ts +42 -0
  86. package/template/src/client/main.tsx +10 -0
  87. package/template/src/client/styles.css +16 -0
  88. package/template/src/client/vite-env.d.ts +1 -0
  89. package/template/src/server/db/index.ts +7 -0
  90. package/template/src/server/db/schema.ts +14 -0
  91. package/template/src/server/index.ts +23 -0
  92. package/template/src/server/routes/todos.ts +57 -0
  93. package/template/tsconfig.client.json +18 -0
  94. package/template/tsconfig.json +4 -0
  95. package/template/tsconfig.worker.json +18 -0
  96. package/template/vite.config.ts +7 -0
  97. package/template/wrangler.jsonc +18 -0
@@ -0,0 +1,121 @@
1
+ import { ApiClient } from "../api.js";
2
+ import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
3
+ import { CliError, log, printJson, table } from "../util.js";
4
+ function orgSlug(opts) {
5
+ return opts.org ?? readNerdJson(findProjectRoot()).org;
6
+ }
7
+ export async function membersList(opts) {
8
+ const org = orgSlug(opts);
9
+ const r = await ApiClient.fromConfig().get(`/v1/orgs/${org}/members`);
10
+ if (opts.json)
11
+ return printJson(r);
12
+ table(r.members.map((m) => ({ email: m.email, name: m.name, role: m.role, joined: m.joinedAt })), ["email", "name", "role", "joined"]);
13
+ if (r.invites.length) {
14
+ log.info("\npending invites:");
15
+ table(r.invites.map((i) => ({ email: i.email, role: i.role, expires: i.expiresAt, id: i.id })), ["email", "role", "expires", "id"]);
16
+ }
17
+ }
18
+ export async function membersInvite(email, opts) {
19
+ if (!email)
20
+ throw new CliError("Usage: nerd members invite someone@example.com [--role admin|member]");
21
+ const org = orgSlug(opts);
22
+ const r = await ApiClient.fromConfig().post(`/v1/orgs/${org}/invites`, { email, role: opts.role ?? "member" });
23
+ if (opts.json)
24
+ return printJson(r);
25
+ log.ok(`Invited ${r.invite.email} as ${r.invite.role}. The invitation email is valid for seven days.`);
26
+ if (r.debugToken)
27
+ log.info(` (fake email provider) accept link: /invite/${r.debugToken}`);
28
+ }
29
+ export async function membersRemove(userId, opts) {
30
+ if (!userId)
31
+ throw new CliError("Usage: nerd members remove <userId>");
32
+ const org = orgSlug(opts);
33
+ const r = await ApiClient.fromConfig().delete(`/v1/orgs/${org}/members/${userId}`);
34
+ if (opts.json)
35
+ return printJson(r);
36
+ log.ok("Removed.");
37
+ }
38
+ export async function domainsList(opts) {
39
+ const nerd = readNerdJson(findProjectRoot());
40
+ const r = await ApiClient.fromConfig().get(projectPath(nerd, "/domains"));
41
+ if (opts.json)
42
+ return printJson(r);
43
+ if (!r.domains.length)
44
+ return log.info(`No custom domains. Add one: nerd domains add app.example.com`);
45
+ table(r.domains.map((d) => ({ hostname: d.hostname, status: d.status, ssl: d.sslStatus ?? "", error: d.lastError ?? "", id: d.id })), ["hostname", "status", "ssl", "error", "id"]);
46
+ log.info(`\nPoint each hostname at ${r.cnameTarget} with a CNAME record.`);
47
+ }
48
+ export async function domainsAdd(hostname, opts) {
49
+ if (!hostname)
50
+ throw new CliError("Usage: nerd domains add app.example.com");
51
+ const nerd = readNerdJson(findProjectRoot());
52
+ const r = await ApiClient.fromConfig().post(projectPath(nerd, "/domains"), { hostname });
53
+ if (opts.json)
54
+ return printJson(r);
55
+ log.ok(`Added ${r.domain.hostname}. Create this DNS record, then check with \`nerd domains\`:\n\n ${r.domain.hostname} CNAME ${r.cnameTarget}`);
56
+ }
57
+ export async function domainsRemove(id, opts) {
58
+ if (!id)
59
+ throw new CliError("Usage: nerd domains remove <domainId>");
60
+ const nerd = readNerdJson(findProjectRoot());
61
+ const r = await ApiClient.fromConfig().delete(projectPath(nerd, `/domains/${id}`));
62
+ if (opts.json)
63
+ return printJson(r);
64
+ log.ok("Removed.");
65
+ }
66
+ export async function logs(opts) {
67
+ const nerd = readNerdJson(findProjectRoot());
68
+ const params = new URLSearchParams({ since: opts.since ?? "30m", limit: opts.limit ?? "100" });
69
+ if (opts.q)
70
+ params.set("q", opts.q);
71
+ if (opts.pr)
72
+ params.set("environment", "preview"), params.set("pr", opts.pr);
73
+ const r = await ApiClient.fromConfig().get(projectPath(nerd, `/logs?${params}`));
74
+ if (opts.json)
75
+ return printJson(r);
76
+ if (!r.events.length)
77
+ return log.info(`No log events for ${r.scriptName} in the last ${opts.since ?? "30m"}.`);
78
+ for (const e of [...r.events].reverse())
79
+ process.stdout.write(`${e.timestamp} ${e.level.padEnd(5)} ${e.context ? `${e.context} ` : ""}${e.message}\n`);
80
+ }
81
+ export async function auditExport(opts) {
82
+ const org = orgSlug(opts);
83
+ const api = ApiClient.fromConfig();
84
+ if (opts.json)
85
+ return printJson(await api.get(`/v1/orgs/${org}/audit?format=json`));
86
+ const res = await fetch(`${api.apiUrl}/v1/orgs/${org}/audit?format=csv`, { headers: { authorization: `Bearer ${process.env.NERD_TOKEN ?? (await import("../config.js")).loadConfig().token ?? ""}` } });
87
+ if (!res.ok)
88
+ throw new CliError(`${res.status}: ${(await res.text()).slice(0, 200)}`);
89
+ const csv = await res.text();
90
+ if (opts.out)
91
+ (await import("node:fs")).writeFileSync(opts.out, csv), log.ok(`Wrote ${opts.out}`);
92
+ else
93
+ process.stdout.write(csv);
94
+ }
95
+ export async function clientsList(opts) {
96
+ const org = orgSlug(opts);
97
+ const r = await ApiClient.fromConfig().get(`/v1/orgs/${org}/clients`);
98
+ if (opts.json)
99
+ return printJson(r);
100
+ if (!r.clients.length)
101
+ return log.info(`No client orgs yet. Create one with: nerd clients create <slug> --name "Client name"`);
102
+ table(r.clients.map((c) => ({ slug: c.slug, name: c.name, projects: String(c.projects), created: c.createdAt })), ["slug", "name", "projects", "created"]);
103
+ }
104
+ export async function clientsCreate(slug, opts) {
105
+ if (!slug)
106
+ throw new CliError('Usage: nerd clients create <slug> --name "Client name"');
107
+ const org = orgSlug(opts);
108
+ const r = await ApiClient.fromConfig().post(`/v1/orgs/${org}/clients`, { slug, name: opts.name ?? slug });
109
+ if (opts.json)
110
+ return printJson(r);
111
+ log.ok(`Created client org ${r.client.slug}, billed through ${org}. Start a project in it with: npx nerdsystem init my-app --org ${r.client.slug}`);
112
+ }
113
+ export async function clientsRelease(slug, opts) {
114
+ if (!slug)
115
+ throw new CliError("Usage: nerd clients release <slug>");
116
+ const org = orgSlug(opts);
117
+ const r = await ApiClient.fromConfig().post(`/v1/orgs/${org}/clients/${slug}/release`, {});
118
+ if (opts.json)
119
+ return printJson(r);
120
+ log.ok(`Released ${r.client.slug}. ${r.note}`);
121
+ }
@@ -0,0 +1,24 @@
1
+ import { type Finding, type GateResult } from "../gates/index.js";
2
+ export interface VerifyOutput {
3
+ ok: boolean;
4
+ /** Decision 3 shape: every failure the agent (or a person) must act on. */
5
+ failures: Finding[];
6
+ /** Rules that hit the retry cap; the agent must stop and hand over. */
7
+ stop: string[];
8
+ results: GateResult[];
9
+ }
10
+ /** Suppressions a human approved in the dashboard, so they stop being reported. Best effort. */
11
+ export declare function approvedSuppressions(root: string): Promise<Set<string>>;
12
+ /** Runs every gate that can run locally. With a dist/ present, also scans the built output. */
13
+ export declare function runVerify(root: string, opts: {
14
+ withBuild: boolean;
15
+ stopOnFail: boolean;
16
+ quiet?: boolean;
17
+ onlyRules?: string[] | undefined;
18
+ trackAttempts?: boolean;
19
+ }): Promise<VerifyOutput>;
20
+ export declare function verify(opts: {
21
+ json: boolean;
22
+ "no-build": boolean;
23
+ rule?: string | undefined;
24
+ }): Promise<void>;
@@ -0,0 +1,50 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ApiClient } from "../api.js";
4
+ import { BUILD_GATES, knownSecretsFrom, runGates, SOURCE_GATES } from "../gates/index.js";
5
+ import { findProjectRoot, projectPath, readDevVars, readNerdJson } from "../project.js";
6
+ import { CliError, log } from "../util.js";
7
+ import { readBuildOutput } from "./deploy.js";
8
+ /** Suppressions a human approved in the dashboard, so they stop being reported. Best effort. */
9
+ export async function approvedSuppressions(root) {
10
+ try {
11
+ const nerd = readNerdJson(root);
12
+ const r = await ApiClient.fromConfig().get(projectPath(nerd, "/gates/overrides"));
13
+ return new Set(r.overrides.map((o) => o.fingerprint));
14
+ }
15
+ catch {
16
+ return new Set();
17
+ }
18
+ }
19
+ /** Runs every gate that can run locally. With a dist/ present, also scans the built output. */
20
+ export async function runVerify(root, opts) {
21
+ const logLine = opts.quiet ? () => { } : (l) => log.info(l);
22
+ const knownSecrets = knownSecretsFrom(readDevVars(root));
23
+ const approved = await approvedSuppressions(root);
24
+ const ctxBase = { root, knownSecrets, approvedSuppressions: approved, onlyRules: opts.onlyRules, log: logLine };
25
+ const source = await runGates(SOURCE_GATES, ctxBase, { stopOnFail: opts.stopOnFail, trackAttempts: opts.trackAttempts ?? true });
26
+ let results = source.results;
27
+ let failures = source.failures;
28
+ let stop = source.capped;
29
+ if (opts.withBuild && source.ok && existsSync(path.join(root, "dist"))) {
30
+ const build = readBuildOutput(root);
31
+ const built = await runGates(BUILD_GATES.filter((g) => g.name !== "policy"), { ...ctxBase, build }, { stopOnFail: opts.stopOnFail, trackAttempts: false });
32
+ results = [...results, ...built.results];
33
+ failures = [...failures, ...built.failures];
34
+ stop = [...stop, ...built.capped];
35
+ }
36
+ return { ok: results.every((r) => r.status !== "fail"), failures, stop, results };
37
+ }
38
+ export async function verify(opts) {
39
+ const root = findProjectRoot();
40
+ const out = await runVerify(root, { withBuild: !opts["no-build"], stopOnFail: false, quiet: opts.json, onlyRules: opts.rule ? [opts.rule] : undefined });
41
+ if (opts.json)
42
+ process.stdout.write(`${JSON.stringify({ ok: out.ok, failures: out.failures, stop: out.stop }, null, 2)}\n`);
43
+ else if (out.ok)
44
+ log.ok("All gates passed");
45
+ const human = out.failures.filter((f) => f.audience === "human" && f.severity === "block");
46
+ if (!opts.json && human.length)
47
+ log.warn(`${human.length} finding(s) need a person, not the agent: ${human.map((f) => f.rule_id).join(", ")}`);
48
+ if (!out.ok)
49
+ throw new CliError(`${out.results.filter((r) => r.status === "fail").length} gate(s) failed`, 2);
50
+ }
@@ -0,0 +1,7 @@
1
+ export interface GlobalConfig {
2
+ apiUrl: string;
3
+ token?: string;
4
+ }
5
+ export declare const DEFAULT_API_URL = "https://api.nerdsystem.com";
6
+ export declare function loadConfig(): GlobalConfig;
7
+ export declare function saveConfig(cfg: GlobalConfig): string;
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const DEFAULT_API_URL = "https://api.nerdsystem.com";
5
+ function configPath() {
6
+ const base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
7
+ return path.join(base, "nerdsystem", "config.json");
8
+ }
9
+ export function loadConfig() {
10
+ const p = configPath();
11
+ const file = existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {};
12
+ return {
13
+ apiUrl: process.env.NERD_API_URL ?? file.apiUrl ?? DEFAULT_API_URL,
14
+ ...(process.env.NERD_TOKEN ?? file.token ? { token: process.env.NERD_TOKEN ?? file.token } : {}),
15
+ };
16
+ }
17
+ export function saveConfig(cfg) {
18
+ const p = configPath();
19
+ mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
20
+ writeFileSync(p, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
21
+ return p;
22
+ }
@@ -0,0 +1,18 @@
1
+ import type { Finding } from "./types.js";
2
+ /**
3
+ * Attempt counting for the agent loop. A rule that fails on consecutive runs gets attempt 1, 2, 3;
4
+ * on the fourth consecutive failure the runner stops the loop and hands the summary to a human.
5
+ * State lives in .nerd/attempts.json (gitignored); a rule that passes resets to zero.
6
+ */
7
+ export declare const MAX_ATTEMPTS = 3;
8
+ interface State {
9
+ [rule_id: string]: number;
10
+ }
11
+ export declare function readAttempts(root: string): State;
12
+ export declare function writeAttempts(root: string, state: State): void;
13
+ /** Stamps attempt numbers on findings and returns the rules that have hit the cap. */
14
+ export declare function applyAttempts(root: string, findings: Finding[], persist?: boolean): {
15
+ findings: Finding[];
16
+ capped: string[];
17
+ };
18
+ export {};
@@ -0,0 +1,38 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Attempt counting for the agent loop. A rule that fails on consecutive runs gets attempt 1, 2, 3;
5
+ * on the fourth consecutive failure the runner stops the loop and hands the summary to a human.
6
+ * State lives in .nerd/attempts.json (gitignored); a rule that passes resets to zero.
7
+ */
8
+ export const MAX_ATTEMPTS = 3;
9
+ function file(root) {
10
+ return path.join(root, ".nerd", "attempts.json");
11
+ }
12
+ export function readAttempts(root) {
13
+ try {
14
+ return JSON.parse(readFileSync(file(root), "utf8"));
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function writeAttempts(root, state) {
21
+ mkdirSync(path.dirname(file(root)), { recursive: true });
22
+ writeFileSync(file(root), `${JSON.stringify(state, null, 2)}\n`);
23
+ const gi = path.join(root, ".gitignore");
24
+ if (existsSync(gi) && !readFileSync(gi, "utf8").includes(".nerd/"))
25
+ writeFileSync(gi, `${readFileSync(gi, "utf8").replace(/\n?$/, "\n")}.nerd/\n`);
26
+ }
27
+ /** Stamps attempt numbers on findings and returns the rules that have hit the cap. */
28
+ export function applyAttempts(root, findings, persist = true) {
29
+ const prev = readAttempts(root);
30
+ const next = {};
31
+ const failedRules = new Set(findings.filter((f) => f.severity === "block").map((f) => f.rule_id));
32
+ for (const r of failedRules)
33
+ next[r] = (prev[r] ?? 0) + 1;
34
+ if (persist)
35
+ writeAttempts(root, next);
36
+ const capped = Array.from(failedRules).filter((r) => (next[r] ?? 0) > MAX_ATTEMPTS);
37
+ return { findings: findings.map((f) => ({ ...f, attempt: next[f.rule_id] ?? 1 })), capped };
38
+ }
@@ -0,0 +1,15 @@
1
+ import { type Gate } from "./types.js";
2
+ export declare function exec(cmd: string, args: string[], cwd: string): Promise<{
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }>;
7
+ export declare function hasTool(name: string): Promise<boolean>;
8
+ /** Platform rules, as `nerd check` enforces them. The rule text is ours, so it is safe to show verbatim. */
9
+ export declare const rulesGate: Gate;
10
+ export declare const typecheckGate: Gate;
11
+ /** Known vulnerabilities in dependencies. Critical blocks; high warns. Package names come from the registry, not the repo. */
12
+ export declare const depsGate: Gate;
13
+ export declare const policyGate: Gate;
14
+ /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
15
+ export declare const piiGate: Gate;
@@ -0,0 +1,179 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { staticChecks } from "../commands/check.js";
5
+ import { lineOf, sourceFiles } from "./fs.js";
6
+ import { finding } from "./secrets.js";
7
+ import { counts, result, verdict } from "./types.js";
8
+ export function exec(cmd, args, cwd) {
9
+ return new Promise((resolve) => {
10
+ const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
11
+ let stdout = "";
12
+ let stderr = "";
13
+ child.stdout.on("data", (d) => (stdout += d));
14
+ child.stderr.on("data", (d) => (stderr += d));
15
+ child.on("error", (e) => resolve({ code: 127, stdout, stderr: String(e) }));
16
+ child.on("exit", (code) => resolve({ code: code ?? 1, stdout, stderr }));
17
+ });
18
+ }
19
+ export async function hasTool(name) {
20
+ const r = await exec(name, ["--version"], process.cwd());
21
+ return r.code === 0;
22
+ }
23
+ /** Platform rules, as `nerd check` enforces them. The rule text is ours, so it is safe to show verbatim. */
24
+ export const rulesGate = {
25
+ name: "rules",
26
+ title: "Platform rules",
27
+ blocking: true,
28
+ rulePrefixes: ["rules/"],
29
+ async run(ctx) {
30
+ const t = Date.now();
31
+ const problems = staticChecks(ctx.root);
32
+ const findings = problems.map((p, i) => {
33
+ const advisory = p.endsWith("(warning)") || p.startsWith("Agent instruction files");
34
+ const fileMatch = /^([\w./-]+\.(ts|tsx|json|jsonc|md|yml))\b/.exec(p);
35
+ return finding(advisory ? "rules/advisory" : "rules/platform", { file: fileMatch?.[1], risk: advisory ? p : `${p} The app would not run correctly on the platform.`, fingerprint: `rules-${i}-${p.slice(0, 40)}` });
36
+ });
37
+ return result(this, verdict(findings), findings.length ? counts(findings) : "All platform rules pass", findings, t);
38
+ },
39
+ };
40
+ export const typecheckGate = {
41
+ name: "typecheck",
42
+ title: "TypeScript",
43
+ blocking: true,
44
+ rulePrefixes: ["typecheck/"],
45
+ async run(ctx) {
46
+ const t = Date.now();
47
+ const r = await exec("npx", ["tsc", "-b", "--pretty", "false"], ctx.root);
48
+ if (r.code === 0)
49
+ return result(this, "pass", "No type errors", [], t, "tsc");
50
+ const findings = [];
51
+ for (const line of `${r.stdout}\n${r.stderr}`.split("\n")) {
52
+ const m = /^(.+?)\((\d+),\d+\): error (TS\d+): /.exec(line.trim());
53
+ // The compiler's message can quote identifiers from the code, so only the file, line, and code are reported.
54
+ if (m)
55
+ findings.push(finding("typecheck/error", { file: m[1], line: Number(m[2]), fix: `Run \`npx tsc -b\` to see ${m[3]} at ${m[1]}:${m[2]} and fix it.` }));
56
+ if (findings.length >= 50)
57
+ break;
58
+ }
59
+ if (!findings.length)
60
+ findings.push(finding("typecheck/failed", {}));
61
+ return result(this, "fail", counts(findings), findings, t, "tsc");
62
+ },
63
+ };
64
+ function depsVerdictSummary(findings) {
65
+ if (findings.some((f) => f.rule_id === "deps/lockfile-required"))
66
+ return "No lockfile; commit one so installs are reproducible";
67
+ return findings.length ? counts(findings) : "No known vulnerabilities";
68
+ }
69
+ /** Known vulnerabilities in dependencies. Critical blocks; high warns. Package names come from the registry, not the repo. */
70
+ export const depsGate = {
71
+ name: "dependencies",
72
+ title: "Dependency vulnerabilities",
73
+ blocking: true,
74
+ rulePrefixes: ["deps/"],
75
+ async run(ctx) {
76
+ const t = Date.now();
77
+ const findings = [];
78
+ const lock = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"].find((f) => existsSync(path.join(ctx.root, f)));
79
+ if (!lock)
80
+ findings.push(finding("deps/lockfile-required", {}));
81
+ const sev = (s) => (s.toUpperCase() === "CRITICAL" ? "deps/critical-vulnerability" : s.toUpperCase() === "HIGH" ? "deps/high-vulnerability" : "deps/advisory");
82
+ if (await hasTool("osv-scanner")) {
83
+ const r = await exec("osv-scanner", ["scan", "source", "--format", "json", "--lockfile", lock ?? "pnpm-lock.yaml"], ctx.root);
84
+ try {
85
+ const json = JSON.parse(r.stdout);
86
+ for (const res of json.results ?? [])
87
+ for (const p of res.packages ?? [])
88
+ for (const v of p.vulnerabilities) {
89
+ const id = sev(v.database_specific?.severity ?? "MODERATE");
90
+ findings.push(finding(id, { file: lock, fix: `Update ${p.package.name} from ${p.package.version} to a version without ${v.id} (https://osv.dev/vulnerability/${v.id}); \`pnpm update ${p.package.name}\`.`, fingerprint: `${v.id}:${p.package.name}` }));
91
+ }
92
+ return result(this, verdict(findings), depsVerdictSummary(findings), findings, t, "osv-scanner");
93
+ }
94
+ catch {
95
+ // fall through to the package manager's audit
96
+ }
97
+ }
98
+ const pm = lock === "package-lock.json" ? "npm" : lock === "yarn.lock" ? "yarn" : "pnpm";
99
+ const r = await exec(pm, pm === "pnpm" ? ["audit", "--json", "--prod"] : ["audit", "--json"], ctx.root);
100
+ try {
101
+ const json = JSON.parse(r.stdout);
102
+ for (const a of Object.values(json.advisories ?? {})) {
103
+ findings.push(finding(sev(a.severity), { file: lock, fix: `Update ${a.module_name} to a fixed version${a.url ? ` (${a.url})` : ""}; \`${pm} update ${a.module_name}\`.`, fingerprint: `${a.url ?? a.module_name}` }));
104
+ }
105
+ }
106
+ catch {
107
+ if (r.code !== 0 && !r.stdout)
108
+ findings.push(finding("deps/audit-unavailable", {}));
109
+ }
110
+ return result(this, verdict(findings), depsVerdictSummary(findings), findings, t, `${pm} audit`);
111
+ },
112
+ };
113
+ /** Packages that have no place in a hosted web app, and size caps. Deterministic policy only. */
114
+ import { BLOCKED_PACKAGES, MINING_POOLS } from "./text.js";
115
+ const MAX_WORKER_BYTES = 10 * 1024 * 1024;
116
+ const MAX_ASSET_BYTES = 25 * 1024 * 1024;
117
+ export const policyGate = {
118
+ name: "policy",
119
+ title: "Acceptable use policy",
120
+ blocking: true,
121
+ rulePrefixes: ["policy/"],
122
+ async run(ctx) {
123
+ const t = Date.now();
124
+ const findings = [];
125
+ const pkg = JSON.parse(readFileSync(path.join(ctx.root, "package.json"), "utf8"));
126
+ for (const name of Object.keys(pkg.dependencies ?? {}))
127
+ if (BLOCKED_PACKAGES.has(name))
128
+ findings.push(finding("policy/blocked-package", { file: "package.json", fix: `Remove the ${name} package and the code that uses it.`, fingerprint: `blocked:${name}` }));
129
+ for (const f of sourceFiles(ctx.root)) {
130
+ const m = MINING_POOLS.exec(f.text);
131
+ if (m)
132
+ findings.push(finding("policy/mining-pool", { file: f.rel, line: lineOf(f.text, m.index) }));
133
+ }
134
+ if (ctx.build) {
135
+ const worker = ctx.build.modules.reduce((n, m) => n + statSync(path.join(ctx.build.workerDir, m)).size, 0);
136
+ const assets = ctx.build.assetsDir ? ctx.build.assets.reduce((n, a) => n + statSync(path.join(ctx.build.assetsDir, a)).size, 0) : 0;
137
+ if (worker > MAX_WORKER_BYTES)
138
+ findings.push(finding("policy/worker-size", { risk: `The server bundle is ${Math.round(worker / 1024 / 1024)} MB; the limit is ${MAX_WORKER_BYTES / 1024 / 1024} MB.` }));
139
+ if (assets > MAX_ASSET_BYTES)
140
+ findings.push(finding("policy/assets-size", { risk: `Static assets total ${Math.round(assets / 1024 / 1024)} MB; the limit is ${MAX_ASSET_BYTES / 1024 / 1024} MB.` }));
141
+ }
142
+ return result(this, verdict(findings), findings.length ? counts(findings) : "No policy violations", findings, t);
143
+ },
144
+ };
145
+ /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
146
+ export const piiGate = {
147
+ name: "pii",
148
+ title: "Personal data handling",
149
+ blocking: false,
150
+ rulePrefixes: ["pii/"],
151
+ async run(ctx) {
152
+ const t = Date.now();
153
+ const findings = [];
154
+ const checks = [
155
+ { rule: "pii/log-credentials", re: /console\.(log|info|debug|warn|error)\([^)]*\b(password|passwd|secret|token|authorization|cookie)\b/gi },
156
+ { rule: "pii/log-request-body", re: /console\.(log|info|debug)\([^)]*\b(req\.json\(\)|body)\b/gi },
157
+ { rule: "pii/email-in-query-string", re: /[?&]email=\$\{/g },
158
+ { rule: "pii/plaintext-password-column", re: /\b(password|passwd)\b\s*:\s*text\(/g, files: /schema\.ts$/ },
159
+ { rule: "pii/unscoped-user-table", re: /\.from\(schema\.(\w+)\)\s*(\.orderBy|\.limit|\.all\(\)|\.get\(\))/g },
160
+ { rule: "pii/wildcard-cors", re: /Access-Control-Allow-Origin["']?\s*[:,]\s*["']\*["']/g },
161
+ ];
162
+ for (const f of sourceFiles(ctx.root)) {
163
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(f.rel) || !f.rel.startsWith("src/"))
164
+ continue;
165
+ for (const c of checks) {
166
+ if (c.files && !c.files.test(f.rel))
167
+ continue;
168
+ c.re.lastIndex = 0;
169
+ let m;
170
+ while ((m = c.re.exec(f.text))) {
171
+ if (c.rule === "pii/unscoped-user-table" && !/user_id|userId/.test(f.text))
172
+ continue;
173
+ findings.push(finding(c.rule, { file: f.rel, line: lineOf(f.text, m.index) }));
174
+ }
175
+ }
176
+ }
177
+ return result(this, verdict(findings), findings.length ? counts(findings) : "Nothing flagged", findings, t);
178
+ },
179
+ };
@@ -0,0 +1,7 @@
1
+ export interface SourceFile {
2
+ rel: string;
3
+ text: string;
4
+ }
5
+ /** Text files under root, skipping dependencies and build output. */
6
+ export declare function sourceFiles(root: string): SourceFile[];
7
+ export declare function lineOf(text: string, index: number): number;
@@ -0,0 +1,35 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".wrangler", ".vite", "coverage"]);
4
+ const TEXT_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".md", ".mdc", ".yml", ".yaml", ".toml", ".env", ".txt", ".html", ".css", ".sql", ".example"]);
5
+ const MAX_BYTES = 2 * 1024 * 1024;
6
+ /** Text files under root, skipping dependencies and build output. */
7
+ export function sourceFiles(root) {
8
+ const out = [];
9
+ const walk = (dir) => {
10
+ for (const entry of readdirSync(dir)) {
11
+ const full = path.join(dir, entry);
12
+ const st = statSync(full);
13
+ if (st.isDirectory()) {
14
+ if (!SKIP_DIRS.has(entry))
15
+ walk(full);
16
+ continue;
17
+ }
18
+ const ext = path.extname(entry) || (entry.startsWith(".env") ? ".env" : "");
19
+ if (!TEXT_EXT.has(ext) && !entry.startsWith(".env"))
20
+ continue;
21
+ if (st.size > MAX_BYTES)
22
+ continue;
23
+ out.push({ rel: path.relative(root, full).split(path.sep).join("/"), text: readFileSync(full, "utf8") });
24
+ }
25
+ };
26
+ walk(root);
27
+ return out;
28
+ }
29
+ export function lineOf(text, index) {
30
+ let line = 1;
31
+ for (let i = 0; i < index && i < text.length; i++)
32
+ if (text.charCodeAt(i) === 10)
33
+ line++;
34
+ return line;
35
+ }
@@ -0,0 +1,26 @@
1
+ import type { Finding, Gate, GateContext, GateResult } from "./types.js";
2
+ export type { Audience, Finding, Gate, GateContext, GateResult, GateStatus, Severity } from "./types.js";
3
+ export { RULES, ruleFor, type RuleDef } from "./rules.js";
4
+ export { mask } from "./secrets.js";
5
+ export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
6
+ /** Gates that run on source, before the build. Suppressions first so a human sees them before anything else. */
7
+ export declare const SOURCE_GATES: Gate[];
8
+ /** Gates that need the built output. */
9
+ export declare const BUILD_GATES: Gate[];
10
+ export interface GateRun {
11
+ results: GateResult[];
12
+ ok: boolean;
13
+ failed: GateResult[];
14
+ /** Every finding across gates, attempt-stamped, the agent's work list. */
15
+ failures: Finding[];
16
+ /** Rules that failed more than MAX_ATTEMPTS consecutive runs: stop and ask a person. */
17
+ capped: string[];
18
+ }
19
+ /** Runs gates in order, stopping after the first blocking failure so logs stay readable. */
20
+ export declare function runGates(gates: Gate[], ctx: GateContext, opts?: {
21
+ stopOnFail?: boolean;
22
+ trackAttempts?: boolean;
23
+ }): Promise<GateRun>;
24
+ export declare function icon(status: GateResult["status"]): string;
25
+ /** Reads server-side secret values from .dev.vars so the bundle gate can detect them in output. */
26
+ export declare function knownSecretsFrom(vars: Record<string, string>): string[];
@@ -0,0 +1,68 @@
1
+ import { bundleGate, finding, secretsGate } from "./secrets.js";
2
+ import { depsGate, piiGate, policyGate, rulesGate, typecheckGate } from "./code.js";
3
+ import { gitleaksGate, semgrepGate } from "./tools.js";
4
+ import { suppressionsGate } from "./suppressions.js";
5
+ import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
6
+ export { RULES, ruleFor } from "./rules.js";
7
+ export { mask } from "./secrets.js";
8
+ export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
9
+ /** Gates that run on source, before the build. Suppressions first so a human sees them before anything else. */
10
+ export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, semgrepGate, policyGate, piiGate, typecheckGate];
11
+ /** Gates that need the built output. */
12
+ export const BUILD_GATES = [bundleGate, policyGate];
13
+ function selectGates(gates, onlyRules) {
14
+ if (!onlyRules?.length)
15
+ return gates;
16
+ return gates.filter((g) => onlyRules.some((r) => g.rulePrefixes.some((p) => r.startsWith(p))));
17
+ }
18
+ /** Runs gates in order, stopping after the first blocking failure so logs stay readable. */
19
+ export async function runGates(gates, ctx, opts = {}) {
20
+ const results = [];
21
+ for (const g of selectGates(gates, ctx.onlyRules)) {
22
+ ctx.log(`› ${g.title}`);
23
+ let r;
24
+ try {
25
+ r = await g.run(ctx);
26
+ }
27
+ catch (err) {
28
+ r = { name: g.name, title: g.title, status: "warn", summary: `Gate crashed: ${err instanceof Error ? err.message : String(err)}`, findings: [], durationMs: 0, tool: "builtin" };
29
+ }
30
+ if (ctx.onlyRules?.length)
31
+ r = { ...r, findings: r.findings.filter((f) => ctx.onlyRules.some((x) => f.rule_id.startsWith(x))) };
32
+ results.push(r);
33
+ ctx.log(` ${icon(r.status)} ${r.summary}${r.status === "skip" ? "" : ` (${r.durationMs} ms)`}`);
34
+ for (const f of r.findings.slice(0, 20))
35
+ ctx.log(` ${f.severity === "block" ? "✗" : f.severity === "warn" ? "!" : "·"} ${f.rule_id}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : ""}${f.masked ? ` (${f.masked})` : ""}${f.audience === "human" ? " [needs a person]" : ""}\n ${f.risk}\n Fix: ${f.fix}`);
36
+ if (r.findings.length > 20)
37
+ ctx.log(` … ${r.findings.length - 20} more`);
38
+ if (r.status === "fail" && g.blocking && opts.stopOnFail)
39
+ break;
40
+ }
41
+ let failures = results.flatMap((r) => r.findings);
42
+ let capped = [];
43
+ if (opts.trackAttempts !== false) {
44
+ const a = applyAttempts(ctx.root, failures);
45
+ failures = a.findings;
46
+ capped = a.capped;
47
+ if (capped.length) {
48
+ const cap = finding("gates/retry-cap", { risk: `${capped.join(", ")} failed ${MAX_ATTEMPTS + 1} runs in a row. Automatic fixing stopped.`, attempt: MAX_ATTEMPTS + 1 });
49
+ failures.push(cap);
50
+ const last = results[results.length - 1];
51
+ if (last)
52
+ last.findings.push(cap);
53
+ ctx.log(` ✗ ${cap.risk}\n ${cap.fix}`);
54
+ }
55
+ }
56
+ const failed = results.filter((r) => r.status === "fail");
57
+ return { results, ok: failed.length === 0, failed, failures, capped };
58
+ }
59
+ export function icon(status) {
60
+ return status === "pass" ? "✓" : status === "warn" ? "!" : status === "fail" ? "✗" : "–";
61
+ }
62
+ /** Reads server-side secret values from .dev.vars so the bundle gate can detect them in output. */
63
+ export function knownSecretsFrom(vars) {
64
+ return Object.entries(vars)
65
+ .filter(([k]) => /SECRET|KEY|TOKEN|PASSWORD/i.test(k) && !/^VITE_/.test(k))
66
+ .map(([, v]) => v)
67
+ .filter((v) => v.length >= 12);
68
+ }
@@ -0,0 +1,19 @@
1
+ import type { Audience, Severity } from "./types.js";
2
+ /**
3
+ * The rule catalog. Every finding's text comes from here, with only file, line, and a masked value
4
+ * substituted. `example` shows the wrong and the right pattern for explain_failure.
5
+ */
6
+ export interface RuleDef {
7
+ title: string;
8
+ severity: Severity;
9
+ audience: Audience;
10
+ risk: string;
11
+ fix: string;
12
+ example?: {
13
+ wrong: string;
14
+ right: string;
15
+ };
16
+ }
17
+ export declare const RULES: Record<string, RuleDef>;
18
+ /** Fallback for scanner rule ids not in the catalog (semgrep and gitleaks have their own). */
19
+ export declare function ruleFor(id: string, fallback?: Partial<RuleDef>): RuleDef;