@nerviq/cli 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/governance.js CHANGED
@@ -1,6 +1,7 @@
1
- const { DOMAIN_PACKS } = require('./domain-packs');
2
- const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
3
- const { getCodexGovernanceSummary } = require('./codex/governance');
1
+ const { DOMAIN_PACKS } = require('./domain-packs');
2
+ const { MCP_PACKS, mergeMcpServers, normalizeMcpPackKeys } = require('./mcp-packs');
3
+ const { getCodexGovernanceSummary } = require('./codex/governance');
4
+ const { formatTerminologyLines } = require('./terminology');
4
5
 
5
6
  const PERMISSION_PROFILES = [
6
7
  {
@@ -406,10 +407,15 @@ function printGovernanceSummary(summary, options = {}) {
406
407
  console.log('');
407
408
  console.log(` nerviq ${summary.platformLabel.toLowerCase()} governance`);
408
409
  console.log(' ═══════════════════════════════════════');
409
- console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
410
- console.log('');
411
-
412
- console.log(' Permission Profiles');
410
+ console.log(` Safe defaults, hook transparency, and pilot guidance for ${summary.platformLabel}.`);
411
+ console.log('');
412
+
413
+ for (const line of formatTerminologyLines(['governance', 'hooks', 'denyRules', 'mcp'])) {
414
+ console.log(line);
415
+ }
416
+ console.log('');
417
+
418
+ console.log(' Permission Profiles');
413
419
  for (const profile of summary.permissionProfiles) {
414
420
  console.log(` - ${profile.label} [${profile.risk}]`);
415
421
  console.log(` ${profile.useWhen}`);
package/src/index.js CHANGED
@@ -88,7 +88,7 @@ const { getOpenCodeGovernanceSummary } = require('./opencode/governance');
88
88
  const { runOpenCodeDeepReview } = require('./opencode/deep-review');
89
89
  const { opencodeInteractive } = require('./opencode/interactive');
90
90
  const { detectPlatforms, getCatalog, synergyReport } = require('./public-api');
91
- const { createServer, startServer } = require('./server');
91
+ const { buildServeOpenApiSpec, createServer, startServer } = require('./server');
92
92
 
93
93
  module.exports = {
94
94
  audit,
@@ -101,6 +101,7 @@ module.exports = {
101
101
  detectPlatforms,
102
102
  getCatalog,
103
103
  synergyReport,
104
+ buildServeOpenApiSpec,
104
105
  createServer,
105
106
  startServer,
106
107
  DOMAIN_PACKS,
@@ -0,0 +1,185 @@
1
+ const path = require('path');
2
+
3
+ const TEST_COMMAND_PATTERNS = [
4
+ /\b(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test\b/i,
5
+ /\b(?:python\s+-m\s+)?pytest\b/i,
6
+ /\bpython\s+manage\.py\s+test\b/i,
7
+ /\bdjango-admin\s+test\b/i,
8
+ /\bpython\s+-m\s+unittest\b/i,
9
+ /\bgo\s+test(?:\s|$)/i,
10
+ /\bcargo\s+test\b/i,
11
+ /\bmake\s+test\b/i,
12
+ /\bmix\s+test\b/i,
13
+ /\bbundle\s+exec\s+rspec\b/i,
14
+ /\brspec\b/i,
15
+ /\bphpunit\b/i,
16
+ /\bdotnet\s+test(?:\s|$)/i,
17
+ /\bflutter\s+test\b/i,
18
+ /\bswift\s+test\b/i,
19
+ /\bxcodebuild\b[^\n\r`]{0,200}\btest\b/i,
20
+ /\bgradlew?\s+test\b/i,
21
+ /\bmvn(?:w)?\s+test\b/i,
22
+ /\bplaywright\s+test\b/i,
23
+ /\bcypress\s+run\b/i,
24
+ ];
25
+
26
+ const LINT_COMMAND_PATTERNS = [
27
+ /\b(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+lint\b/i,
28
+ /\beslint\b/i,
29
+ /\bprettier\b/i,
30
+ /\bruff(?:\s+(?:check|format))?\b/i,
31
+ /\bblack\b/i,
32
+ /\bflake8\b/i,
33
+ /\bpylint\b/i,
34
+ /\bmypy\b/i,
35
+ /\bpyright\b/i,
36
+ /\bgo\s+vet\b/i,
37
+ /\bgofmt\b/i,
38
+ /\bgofumpt\b/i,
39
+ /\bstaticcheck\b/i,
40
+ /\bgolangci-lint\b/i,
41
+ /\bcargo\s+clippy\b/i,
42
+ /\bflutter\s+analyze\b/i,
43
+ /\bdart\s+analyze\b/i,
44
+ /\bswiftlint\b/i,
45
+ /\bswift(?:-|\s+)format\b/i,
46
+ /\bdotnet\s+format(?:\s|$)/i,
47
+ /\bgradlew?\s+lint\b/i,
48
+ /\bmvn(?:w)?\s+(?:checkstyle:check|spotbugs:check|verify)\b/i,
49
+ ];
50
+
51
+ const BUILD_COMMAND_PATTERNS = [
52
+ /\b(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+build\b/i,
53
+ /\btsc(?:\s|$)/i,
54
+ /\bgo\s+build(?:\s|$)/i,
55
+ /\bcargo\s+(?:build|check)\b/i,
56
+ /\bmake\s+build\b/i,
57
+ /\bdotnet\s+(?:build|publish)(?:\s|$)/i,
58
+ /\bmsbuild\b/i,
59
+ /\bflutter\s+build(?:\s|$)/i,
60
+ /\bswift\s+build\b/i,
61
+ /\bxcodebuild\b/i,
62
+ /\bgradlew?\s+(?:build|assemble)\b/i,
63
+ /\bmvn(?:w)?\s+(?:compile|package|verify|install)\b/i,
64
+ /\bpython\s+-m\s+build\b/i,
65
+ /\bpoetry\s+build\b/i,
66
+ ];
67
+
68
+ function normalizePath(filePath) {
69
+ return String(filePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
70
+ }
71
+
72
+ function addSurface(ctx, surfaces, seen, filePath) {
73
+ const normalized = normalizePath(filePath);
74
+ if (!normalized || seen.has(normalized)) return;
75
+ const content = typeof ctx.fileContent === 'function' ? (ctx.fileContent(normalized) || '') : '';
76
+ if (!content.trim()) return;
77
+ seen.add(normalized);
78
+ surfaces.push({ path: normalized, content });
79
+ }
80
+
81
+ function addDirSurfaces(ctx, surfaces, seen, dirPath, filter) {
82
+ if (typeof ctx.dirFiles !== 'function') return;
83
+ for (const entry of ctx.dirFiles(dirPath) || []) {
84
+ if (filter && !filter.test(entry)) continue;
85
+ addSurface(ctx, surfaces, seen, path.join(dirPath, entry));
86
+ }
87
+ }
88
+
89
+ function buildSurfaceList(ctx, scope) {
90
+ const surfaces = [];
91
+ const seen = new Set();
92
+ const includeReadme = scope === 'repo';
93
+
94
+ addSurface(ctx, surfaces, seen, 'CLAUDE.md');
95
+ addSurface(ctx, surfaces, seen, '.claude/CLAUDE.md');
96
+ addDirSurfaces(ctx, surfaces, seen, '.claude/rules', /\.md$/i);
97
+ addDirSurfaces(ctx, surfaces, seen, '.claude/commands', /\.md$/i);
98
+ addDirSurfaces(ctx, surfaces, seen, '.claude/agents', /\.md$/i);
99
+
100
+ if (scope === 'repo') {
101
+ addSurface(ctx, surfaces, seen, 'AGENTS.md');
102
+ addSurface(ctx, surfaces, seen, 'AGENTS.override.md');
103
+ addSurface(ctx, surfaces, seen, '.cursorrules');
104
+ addSurface(ctx, surfaces, seen, '.windsurfrules');
105
+ addSurface(ctx, surfaces, seen, 'GEMINI.md');
106
+ addSurface(ctx, surfaces, seen, '.gemini/GEMINI.md');
107
+ addSurface(ctx, surfaces, seen, '.github/copilot-instructions.md');
108
+ addDirSurfaces(ctx, surfaces, seen, '.cursor/rules', /\.(md|mdc)$/i);
109
+ addDirSurfaces(ctx, surfaces, seen, '.cursor/commands', /\.md$/i);
110
+ addDirSurfaces(ctx, surfaces, seen, '.windsurf/rules', /\.md$/i);
111
+ addDirSurfaces(ctx, surfaces, seen, '.windsurf/workflows', /\.md$/i);
112
+ addDirSurfaces(ctx, surfaces, seen, '.github/instructions', /\.instructions\.md$/i);
113
+ addDirSurfaces(ctx, surfaces, seen, '.github/prompts', /\.prompt\.md$/i);
114
+ addDirSurfaces(ctx, surfaces, seen, '.opencode/commands', /\.(md|markdown|ya?ml)$/i);
115
+ addDirSurfaces(ctx, surfaces, seen, '.gemini/agents', /\.md$/i);
116
+ }
117
+
118
+ if (includeReadme) {
119
+ addSurface(ctx, surfaces, seen, 'README.md');
120
+ addSurface(ctx, surfaces, seen, 'CONTRIBUTING.md');
121
+ }
122
+
123
+ return surfaces;
124
+ }
125
+
126
+ function getSurfaceBundle(ctx, scope) {
127
+ const cacheKey = scope === 'repo' ? '__nerviqRepoInstructionBundle' : '__nerviqClaudeInstructionBundle';
128
+ if (ctx && ctx[cacheKey] !== undefined) return ctx[cacheKey];
129
+ const bundle = buildSurfaceList(ctx, scope)
130
+ .map((surface) => surface.content)
131
+ .join('\n\n');
132
+ if (ctx) ctx[cacheKey] = bundle;
133
+ return bundle;
134
+ }
135
+
136
+ function matchesAny(text, patterns) {
137
+ const normalized = String(text || '');
138
+ return patterns.some((pattern) => {
139
+ pattern.lastIndex = 0;
140
+ return pattern.test(normalized);
141
+ });
142
+ }
143
+
144
+ function hasDocumentedTestCommand(text) {
145
+ return matchesAny(text, TEST_COMMAND_PATTERNS);
146
+ }
147
+
148
+ function hasDocumentedLintCommand(text) {
149
+ return matchesAny(text, LINT_COMMAND_PATTERNS);
150
+ }
151
+
152
+ function hasDocumentedBuildCommand(text) {
153
+ return matchesAny(text, BUILD_COMMAND_PATTERNS);
154
+ }
155
+
156
+ function hasDocumentedVerificationGuidance(text) {
157
+ const normalized = String(text || '');
158
+ if (!normalized.trim()) return false;
159
+ return hasDocumentedTestCommand(normalized) ||
160
+ hasDocumentedLintCommand(normalized) ||
161
+ hasDocumentedBuildCommand(normalized) ||
162
+ (/\b(?:verification|verify|self-check|quality gate)\b/i.test(normalized) &&
163
+ matchesAny(normalized, [
164
+ ...TEST_COMMAND_PATTERNS,
165
+ ...LINT_COMMAND_PATTERNS,
166
+ ...BUILD_COMMAND_PATTERNS,
167
+ ]));
168
+ }
169
+
170
+ function getClaudeInstructionBundle(ctx) {
171
+ return getSurfaceBundle(ctx, 'claude');
172
+ }
173
+
174
+ function getRepoInstructionBundle(ctx) {
175
+ return getSurfaceBundle(ctx, 'repo');
176
+ }
177
+
178
+ module.exports = {
179
+ getClaudeInstructionBundle,
180
+ getRepoInstructionBundle,
181
+ hasDocumentedVerificationGuidance,
182
+ hasDocumentedTestCommand,
183
+ hasDocumentedLintCommand,
184
+ hasDocumentedBuildCommand,
185
+ };
@@ -10,66 +10,113 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const https = require('https');
14
- const http = require('http');
15
- const { URL } = require('url');
16
-
17
- // ─── Webhook delivery ────────────────────────────────────────────────────────
13
+ const https = require('https');
14
+ const http = require('http');
15
+ const { URL } = require('url');
16
+
17
+ const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
18
+
19
+ function wait(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ function sendWebhookOnce(parsed, body, opts = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ const timeoutMs = opts.timeoutMs ?? 10_000;
26
+ const customHeaders = opts.headers || {};
27
+ const headers = {
28
+ 'User-Agent': `nerviq/${require('../package.json').version}`,
29
+ ...customHeaders,
30
+ 'Content-Length': Buffer.byteLength(body),
31
+ };
32
+
33
+ const hasContentTypeHeader = Object.keys(headers).some((name) => name.toLowerCase() === 'content-type');
34
+ if (!hasContentTypeHeader) {
35
+ headers['Content-Type'] = 'application/json';
36
+ }
37
+
38
+ const options = {
39
+ hostname: parsed.hostname,
40
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
41
+ path: parsed.pathname + (parsed.search || ''),
42
+ method: 'POST',
43
+ headers,
44
+ };
45
+
46
+ const transport = parsed.protocol === 'https:' ? https : http;
47
+
48
+ const req = transport.request(options, (res) => {
49
+ const chunks = [];
50
+ res.on('data', (c) => chunks.push(c));
51
+ res.on('end', () => {
52
+ const respBody = Buffer.concat(chunks).toString('utf8');
53
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: respBody });
54
+ });
55
+ });
56
+
57
+ req.setTimeout(timeoutMs, () => {
58
+ req.destroy(new Error(`Webhook request timed out after ${timeoutMs}ms`));
59
+ });
60
+
61
+ req.on('error', reject);
62
+ req.write(body);
63
+ req.end();
64
+ });
65
+ }
66
+
67
+ // ─── Webhook delivery ────────────────────────────────────────────────────────
18
68
 
19
69
  /**
20
70
  * POST JSON payload to a webhook URL.
21
71
  * @param {string} url - Destination URL (http or https)
22
72
  * @param {object} payload - JSON-serialisable object
23
- * @param {object} [opts]
24
- * @param {number} [opts.timeoutMs=10000]
25
- * @param {object} [opts.headers]
26
- * @returns {Promise<{ ok: boolean, status: number, body: string }>}
27
- */
28
- function sendWebhook(url, payload, opts = {}) {
29
- return new Promise((resolve, reject) => {
30
- let parsed;
31
- try {
32
- parsed = new URL(url);
33
- } catch {
34
- return reject(new Error(`Invalid webhook URL: ${url}`));
35
- }
36
-
37
- const body = JSON.stringify(payload);
38
- const timeoutMs = opts.timeoutMs ?? 10_000;
39
-
40
- const options = {
41
- hostname: parsed.hostname,
42
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
43
- path: parsed.pathname + (parsed.search || ''),
44
- method: 'POST',
45
- headers: {
46
- 'Content-Type': 'application/json',
47
- 'Content-Length': Buffer.byteLength(body),
48
- 'User-Agent': `nerviq/${require('../package.json').version}`,
49
- ...(opts.headers || {}),
50
- },
51
- };
52
-
53
- const transport = parsed.protocol === 'https:' ? https : http;
54
-
55
- const req = transport.request(options, (res) => {
56
- const chunks = [];
57
- res.on('data', (c) => chunks.push(c));
58
- res.on('end', () => {
59
- const respBody = Buffer.concat(chunks).toString('utf8');
60
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: respBody });
61
- });
62
- });
63
-
64
- req.setTimeout(timeoutMs, () => {
65
- req.destroy(new Error(`Webhook request timed out after ${timeoutMs}ms`));
66
- });
67
-
68
- req.on('error', reject);
69
- req.write(body);
70
- req.end();
71
- });
72
- }
73
+ * @param {object} [opts]
74
+ * @param {number} [opts.timeoutMs=10000]
75
+ * @param {object} [opts.headers]
76
+ * @param {number} [opts.retries=2]
77
+ * @param {number} [opts.retryDelayMs=400]
78
+ * @returns {Promise<{ ok: boolean, status: number, body: string, attempts: number }>}
79
+ */
80
+ async function sendWebhook(url, payload, opts = {}) {
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(url);
84
+ } catch {
85
+ throw new Error(`Invalid webhook URL: ${url}`);
86
+ }
87
+
88
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
89
+ throw new Error(`Unsupported webhook protocol: ${parsed.protocol}`);
90
+ }
91
+
92
+ const body = JSON.stringify(payload);
93
+ const retries = Number.isInteger(opts.retries) && opts.retries >= 0 ? opts.retries : 2;
94
+ const retryDelayMs = Number.isFinite(opts.retryDelayMs) && opts.retryDelayMs >= 0 ? opts.retryDelayMs : 400;
95
+ const maxAttempts = retries + 1;
96
+
97
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
98
+ try {
99
+ const response = await sendWebhookOnce(parsed, body, opts);
100
+ const enriched = { ...response, attempts: attempt };
101
+ const shouldRetry = RETRYABLE_STATUS_CODES.has(response.status) && attempt < maxAttempts;
102
+ if (!shouldRetry) {
103
+ return enriched;
104
+ }
105
+ } catch (error) {
106
+ error.attempts = attempt;
107
+ if (attempt >= maxAttempts) {
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ const delayMs = retryDelayMs * attempt;
113
+ if (delayMs > 0) {
114
+ await wait(delayMs);
115
+ }
116
+ }
117
+
118
+ return { ok: false, status: 0, body: '', attempts: maxAttempts };
119
+ }
73
120
 
74
121
  // ─── Slack formatting ─────────────────────────────────────────────────────────
75
122
 
@@ -7,7 +7,7 @@
7
7
  "audit.platform": "Platform: {platform} ({version})",
8
8
  "audit.domainPacks": "Domain packs: {packs}",
9
9
  "audit.scope": "Scope: {message}",
10
- "audit.score": "Score: {score}/100 ({passed}/{total} checks passing)",
10
+ "audit.score": "Live audit score: {score} ({passed}/{total} checks passing)",
11
11
  "audit.found": "Found: {files}",
12
12
  "audit.excellent": "Excellent setup — production-ready governance",
13
13
  "audit.strong": "Strong setup — {count} critical items to address",
@@ -7,7 +7,7 @@
7
7
  "audit.platform": "Plataforma: {platform} ({version})",
8
8
  "audit.domainPacks": "Paquetes de dominio: {packs}",
9
9
  "audit.scope": "Alcance: {message}",
10
- "audit.score": "Puntuación: {score}/100 ({passed}/{total} verificaciones aprobadas)",
10
+ "audit.score": "Puntuación de auditoría en vivo: {score} ({passed}/{total} verificaciones aprobadas)",
11
11
  "audit.found": "Encontrado: {files}",
12
12
  "audit.excellent": "Configuración excelente — gobernanza lista para producción",
13
13
  "audit.strong": "Configuración sólida — {count} elementos críticos por resolver",
@@ -0,0 +1,218 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PATH_ACTIONS = new Set(['read', 'write', 'edit', 'multiedit']);
5
+ const SECRET_PATH_RE = /(^|\/)(\.env(?:[^/]*)?|secrets?)(\/|$)/i;
6
+
7
+ function normalizeSlash(value) {
8
+ return String(value || '').replace(/\\/g, '/');
9
+ }
10
+
11
+ function stripWrappingQuotes(value) {
12
+ const trimmed = String(value || '').trim();
13
+ if (!trimmed) return '';
14
+ const first = trimmed[0];
15
+ const last = trimmed[trimmed.length - 1];
16
+ if ((first === '"' || first === "'") && first === last) {
17
+ return trimmed.slice(1, -1);
18
+ }
19
+ return trimmed;
20
+ }
21
+
22
+ function getProjectRoot(rootDir) {
23
+ try {
24
+ return fs.realpathSync.native(rootDir);
25
+ } catch {
26
+ return path.resolve(rootDir);
27
+ }
28
+ }
29
+
30
+ function splitPatternSegments(rawPattern, isAbsolute) {
31
+ const normalized = normalizeSlash(rawPattern);
32
+
33
+ if (/^[A-Za-z]:\//.test(normalized)) {
34
+ return normalized.slice(3).split('/').filter(Boolean);
35
+ }
36
+
37
+ if (isAbsolute && normalized.startsWith('/')) {
38
+ return normalized.slice(1).split('/').filter(Boolean);
39
+ }
40
+
41
+ return normalized.split('/').filter((segment) => segment && segment !== '.');
42
+ }
43
+
44
+ function hasGlob(segment) {
45
+ return /[*?[\]{}]/.test(segment);
46
+ }
47
+
48
+ function buildAbsolutePattern(rootDir, rawPattern) {
49
+ const normalized = stripWrappingQuotes(normalizeSlash(rawPattern).replace(/^file:\/\//i, ''));
50
+ if (!normalized) {
51
+ return {
52
+ absolutePattern: null,
53
+ normalizedInput: '',
54
+ isAbsolute: false,
55
+ traversalSegments: false,
56
+ };
57
+ }
58
+
59
+ const isAbsolute = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/');
60
+ const traversalSegments = normalized.split('/').some((segment) => segment === '..');
61
+ const segments = splitPatternSegments(normalized, isAbsolute);
62
+ let current = isAbsolute ? path.parse(path.resolve(normalized)).root : getProjectRoot(rootDir);
63
+
64
+ for (const segment of segments) {
65
+ const candidate = path.join(current, segment);
66
+ if (hasGlob(segment)) {
67
+ current = candidate;
68
+ continue;
69
+ }
70
+
71
+ try {
72
+ current = fs.realpathSync.native(candidate);
73
+ } catch {
74
+ current = candidate;
75
+ }
76
+ }
77
+
78
+ return {
79
+ absolutePattern: current,
80
+ normalizedInput: normalized,
81
+ isAbsolute,
82
+ traversalSegments,
83
+ };
84
+ }
85
+
86
+ function normalizePathPayload(rawPayload, rootDir) {
87
+ const {
88
+ absolutePattern,
89
+ normalizedInput,
90
+ isAbsolute,
91
+ traversalSegments,
92
+ } = buildAbsolutePattern(rootDir, rawPayload);
93
+
94
+ if (!absolutePattern) {
95
+ return {
96
+ normalizedPath: '',
97
+ repoRelativePath: '',
98
+ outsideRepo: false,
99
+ invalid: true,
100
+ isAbsolute,
101
+ traversalSegments,
102
+ };
103
+ }
104
+
105
+ const projectRoot = getProjectRoot(rootDir);
106
+ const relativePath = normalizeSlash(path.relative(projectRoot, absolutePattern));
107
+ const outsideRepo = relativePath === '..' || relativePath.startsWith('../') || /^[A-Za-z]:\//.test(relativePath);
108
+ const repoRelativePath = outsideRepo ? null : relativePath || '.';
109
+ const normalizedPath = outsideRepo
110
+ ? normalizeSlash(absolutePattern)
111
+ : `./${repoRelativePath}`;
112
+
113
+ return {
114
+ normalizedPath,
115
+ repoRelativePath,
116
+ outsideRepo,
117
+ invalid: traversalSegments && outsideRepo && !isAbsolute,
118
+ isAbsolute,
119
+ traversalSegments,
120
+ normalizedInput,
121
+ };
122
+ }
123
+
124
+ function normalizeCommandPayload(rawPayload) {
125
+ return stripWrappingQuotes(rawPayload).replace(/\s+/g, ' ').trim();
126
+ }
127
+
128
+ function normalizePermissionRule(rule, rootDir) {
129
+ if (typeof rule !== 'string' || !rule.trim()) return null;
130
+ const trimmed = rule.trim();
131
+ const match = trimmed.match(/^([A-Za-z]+)\((.*)\)$/);
132
+ if (!match) {
133
+ return {
134
+ raw: trimmed,
135
+ action: null,
136
+ payload: trimmed,
137
+ normalized: trimmed,
138
+ dedupeKey: trimmed.toLowerCase(),
139
+ kind: 'raw',
140
+ invalid: false,
141
+ outsideRepo: false,
142
+ protectsSecrets: false,
143
+ };
144
+ }
145
+
146
+ const action = match[1];
147
+ const payload = match[2].trim();
148
+ const actionKey = action.toLowerCase();
149
+
150
+ if (PATH_ACTIONS.has(actionKey)) {
151
+ const details = normalizePathPayload(payload, rootDir);
152
+ const dedupeKey = `${actionKey}:${details.normalizedPath.toLowerCase()}`;
153
+ return {
154
+ raw: trimmed,
155
+ action,
156
+ payload,
157
+ normalized: `${action}(${details.normalizedPath})`,
158
+ normalizedPath: details.normalizedPath,
159
+ repoRelativePath: details.repoRelativePath,
160
+ dedupeKey,
161
+ kind: 'path',
162
+ invalid: details.invalid,
163
+ outsideRepo: details.outsideRepo,
164
+ traversalSegments: details.traversalSegments,
165
+ isAbsolute: details.isAbsolute,
166
+ protectsSecrets: !details.outsideRepo && SECRET_PATH_RE.test(details.repoRelativePath || ''),
167
+ };
168
+ }
169
+
170
+ const normalizedPayload = normalizeCommandPayload(payload);
171
+ return {
172
+ raw: trimmed,
173
+ action,
174
+ payload,
175
+ normalized: `${action}(${normalizedPayload})`,
176
+ dedupeKey: `${actionKey}:${normalizedPayload.toLowerCase()}`,
177
+ kind: 'command',
178
+ invalid: false,
179
+ outsideRepo: false,
180
+ protectsSecrets: false,
181
+ };
182
+ }
183
+
184
+ function normalizePermissionRules(rules, rootDir) {
185
+ const seen = new Set();
186
+ const normalized = [];
187
+
188
+ for (const rule of Array.isArray(rules) ? rules : []) {
189
+ const entry = normalizePermissionRule(rule, rootDir);
190
+ if (!entry || entry.invalid) continue;
191
+ if (seen.has(entry.dedupeKey)) continue;
192
+ seen.add(entry.dedupeKey);
193
+ normalized.push(entry);
194
+ }
195
+
196
+ return normalized;
197
+ }
198
+
199
+ function collectClaudeDenyRules(ctx) {
200
+ const shared = ctx.jsonFile('.claude/settings.json');
201
+ const local = ctx.jsonFile('.claude/settings.local.json');
202
+ const denyRules = []
203
+ .concat(shared?.permissions?.deny || [])
204
+ .concat(local?.permissions?.deny || []);
205
+
206
+ return normalizePermissionRules(denyRules, ctx.dir);
207
+ }
208
+
209
+ function hasSecretDenyRule(rules) {
210
+ return (Array.isArray(rules) ? rules : []).some((rule) => rule && rule.protectsSecrets);
211
+ }
212
+
213
+ module.exports = {
214
+ collectClaudeDenyRules,
215
+ hasSecretDenyRule,
216
+ normalizePermissionRule,
217
+ normalizePermissionRules,
218
+ };
@@ -5,6 +5,15 @@ const EMBEDDED_SECRET_PATTERNS = [
5
5
  /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
6
6
  /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
7
7
  /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
8
+ /\bAZURE(?:_[A-Z0-9]+){0,4}_(?:API_)?KEY\s*[:=]\s*['"]?[A-Za-z0-9+/=_-]{20,}['"]?/g,
9
+ /\bDefaultEndpointsProtocol=https;AccountName=[^;\s]+;AccountKey=[A-Za-z0-9+/=]{20,};EndpointSuffix=core\.windows\.net\b/gi,
10
+ /\bEndpoint=sb:\/\/[^\s;]+;SharedAccessKeyName=[^;\s]+;SharedAccessKey=[A-Za-z0-9+/=]{20,}\b/gi,
11
+ /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^:\s/]+:[^@\s]{4,}@[^/\s]+(?:\/[^\s'"]*)?/gi,
12
+ /\b(?:Server|Host|Data Source)\s*=\s*[^;\n]+;[^\n]*(?:Password|Pwd)\s*=\s*[^;\n]{4,}/gi,
13
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
14
+ /-----BEGIN (?:OPENSSH|RSA|DSA|EC) PRIVATE KEY-----[\s\S]{40,}?-----END (?:OPENSSH|RSA|DSA|EC) PRIVATE KEY-----/g,
15
+ /-----BEGIN PRIVATE KEY-----[\s\S]{40,}?-----END PRIVATE KEY-----/g,
16
+ /"type"\s*:\s*"service_account"[\s\S]{0,1200}?"client_email"\s*:\s*"[^\"]+@[^"]*gserviceaccount\.com"[\s\S]{0,1200}?"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----[\s\S]{20,}?-----END PRIVATE KEY-----\\n?"/g,
8
17
  ];
9
18
 
10
19
  function containsEmbeddedSecret(text = '') {