@haposoft/cafekit 0.15.3 → 0.16.0-rc.1

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 (90) hide show
  1. package/README.md +4 -2
  2. package/bin/__tests__/codex-hooks.test.js +140 -0
  3. package/bin/__tests__/codex-native.test.js +2 -2
  4. package/bin/__tests__/develop-contract.test.js +1120 -0
  5. package/bin/__tests__/installer-safety.test.js +510 -2
  6. package/bin/__tests__/package-inventory.test.js +258 -0
  7. package/bin/__tests__/secret-scanner.test.js +157 -0
  8. package/bin/__tests__/validator-grounding.test.js +713 -0
  9. package/bin/install.js +5 -1
  10. package/bin/lib/codex-install.js +21 -6
  11. package/bin/lib/context.js +17 -6
  12. package/bin/lib/copy-utils.js +31 -0
  13. package/bin/lib/instruction-blocks.js +85 -0
  14. package/bin/lib/managed-writer.js +4 -1
  15. package/bin/lib/opencode-install.js +75 -30
  16. package/bin/phases/claude-runtime.js +103 -4
  17. package/bin/phases/codex-runtime.js +9 -5
  18. package/bin/phases/post-install.js +29 -22
  19. package/bin/phases/root-config.js +89 -10
  20. package/package.json +14 -4
  21. package/src/claude/CLAUDE.md +6 -122
  22. package/src/claude/agents/code-auditor.md +21 -22
  23. package/src/claude/agents/debugger.md +3 -3
  24. package/src/claude/agents/deployer.md +1 -1
  25. package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
  26. package/src/claude/agents/project-manager.md +1 -1
  27. package/src/claude/agents/spec-maker.md +2 -2
  28. package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
  29. package/src/claude/hooks/agent.cjs +7 -14
  30. package/src/claude/hooks/rules.cjs +33 -53
  31. package/src/claude/hooks/spec-gate.cjs +66 -21
  32. package/src/claude/hooks/spec-state.cjs +5 -0
  33. package/src/claude/migration-manifest.json +9 -2
  34. package/src/claude/runtime.json +0 -3
  35. package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
  36. package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
  37. package/src/claude/scripts/spec-ground.cjs +189 -83
  38. package/src/claude/scripts/validate-spec-output.cjs +294 -30
  39. package/src/claude/scripts/workflow-policy.cjs +609 -0
  40. package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
  41. package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
  42. package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
  43. package/src/claude/skills/backend-development/SKILL.md +4 -4
  44. package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
  45. package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
  46. package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
  47. package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
  48. package/src/claude/skills/backend-development/references/backend-security.md +3 -3
  49. package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
  50. package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
  51. package/src/claude/skills/brainstorm/SKILL.md +2 -3
  52. package/src/claude/skills/code-review/SKILL.md +12 -8
  53. package/src/claude/skills/code-review/references/verification-gate.md +4 -4
  54. package/src/claude/skills/debug/SKILL.md +2 -0
  55. package/src/claude/skills/develop/SKILL.md +100 -52
  56. package/src/claude/skills/develop/references/parallel-waves.md +105 -26
  57. package/src/claude/skills/develop/references/quality-gate.md +58 -38
  58. package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
  59. package/src/claude/skills/git/SKILL.md +44 -50
  60. package/src/claude/skills/hotfix/SKILL.md +10 -8
  61. package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
  62. package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
  63. package/src/claude/skills/inspect/SKILL.md +12 -30
  64. package/src/claude/skills/mobile-development/SKILL.md +12 -12
  65. package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
  66. package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
  67. package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
  68. package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
  69. package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
  70. package/src/claude/skills/question/SKILL.md +13 -13
  71. package/src/claude/skills/research/SKILL.md +2 -2
  72. package/src/claude/skills/specs/SKILL.md +32 -15
  73. package/src/claude/skills/specs/references/review.md +3 -3
  74. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  75. package/src/claude/skills/specs/templates/spec-state.json +7 -3
  76. package/src/claude/skills/sync/SKILL.md +26 -1
  77. package/src/claude/skills/sync/references/sync-protocols.md +3 -3
  78. package/src/claude/skills/test/SKILL.md +12 -4
  79. package/src/claude/skills/test/references/failure-triage.md +17 -17
  80. package/src/claude/skills/test/references/test-memory.md +2 -2
  81. package/src/codex/AGENTS.md +16 -24
  82. package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
  83. package/src/codex/hooks/rules.cjs +27 -26
  84. package/src/codex/hooks/spec-gate.cjs +19 -8
  85. package/src/codex/hooks/spec-state.cjs +10 -1
  86. package/src/common/AGENTS.md +43 -0
  87. package/src/opencode/AGENTS.md +21 -121
  88. package/src/opencode/plugins/rules.ts +26 -34
  89. package/src/opencode/plugins/spec-gate.ts +283 -0
  90. package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
