@dzhechkov/harness-core 0.3.123 → 0.3.125

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.
@@ -32,6 +32,11 @@ export interface SetupSpec {
32
32
  /** scaffold a starter `architecture/degradations.md` — the accepted-degradations registry the R6
33
33
  * challenge panel (C1) reads so it does not re-flag debt you already own. Create-if-absent. */
34
34
  readonly degradations?: boolean;
35
+ /** P3 (fa-improvements): scaffold DETERMINISTIC guard tests into the project — a declarative
36
+ * `guards.config.json` + a zero-dependency Node runner `check.mjs` (LOC cap, secret scan, frozen-file
37
+ * sha256 pins, each with an explicit waiver mechanism). `true` for defaults, or `{ locCap }` to tune.
38
+ * Moves rules a reviewer "might notice" down to layer 1 of the cost-of-detection ladder. Create-if-absent. */
39
+ readonly guards?: boolean | { readonly locCap?: number };
35
40
  }
36
41
 
37
42
  export interface SetupScan {
@@ -57,7 +62,7 @@ export interface ScaffoldResult { readonly files: readonly ScaffoldFile[] }
57
62
  /** One existing on-disk file: `exists` distinguishes ABSENT from EXISTS-BUT-UNREADABLE (never clobber either). */
58
63
  export interface ExistingFile { readonly exists: boolean; readonly content?: string }
59
64
  /** The existing files the scaffold compares against. */
60
- export interface ExistingScaffoldFiles { readonly vision: ExistingFile; readonly manifest: ExistingFile; readonly projectSkills: ExistingFile; readonly testing: ExistingFile; readonly degradations?: ExistingFile }
65
+ export interface ExistingScaffoldFiles { readonly vision: ExistingFile; readonly manifest: ExistingFile; readonly projectSkills: ExistingFile; readonly testing: ExistingFile; readonly degradations?: ExistingFile; readonly guardsConfig?: ExistingFile; readonly guardsRunner?: ExistingFile }
61
66
 
62
67
  // Canonical committed paths (ADR: everything under architecture/).
63
68
  export const P_VISION = 'architecture/vision.md';
@@ -66,6 +71,8 @@ export const P_MANIFEST = 'architecture/subsystems.manifest.json';
66
71
  export const P_PROJECT_SKILLS = 'architecture/project-skills.json';
67
72
  export const P_CRITIC = 'architecture/project-critic/SKILL.md';
68
73
  export const P_DEGRADATIONS = 'architecture/degradations.md';
74
+ export const P_GUARDS_CONFIG = 'architecture/guards/guards.config.json';
75
+ export const P_GUARDS_RUNNER = 'architecture/guards/check.mjs';
69
76
 
70
77
  const byStr = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
71
78
  const uniqSorted = (xs: readonly string[]): string[] => [...new Set(xs)].sort(byStr);
@@ -235,6 +242,122 @@ function structuredFile(path: string, existing: ExistingFile, render: () => stri
235
242
  * are append-only merged (existing content, order, and unknown keys preserved). A file only gets `create`
236
243
  * when it is genuinely ABSENT. Malformed existing content never crashes and never clobbers.
237
244
  */
245
+ /** Default LOC (lines of code) cap for the scaffolded guard — the classic god-object threshold. */
246
+ export const DEFAULT_GUARD_LOC_CAP = 700;
247
+
248
+ /** Render the declarative guard config. Data, not behavior — the owner edits caps/waivers here. */
249
+ export function renderGuardsConfig(opts: { locCap?: number } = {}): string {
250
+ const cap = typeof opts.locCap === 'number' && Number.isFinite(opts.locCap) && opts.locCap > 0 ? Math.floor(opts.locCap) : DEFAULT_GUARD_LOC_CAP;
251
+ return JSON.stringify({
252
+ $doc: 'Deterministic project guards (fa-improvements P3). Enforced by architecture/guards/check.mjs — wire `node architecture/guards/check.mjs` into your test/CI command. Every waiver REQUIRES a reason: a conscious exception is recorded, not silently allowed. The project-critic role must NOT re-flag rules enforced here — only waivers without a reason.',
253
+ locCap: {
254
+ limit: cap,
255
+ include: ['src/**', 'lib/**', 'app/**', 'test/**', 'tests/**'],
256
+ extensions: ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.py', '.go', '.rs', '.java'],
257
+ waivers: [{ path: 'example/generated-file.ts', reason: 'generated code — delete this sample waiver' }],
258
+ },
259
+ secretScan: {
260
+ include: ['src/**', 'lib/**', 'app/**', 'test/**', 'tests/**', 'docs/**'],
261
+ waivers: [],
262
+ },
263
+ frozenFiles: [] as { path: string; sha256: string; reason: string }[],
264
+ }, null, 2) + '\n';
265
+ }
266
+
267
+ /**
268
+ * Render the ZERO-DEPENDENCY guard runner (plain Node ≥18, no framework, no install): LOC cap + high-signal
269
+ * secret scan + frozen-file sha256 pins, waivers with required reasons, `--json`, exit 1 on violation.
270
+ * Deliberately a portable .mjs, not a vitest/pytest file — it runs in ANY stack's CI with just Node.
271
+ */
272
+ export function renderGuardsRunner(): string {
273
+ return [
274
+ '#!/usr/bin/env node',
275
+ "// architecture/guards/check.mjs — deterministic project guards (generated by dz feature-adr-setup --guards).",
276
+ '// Zero dependencies: plain Node >=18. Wire into CI/test: `node architecture/guards/check.mjs` (exit 1 = violation).',
277
+ '// Rules and waivers live in guards.config.json — a waiver without a reason is itself a violation.',
278
+ "import { readFileSync, readdirSync, statSync } from 'node:fs';",
279
+ "import { join, extname, sep } from 'node:path';",
280
+ "import { createHash } from 'node:crypto';",
281
+ '',
282
+ "const ROOT = process.cwd();",
283
+ "const CFG = JSON.parse(readFileSync(join(ROOT, 'architecture/guards/guards.config.json'), 'utf8'));",
284
+ "const JSON_MODE = process.argv.includes('--json');",
285
+ 'const violations = [];',
286
+ '',
287
+ "const SECRETS = [",
288
+ " { name: 'private-key-pem', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP |ENCRYPTED )?PRIVATE KEY-----/ },",
289
+ " { name: 'openai-key', re: /\\bsk-[A-Za-z0-9_-]{20,}\\b/ },",
290
+ " { name: 'stripe-key', re: /\\bsk_(?:live|test)_[A-Za-z0-9]{16,}\\b/ },",
291
+ " { name: 'github-token', re: /\\bgh[pousr]_[A-Za-z0-9]{36,}\\b/ },",
292
+ " { name: 'aws-access-key', re: /\\bAKIA[0-9A-Z]{16}\\b/ },",
293
+ " { name: 'slack-token', re: /\\bxox[baprs]-[A-Za-z0-9-]{10,}\\b/ },",
294
+ " { name: 'google-api-key', re: /\\bAIza[0-9A-Za-z_-]{35}\\b/ },",
295
+ '];',
296
+ "const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'coverage', '.next', 'vendor', '__pycache__']);",
297
+ '',
298
+ '// include patterns are simple dir prefixes (the part before /**). Deterministic, no glob dependency.',
299
+ "const prefixes = (pats) => (pats || []).map((p) => String(p).split('/**')[0]).filter(Boolean);",
300
+ 'function walk(dir, out) {',
301
+ ' let entries = [];',
302
+ ' try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }',
303
+ ' for (const e of entries) {',
304
+ ' if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(join(dir, e.name), out); }',
305
+ ' else if (e.isFile()) out.push(join(dir, e.name));',
306
+ ' }',
307
+ ' return out;',
308
+ '}',
309
+ "const rel = (p) => p.slice(ROOT.length + 1).split(sep).join('/');",
310
+ 'function filesUnder(pats) {',
311
+ ' const out = [];',
312
+ ' for (const pre of prefixes(pats)) walk(join(ROOT, pre), out);',
313
+ ' return out;',
314
+ '}',
315
+ 'function waiverFor(list, relPath) {',
316
+ " for (const w of list || []) { if (w && w.path === relPath) return w; }",
317
+ ' return null;',
318
+ '}',
319
+ 'function applyWaiver(rule, relPath, list, detail) {',
320
+ ' const w = waiverFor(list, relPath);',
321
+ " if (!w) { violations.push({ rule, path: relPath, detail }); return; }",
322
+ " if (!w.reason || !String(w.reason).trim()) violations.push({ rule: rule + '-waiver', path: relPath, detail: 'waived WITHOUT a reason — a silent exception is a violation' });",
323
+ '}',
324
+ '',
325
+ '// 1) LOC cap (the god-object guard: deterministic wc -l, not reviewer judgment)',
326
+ 'if (CFG.locCap && CFG.locCap.limit > 0) {',
327
+ ' const exts = new Set(CFG.locCap.extensions || []);',
328
+ ' for (const f of filesUnder(CFG.locCap.include)) {',
329
+ ' if (exts.size && !exts.has(extname(f))) continue;',
330
+ " let n = 0; try { n = readFileSync(f, 'utf8').split('\\n').length; } catch { continue; }",
331
+ ' if (n > CFG.locCap.limit) applyWaiver(\'loc-cap\', rel(f), CFG.locCap.waivers, n + \' lines > cap \' + CFG.locCap.limit);',
332
+ ' }',
333
+ '}',
334
+ '',
335
+ '// 2) secret scan (high-signal shapes only — ordinary prose and hashes do not trip it)',
336
+ 'if (CFG.secretScan) {',
337
+ ' for (const f of filesUnder(CFG.secretScan.include)) {',
338
+ " let text = ''; try { text = readFileSync(f, 'utf8'); } catch { continue; }",
339
+ ' for (const s of SECRETS) { if (s.re.test(text)) applyWaiver(\'secret-scan\', rel(f), CFG.secretScan.waivers, \'looks like a \' + s.name); }',
340
+ ' }',
341
+ '}',
342
+ '',
343
+ '// 3) frozen-file sha256 pins (tamper/drift evidence for files that must not change silently)',
344
+ 'for (const fz of CFG.frozenFiles || []) {',
345
+ ' if (!fz || !fz.path || !fz.sha256) continue;',
346
+ ' let actual = null;',
347
+ " try { actual = createHash('sha256').update(readFileSync(join(ROOT, fz.path))).digest('hex'); } catch { /* missing counts as changed */ }",
348
+ " if (actual !== fz.sha256) violations.push({ rule: 'frozen-file', path: fz.path, detail: actual ? 'sha256 changed (pinned ' + fz.sha256.slice(0, 12) + '…, actual ' + actual.slice(0, 12) + '…)' : 'file missing/unreadable' });",
349
+ '}',
350
+ '',
351
+ 'if (JSON_MODE) { console.log(JSON.stringify({ ok: violations.length === 0, violations })); }',
352
+ 'else {',
353
+ " if (violations.length === 0) console.log('guards: ✓ all deterministic guards pass');",
354
+ " else { console.log('guards: ✗ ' + violations.length + ' violation(s)'); for (const v of violations) console.log(' [' + v.rule + '] ' + v.path + ' — ' + v.detail); }",
355
+ '}',
356
+ 'process.exit(violations.length === 0 ? 0 : 1);',
357
+ '',
358
+ ].join('\n');
359
+ }
360
+
238
361
  export function scaffoldFromSpec(spec: SetupSpec, existing: ExistingScaffoldFiles): ScaffoldResult {
239
362
  const files: ScaffoldFile[] = [];
240
363
  if (spec.vision) files.push(proseFile(P_VISION, existing.vision, () => renderVisionDoc(spec.vision!)));
@@ -246,6 +369,13 @@ export function scaffoldFromSpec(spec: SetupSpec, existing: ExistingScaffoldFile
246
369
  const nextPS = buildProjectSkillsFromSpec(spec);
247
370
  files.push(structuredFile(P_PROJECT_SKILLS, existing.projectSkills, () => JSON.stringify(nextPS, null, 2) + '\n', (parsed) => mergeProjectSkills(parsed, (nextPS.roles ?? {}) as Record<string, string>, nextPS.extra ?? [])));
248
371
  if (spec.degradations) files.push(proseFile(P_DEGRADATIONS, existing.degradations ?? { exists: false }, renderDegradationsDoc));
372
+ if (spec.guards) {
373
+ const gOpts = typeof spec.guards === 'object' ? spec.guards : {};
374
+ // Both create-if-absent: the config is the owner's to edit after scaffolding; the runner is regenerable
375
+ // but never clobbered (a project may have patched it — treat like any owned file).
376
+ files.push(proseFile(P_GUARDS_CONFIG, existing.guardsConfig ?? { exists: false }, () => renderGuardsConfig(gOpts)));
377
+ files.push(proseFile(P_GUARDS_RUNNER, existing.guardsRunner ?? { exists: false }, renderGuardsRunner));
378
+ }
249
379
  return { files: [...files].sort((a, b) => byStr(a.path, b.path)) };
250
380
  }
251
381
 
@@ -292,5 +422,7 @@ export function readExistingForScaffold(repoRoot: string): ExistingScaffoldFiles
292
422
  projectSkills: readExistingFile(join(repoRoot, P_PROJECT_SKILLS)),
293
423
  testing: readExistingFile(join(repoRoot, P_TESTING)),
294
424
  degradations: readExistingFile(join(repoRoot, P_DEGRADATIONS)),
425
+ guardsConfig: readExistingFile(join(repoRoot, P_GUARDS_CONFIG)),
426
+ guardsRunner: readExistingFile(join(repoRoot, P_GUARDS_RUNNER)),
295
427
  };
296
428
  }
package/src/sign.ts CHANGED
@@ -118,10 +118,18 @@ export function listPackFiles(root: string): string[] {
118
118
  const out: string[] = [];
119
119
  const walk = (dir: string, rel: string): void => {
120
120
  for (const e of readdirSync(dir, { withFileTypes: true })) {
121
+ // node_modules/.git are unsigned territory BY DESIGN (installed deps / VCS metadata — not pack
122
+ // content; npm tarballs ship neither). The SIGN and VERIFY walks MUST share these exclusions:
123
+ // an asymmetry here false-TAMPERs every pnpm workspace pack whose node_modules holds symlinks
124
+ // (found live arming task #36 — 10 of 23 skill packs flagged right after signing).
125
+ if (e.name === 'node_modules' || e.name === '.git') continue;
121
126
  if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
122
127
  const abs = join(dir, e.name);
123
128
  const r = rel ? rel + '/' + e.name : e.name;
124
129
  if (e.isDirectory()) walk(abs, r);
130
+ // NOT else-isFile: a symlink (or other non-file) OUTSIDE the excluded dirs must stay VISIBLE to the
131
+ // verify sweep — it fails as "present but not signed" / "is a symlink" (R3-4: a smuggled symlink is a
132
+ // finding, not something to silently ignore). Only node_modules/.git are exempt territory.
125
133
  else out.push(r);
126
134
  }
127
135
  };
@@ -129,6 +137,27 @@ export function listPackFiles(root: string): string[] {
129
137
  return out.sort();
130
138
  }
131
139
 
140
+ /**
141
+ * The SIGN-side file list: same exclusions as {@link listPackFiles}, but REGULAR FILES ONLY — `dz sign`
142
+ * never signs a symlink (hashing one would follow it outside the pack). The verify sweep intentionally
143
+ * sees MORE than this (symlinks/specials outside node_modules), so a smuggled entry fails verification.
144
+ */
145
+ export function listSignablePackFiles(root: string): string[] {
146
+ const out: string[] = [];
147
+ const walk = (dir: string, rel: string): void => {
148
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
149
+ if (e.name === 'node_modules' || e.name === '.git') continue;
150
+ if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
151
+ const abs = join(dir, e.name);
152
+ const r = rel ? rel + '/' + e.name : e.name;
153
+ if (e.isDirectory()) walk(abs, r);
154
+ else if (e.isFile()) out.push(r);
155
+ }
156
+ };
157
+ walk(root, '');
158
+ return out.sort();
159
+ }
160
+
132
161
  /**
133
162
  * The bytes that get signed (FR-7). Sorted by path, LF endings, no trailing whitespace, and no
134
163
  * dependence on JSON key order — a signature must not depend on how a serialiser felt that day.