@@ -9,13 +9,15 @@
9
9
  * Block contract (exit 0 + stdout): {"decision":"block","reason":"..."}.
10
10
  *
11
11
  * Safety: stop_hook_active loop guard; runtime.spec.completion_gate=false
12
- * escape hatch (missing key → ON); first-run seeds cache without blocking;
13
- * crash → fail-open + hooks/.logs/hook-log.jsonl. Exit: 0 always.
12
+ * escape hatch (missing key → ON); cache is optimization only - every
13
+ * done transition is validated even on first run / empty cache; crash → fail-open + hooks/.logs/hook-log.jsonl. Exit: 0 always.
14
14
  */
15
15
 
16
16
  try {
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
+ const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
20
+ const POLICY = require(policyPath);
19
21
 
20
22
  const stdin = fs.readFileSync(0, 'utf8').trim();
21
23
  if (!stdin) process.exit(0);
@@ -69,20 +71,24 @@ try {
69
71
  currentStatuses[tp] = task?.status || 'pending';
70
72
  }
71
73
 
72
- // First run: treat all current done as historical — seed cache, never block.
73
- if (!cacheExists) {
74
- try {
75
- fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
76
- cache[featureName] = currentStatuses;
77
- fs.writeFileSync(cacheFile, JSON.stringify(cache));
78
- } catch { /* fail-open */ }
74
+ const staleFlashTasks = Object.entries(taskRegistry)
75
+ .filter(([, task]) => POLICY.isStaleFlashDone(task))
76
+ .map(([taskPath]) => taskPath);
77
+ if (staleFlashTasks.length > 0) {
78
+ process.stdout.write(JSON.stringify({
79
+ decision: 'block',
80
+ reason: `Completion gate: ${staleFlashTasks.length} task(s) marked done with FLASH_UNVERIFIED (${staleFlashTasks.join(', ')}). Run /hapo:test for exact proof, then use explicit sync-finalize.`
81
+ }) + '\n');
79
82
  process.exit(0);
80
83
  }
81
84
 
82
- const featureCache = cache[featureName] || {};
83
- // Newly done: status is done now AND cached status differs or is absent.
84
- const newlyDone = Object.keys(taskRegistry).filter((tp) =>
85
- (taskRegistry[tp]?.status || 'pending') === 'done' && featureCache[tp] !== 'done'
85
+ // Cache hardening: every Stop revalidates the canonical receipt for
86
+ // every task currently marked done, so a cached PASS cannot hide later
87
+ // mutations (deletion, placeholder, changed Verification/Command/Exit/
88
+ // Base/Head, stale provenance). Cache is an optimization, not truth.
89
+ const featureCache = cacheExists ? (cache[featureName] || {}) : {};
90
+ const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
91
+ (taskRegistry[tp]?.status || 'pending') === 'done'
86
92
  );
87
93
  // /m so ^ matches line starts (Evidence is never at byte 0 of the file).
88
94
  // legacy heading aliases: read-compat only, no longer advertised
@@ -108,12 +114,35 @@ try {
108
114
  return lines.slice(start, end).join('\n');
109
115
  }
110
116
 
111
- // Receipt checks a–d → failed letter list.
112
- // a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + (fence|proof); d: completed_at
117
+ function validateCanonicalReceipt(body) {
118
+ const fails = [];
119
+ if (!/^\s*Verification:\s*PASS\s*$/m.test(body)) fails.push('verification_state');
120
+ if (!/^\s*Command(?:\(s\))?\s*:/m.test(body)) fails.push('command');
121
+ if (!/^\s*Exit\s*:|exit\s+code\s*[:=]|\bResult\s*:\s*PASS\b/im.test(body)) fails.push('exit_result');
122
+ const hasBase = /^\s*Base[ \t]*:[ \t]*\S/im.test(body);
123
+ const hasHead = /^\s*Head[ \t]*:[ \t]*\S/im.test(body);
124
+ const hasBaseSha = /\bbase_sha[ \t]*:[ \t]*\S/im.test(body);
125
+ const hasHeadSha = /\bhead_sha[ \t]*:[ \t]*\S/im.test(body);
126
+ if (!((hasBase && hasHead) || (hasBaseSha && hasHeadSha))) fails.push('provenance');
127
+ if (/\bartifact\b/i.test(body) && !/sha256:/i.test(body)) fails.push('artifact_hash');
128
+ return fails;
129
+ }
130
+
131
+ // Receipt checks a–h → failed letter list.
132
+ // a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + unambiguous PASS; d: completed_at; e: command; f: exit/result; g: provenance; h: artifact hash
133
+ function safeTaskFile(featureDir, taskPath) {
134
+ const target = path.resolve(featureDir, taskPath);
135
+ const relative = path.relative(featureDir, target);
136
+ // sibling-prefix safe: must not be .., ../..., or absolute (cross-platform)
137
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
138
+ return target;
139
+ }
140
+
113
141
  function checkReceipt(taskPath) {
114
142
  const fails = [];
115
- const abs = path.join(specsPath, featureName, taskPath);
116
- if (!fs.existsSync(abs)) return ['a'];
143
+ const featureDir = path.join(specsPath, featureName);
144
+ const abs = safeTaskFile(featureDir, taskPath);
145
+ if (!abs || !fs.existsSync(abs)) return ['a'];
117
146
  let text = '';
118
147
  try { text = fs.readFileSync(abs, 'utf8'); } catch { return ['a']; }
119
148
 
@@ -129,6 +158,20 @@ try {
129
158
  !(PASS_MARKER_RE.test(body) || LEGACY_SUCCESS_RE.test(body))
130
159
  ) {
131
160
  fails.push('c');
161
+ } else {
162
+ // Canonical receipt checks for done tasks
163
+ const canonicalFails = validateCanonicalReceipt(body);
164
+ const map = { verification_state: 'c', command: 'e', exit_result: 'f', provenance: 'g', artifact_hash: 'h' };
165
+ for (const cf of canonicalFails) {
166
+ if (cf === 'verification_state') {
167
+ if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
168
+ continue;
169
+ }
170
+ const letter = map[cf];
171
+ if (letter && !fails.includes(letter)) fails.push(letter);
172
+ }
173
+ // Enforce strict Verification: PASS, not just legacy success
174
+ if (!PASS_MARKER_RE.test(body) && !fails.includes('c')) fails.push('c');
132
175
  }
133
176
 
134
177
  const at = taskRegistry[taskPath]?.completed_at;
@@ -140,12 +183,14 @@ try {
140
183
  return fails;
141
184
  }
142
185
 
143
- const failures = newlyDone
186
+ const failures = allDoneTasks
144
187
  .map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
145
188
  .filter((f) => f.fails.length > 0);
146
189
 
147
- // Always persist status transitions, including done → pending. Leave failing
148
- // newly-done tasks at their old status so the gate re-fires next Stop.
190
+ // Always persist status transitions, including done → pending. Keep
191
+ // failing done tasks at their previous cached status so a stale valid
192
+ // entry cannot mask a mutated receipt on the next Stop; with
193
+ // revalidation of every done task the gate still re-fires regardless.
149
194
  try {
150
195
  const nextFeature = { ...featureCache, ...currentStatuses };
151
196
  for (const { taskPath } of failures) {
@@ -157,7 +202,7 @@ try {
157
202
  fs.writeFileSync(cacheFile, JSON.stringify(cache));
158
203
  } catch { /* fail-open */ }
159
204
 
160
- if (newlyDone.length === 0) process.exit(0);
205
+ if (allDoneTasks.length === 0) process.exit(0);
161
206
  if (failures.length === 0) process.exit(0);
162
207
 
163
208
  const lines = [
@@ -14,6 +14,7 @@
14
14
  try {
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const POLICY = require(path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs'));
17
18
 
18
19
  // ── Main ──────────────────────────────────────────────────────────────────
19
20
 
@@ -68,6 +69,7 @@ try {
68
69
 
69
70
  const phase = activeSpec.current_phase || activeSpec.phase || 'unknown';
70
71
  const taskRegistry = activeSpec.task_registry || {};
72
+ const flashTasks = POLICY.flashState(taskRegistry);
71
73
  const taskEntries = Object.entries(taskRegistry);
72
74
  const taskCounts = taskEntries.reduce((acc, [, task]) => {
73
75
  const status = task?.status || 'pending';
@@ -113,6 +115,9 @@ try {
113
115
  if (nextUnblocked) {
114
116
  lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
115
117
  }
118
+ if (flashTasks.length > 0) {
119
+ lines.push(`- Flash verification pending: ${flashTasks.map((taskPath) => `\`${taskPath}\``).join(', ')}. PASS promotion keeps task in_progress until explicit sync-finalize.`);
120
+ }
116
121
  lines.push(`- Sync \`spec.json\` + task file after verified work; run \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` before \`ready_for_implementation=true\`.`);
117
122
  lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`);
118
123
  lines.push('');
@@ -41,7 +41,7 @@
41
41
  "required": [
42
42
  "test-runner.md",
43
43
  "code-auditor.md",
44
- "god-developer.md",
44
+ "implementer.md",
45
45
  "debugger.md",
46
46
  "docs-keeper.md",
47
47
  "git-ops.md",
@@ -59,7 +59,9 @@
59
59
  "validate-docs.cjs",
60
60
  "validate-docs-reconstruct.cjs",
61
61
  "generate-skill-catalog.cjs",
62
- "validate-spec-output.cjs"
62
+ "validate-spec-output.cjs",
63
+ "scan-staged-secrets.cjs",
64
+ "workflow-policy.cjs"
63
65
  ]
64
66
  },
65
67
  "agentReferences": {
@@ -69,6 +71,11 @@
69
71
  "assumed": []
70
72
  },
71
73
  "obsolete": {
74
+ "agents": {
75
+ "claude": ["agents/god-developer.md"],
76
+ "codex": ["agents/god_developer.toml"],
77
+ "opencode": ["agents/god-developer.md"]
78
+ },
72
79
  "runtimeFiles": [
73
80
  "hooks/skill-router.cjs",
74
81
  "hooks/lib/skill-router-routes.cjs",
@@ -3,9 +3,6 @@
3
3
  "inspect": {
4
4
  "enabled": true
5
5
  },
6
- "gemini": {
7
- "model": "gemma-4-31b-it"
8
- },
9
6
  "statusline": "full",
10
7
  "statuslineColors": true,
11
8
  "docs": {
@@ -38,10 +38,10 @@ function parseArgs(argv) {
38
38
 
39
39
  function defaultSkillsRoot() {
40
40
  const candidates = [
41
+ path.resolve(__dirname, '..', 'skills'),
41
42
  path.resolve(process.cwd(), '.claude', 'skills'),
42
43
  path.resolve(process.cwd(), '.agents', 'skills'),
43
44
  path.resolve(process.cwd(), '.opencode', 'skills'),
44
- path.resolve(__dirname, '..', 'skills'),
45
45
  path.resolve(__dirname, '..', '..', '.agents', 'skills')
46
46
  ];
47
47
  return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('node:child_process');
4
+
5
+ const STRONG_SENSITIVE_NAMES = new Set([
6
+ 'api_key', 'apikey', 'api-key', 'password', 'passwd', 'access_token',
7
+ 'auth_token', 'bearer_token', 'client_secret', 'private_key',
8
+ 'aws_secret_access_key', 'aws_access_key_id', 'github_token', 'jwt_secret',
9
+ 'openai_api_key', 'anthropic_api_key', 'credential_url', 'credentials_url',
10
+ 'database_url', 'connection_string', 'connection_url', 'dsn', 'basic_auth',
11
+ 'basic-auth', 'auth_header', 'authorization'
12
+ ]);
13
+ const GENERIC_SENSITIVE_NAMES = new Set(['secret', 'token', 'key', 'auth']);
14
+ const SAFE_SUFFIXES = /(?:_label|_hint|_path|_file)$/i;
15
+ const PLACEHOLDER = /^(?:$|(?:your|my|replace|change|set)[-_ ]?(?:value|secret|key|token|password)?$|(?:changeme|change-me|example|sample|dummy|placeholder|test|testing|fake|xxx+|<[^>]+>|\.{3,}|\*{3,}))/i;
16
+ const ENV_REFERENCE = /^(?:\$\{?[A-Z0-9_]+\}?|process\.env(?:\.|\[)|import\.meta\.env(?:\.|\[)|(?:env|os\.environ)\s*\()/i;
17
+ const FUNCTION_REFERENCE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\(/;
18
+ const VALUE_SIGNAL = /(?:^|[\s_-])(sk-[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_]{8,}|ey[a-z0-9_-]{20,}|[a-f0-9]{24,}|[a-z0-9+/]{20,}={0,2})(?:$|[^a-z0-9_])/i;
19
+ const PEM_SIGNAL = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)[A-Z0-9 ]*-----/i;
20
+ const CREDENTIAL_URL_SIGNAL = /\b(?:https?|postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s/@:]+(?::[^\s/@]*)?@[^\s/]+/i;
21
+ const PUBLIC_URL_SIGNAL = /^(?:https?|postgres(?:ql)?|mysql|mongodb(?:\+srv)?:)\/\//i;
22
+ const BASIC_AUTH_SIGNAL = /\bBasic\s+[A-Za-z0-9+/]{4,}={0,2}\b/i;
23
+ const SAFE_LITERAL = /^(?:true|false|null|undefined|enabled|disabled|none|safe)$/i;
24
+ const ASSIGNMENT = /(?:^|[\s,{\-])(["']?[A-Za-z_$][A-Za-z0-9_$-]*["']?)\s*(?::|=)\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`|([^\s#},]*))/g;
25
+
26
+ function normalizeIdentifier(name) {
27
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/-/g, '_').toLowerCase();
28
+ }
29
+
30
+ function sensitivityLevel(name) {
31
+ const normalized = normalizeIdentifier(name);
32
+ if (SAFE_SUFFIXES.test(normalized) || normalized === 'tokenizer') return null;
33
+ if (STRONG_SENSITIVE_NAMES.has(normalized)
34
+ || /(?:^|_)(?:api_key|password|passwd|private_key|client_secret|access_token|auth_token|credential|credentials)(?:_|$)/i.test(normalized)) {
35
+ return 'strong';
36
+ }
37
+ if (GENERIC_SENSITIVE_NAMES.has(normalized)
38
+ || /(?:^|_)(?:secret|token|key|auth)(?:_|$)/i.test(normalized)) {
39
+ return 'generic';
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function identifierIsSensitive(name) {
45
+ return sensitivityLevel(name) !== null;
46
+ }
47
+
48
+ function stripValue(value) {
49
+ return String(value || '').trim().replace(/^(['"`])([\s\S]*)\1$/, '$2').replace(/[;,]$/, '');
50
+ }
51
+
52
+ function valueLooksSecret(value, level = 'generic') {
53
+ const clean = stripValue(value);
54
+ if (!clean || PLACEHOLDER.test(clean) || ENV_REFERENCE.test(clean) || FUNCTION_REFERENCE.test(clean)) return false;
55
+ if (PUBLIC_URL_SIGNAL.test(clean) && !CREDENTIAL_URL_SIGNAL.test(clean)) return false;
56
+ if (PEM_SIGNAL.test(clean) || CREDENTIAL_URL_SIGNAL.test(clean) || BASIC_AUTH_SIGNAL.test(clean)) return true;
57
+ if (level === 'strong') return clean.length >= 4 && !SAFE_LITERAL.test(clean);
58
+ return VALUE_SIGNAL.test(clean) || (clean.length >= 20 && /[A-Za-z]/.test(clean) && /\d/.test(clean));
59
+ }
60
+
61
+ function parseAssignments(text) {
62
+ const assignments = [];
63
+ let match;
64
+ while ((match = ASSIGNMENT.exec(text)) !== null) {
65
+ const name = match[1].replace(/^["']|["']$/g, '');
66
+ const value = match[2] ?? match[3] ?? match[4] ?? match[5] ?? '';
67
+ assignments.push({ name, value, level: sensitivityLevel(name) });
68
+ }
69
+ return assignments;
70
+ }
71
+
72
+ function inspectAssignment(assignment, file, sourceLine, findings) {
73
+ const specializedValue = PEM_SIGNAL.test(assignment.value)
74
+ || CREDENTIAL_URL_SIGNAL.test(assignment.value)
75
+ || BASIC_AUTH_SIGNAL.test(assignment.value);
76
+ if ((assignment.level && valueLooksSecret(assignment.value, assignment.level)) || (!assignment.level && specializedValue)) {
77
+ findings.push({ file, sourceLine, name: assignment.name });
78
+ }
79
+ }
80
+
81
+ function parseDiff(diff) {
82
+ const findings = [];
83
+ let file = null;
84
+ let sourceLine = 0;
85
+ let pending = null;
86
+
87
+ const flushPending = () => {
88
+ if (!pending) return;
89
+ if (valueLooksSecret(pending.value, pending.level)) {
90
+ findings.push({ file: pending.file, sourceLine: pending.sourceLine, name: pending.name });
91
+ }
92
+ pending = null;
93
+ };
94
+
95
+ for (const line of diff.split(/\r?\n/)) {
96
+ if (line.startsWith('+++ b/')) {
97
+ flushPending();
98
+ file = line.slice(6);
99
+ continue;
100
+ }
101
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
102
+ if (hunk) {
103
+ flushPending();
104
+ sourceLine = Number(hunk[1]);
105
+ continue;
106
+ }
107
+ if (!file || sourceLine < 1) continue;
108
+
109
+ if (line.startsWith('+') && !line.startsWith('+++')) {
110
+ const text = line.slice(1);
111
+ const assignments = parseAssignments(text);
112
+ const first = assignments[0];
113
+ const indented = /^\s+/.test(text);
114
+ if (pending && (!indented || first)) flushPending();
115
+
116
+ if (first) {
117
+ for (const assignment of assignments) inspectAssignment(assignment, file, sourceLine, findings);
118
+ if (first.level && (!first.value || /^(?:\||>|\{|\[)$/.test(first.value))) {
119
+ pending = {
120
+ file,
121
+ sourceLine,
122
+ name: first.name,
123
+ level: first.level,
124
+ value: ''
125
+ };
126
+ }
127
+ } else if (pending && indented) {
128
+ pending.value += `\n${text.trim()}`;
129
+ } else if (pending && (PEM_SIGNAL.test(text) || BASIC_AUTH_SIGNAL.test(text))) {
130
+ pending.value += `\n${text.trim()}`;
131
+ }
132
+ sourceLine++;
133
+ } else if (!line.startsWith('-') && !line.startsWith('\\')) {
134
+ flushPending();
135
+ sourceLine++;
136
+ } else if (line.startsWith('-')) {
137
+ flushPending();
138
+ }
139
+ }
140
+ flushPending();
141
+ return findings;
142
+ }
143
+
144
+ function main() {
145
+ const result = spawnSync(
146
+ 'git',
147
+ ['diff', '--cached', '--unified=0', '--no-color', '--diff-filter=ACMR', '--'],
148
+ { encoding: 'utf8' }
149
+ );
150
+ if (result.error || result.status !== 0) {
151
+ process.stderr.write(`Unable to read staged diff: ${result.error?.message || result.stderr || 'git diff failed'}\n`);
152
+ return 2;
153
+ }
154
+
155
+ const findings = parseDiff(result.stdout);
156
+ const unique = new Map(findings.map((finding) => [
157
+ `${finding.file}:${finding.sourceLine}:${finding.name}`,
158
+ finding
159
+ ]));
160
+ for (const finding of unique.values()) {
161
+ process.stdout.write(
162
+ `Blocked: possible secret \`${finding.name}\` at ${finding.file}:${finding.sourceLine}. Value not shown.\n`
163
+ );
164
+ }
165
+ if (unique.size === 0) process.stdout.write('No staged secrets found.\n');
166
+ return unique.size > 0 ? 1 : 0;
167
+ }
168
+
169
+ if (require.main === module) process.exit(main());
170
+ module.exports = { parseDiff, identifierIsSensitive, valueLooksSecret };