codeant-cli 0.3.7 → 0.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
2
2
  import { useApp } from 'ink';
3
3
  import SecretsApiHelper from '../utils/secretsApiHelper.js';
4
4
  import { detectSecrets } from '../utils/secretsDetector.js';
5
+ import { fetchApi } from '../utils/fetchApi.js';
6
+ import { detectRemote, detectRepoName, detectBaseUrl } from '../scm/index.js';
5
7
  import {
6
8
  renderInitializing,
7
9
  renderFetchingDiff,
@@ -9,9 +11,10 @@ import {
9
11
  renderNoFiles,
10
12
  renderError,
11
13
  renderDone,
14
+ renderBypassPrompt,
12
15
  } from '../components/SecretsUI.js';
13
16
 
14
- export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
17
+ export default function Secrets({ scanType = 'all', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null, hook = false }) {
15
18
  const { exit } = useApp();
16
19
  const [status, setStatus] = useState('initializing');
17
20
  const [secrets, setSecrets] = useState([]);
@@ -20,14 +23,6 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
20
23
  const [scanMeta, setScanMeta] = useState(null);
21
24
  const [startTime] = useState(() => Date.now());
22
25
 
23
- const shouldFailOn = (confidenceScore) => {
24
- const score = confidenceScore?.toUpperCase();
25
- if (score === 'FALSE_POSITIVE') return false;
26
- if (failOn === 'HIGH') return score === 'HIGH';
27
- if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
28
- return true;
29
- };
30
-
31
26
  useEffect(() => {
32
27
  let cancelled = false;
33
28
 
@@ -79,13 +74,65 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
79
74
  return () => { cancelled = true; };
80
75
  }, [scanType, include, exclude, lastNCommits, baseBranch, baseCommit]);
81
76
 
77
+ // Build secrets payload for API calls
78
+ function buildSecretsPayload() {
79
+ return secrets.flatMap(file =>
80
+ file.secrets.map(s => ({ type: s.type, file_path: file.file_path, line_number: s.line_number }))
81
+ );
82
+ }
83
+
84
+ // Build remote info using existing scm detection
85
+ function getRemoteBody() {
86
+ const service = detectRemote();
87
+ const repo = detectRepoName();
88
+ if (!service || !repo) return null;
89
+ return { service, repo, base_url: detectBaseUrl() };
90
+ }
91
+
92
+ // Fire-and-forget block event + exit 1
93
+ function reportBlockAndExit() {
94
+ const remote = getRemoteBody();
95
+ if (remote) {
96
+ fetchApi('/extension/push-protection/event', 'POST', {
97
+ ...remote,
98
+ secrets: buildSecretsPayload(),
99
+ }).catch(() => {});
100
+ }
101
+ setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
102
+ }
103
+
104
+ // Handle bypass selection
105
+ async function handleBypassSelect(reason) {
106
+ if (reason === 'cancel') {
107
+ reportBlockAndExit();
108
+ return;
109
+ }
110
+ const remote = getRemoteBody();
111
+ if (remote) {
112
+ try {
113
+ await fetchApi('/extension/push-protection/bypass', 'POST', {
114
+ ...remote,
115
+ secrets: buildSecretsPayload(),
116
+ reason: reason.startsWith('other:') ? 'other' : reason,
117
+ custom_reason: reason.startsWith('other:') ? reason.slice(6) : undefined,
118
+ });
119
+ } catch {
120
+ reportBlockAndExit();
121
+ return;
122
+ }
123
+ }
124
+ setTimeout(() => exit(), 100);
125
+ }
126
+
82
127
  useEffect(() => {
83
128
  if (status === 'done') {
84
- const hasBlockingSecrets = secrets.some(file =>
85
- file.secrets.some(secret => shouldFailOn(secret.confidence_score))
86
- );
87
- if (hasBlockingSecrets) {
88
- setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
129
+ const hasSecrets = secrets.some(file => file.secrets.length > 0);
130
+ if (hasSecrets) {
131
+ if (hook && process.stdin.isTTY) {
132
+ setStatus('prompt_bypass');
133
+ } else {
134
+ reportBlockAndExit();
135
+ }
89
136
  } else {
90
137
  setTimeout(() => exit(), 100);
91
138
  }
@@ -101,7 +148,8 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
101
148
  if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
102
149
  if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
103
150
  if (status === 'error') return renderError(error);
104
- if (status === 'done') return renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, scanMeta);
151
+ if (status === 'prompt_bypass') return renderBypassPrompt(secrets, handleBypassSelect);
152
+ if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
105
153
 
106
154
  return null;
107
155
  }
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { Box, Text } from 'ink';
2
+ import { Box, Text, useInput } from 'ink';
3
3
 
4
4
  // ── Constants ────────────────────────────────────────────────────────────────
5
5
 
@@ -7,19 +7,8 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧',
7
7
  const DIVIDER = '─'.repeat(55);
8
8
  const STEPS = ['Init', 'Fetch', 'Scan'];
9
9
 
10
- const CONFIDENCE_COLORS = {
11
- HIGH: 'red',
12
- MEDIUM: 'yellow',
13
- LOW: 'cyan',
14
- FALSE_POSITIVE: 'gray',
15
- };
16
-
17
10
  // ── Helpers ──────────────────────────────────────────────────────────────────
18
11
 
19
- function getConfidenceColor(score) {
20
- return CONFIDENCE_COLORS[score?.toUpperCase()] || 'gray';
21
- }
22
-
23
12
  function padEnd(str, len) {
24
13
  return str + ' '.repeat(Math.max(0, len - str.length));
25
14
  }
@@ -211,22 +200,13 @@ export function renderNotLoggedIn() {
211
200
  );
212
201
  }
213
202
 
214
- export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount, meta) {
203
+ export function renderDone(secrets, startTime, fileCount, meta) {
215
204
  const allSecrets = secrets.flatMap(file =>
216
205
  file.secrets.map(s => ({ ...s, file_path: file.file_path }))
217
206
  );
218
207
 
219
- const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
220
- const hasBlocking = blockingSecrets.length > 0;
221
208
  const elapsed = startTime ? formatElapsed(startTime) : null;
222
209
 
223
- // Count by confidence
224
- const counts = {};
225
- allSecrets.forEach(s => {
226
- const conf = s.confidence_score?.toUpperCase() || 'UNKNOWN';
227
- counts[conf] = (counts[conf] || 0) + 1;
228
- });
229
-
230
210
  // Group by file
231
211
  const grouped = {};
232
212
  allSecrets.forEach(s => {
@@ -269,9 +249,6 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
269
249
  ));
270
250
 
271
251
  fileSecrets.forEach((secret, si) => {
272
- const conf = secret.confidence_score?.toUpperCase() || 'UNKNOWN';
273
- const confColor = getConfidenceColor(conf);
274
- const isBlocking = shouldFailOn(secret.confidence_score);
275
252
  const isLast = si === fileSecrets.length - 1;
276
253
  const branch = isLast ? '└─' : '├─';
277
254
  const lineStr = secret.line_number ? `L${secret.line_number}` : '';
@@ -279,12 +256,8 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
279
256
  els.push(React.createElement(
280
257
  Box, { key: `s-${fi}-${si}` },
281
258
  React.createElement(Text, { color: 'gray' }, ` ${branch} `),
282
- React.createElement(Text, {
283
- color: confColor,
284
- bold: isBlocking,
285
- }, padEnd(conf, 16)),
286
259
  React.createElement(Text, { color: 'gray' }, padEnd(lineStr, 6)),
287
- React.createElement(Text, { color: isBlocking ? 'white' : 'gray', dimColor: !isBlocking }, secret.type || ''),
260
+ React.createElement(Text, { color: 'white' }, secret.type || ''),
288
261
  ));
289
262
  });
290
263
 
@@ -330,28 +303,17 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
330
303
 
331
304
  // Secret stats line
332
305
  if (allSecrets.length > 0) {
333
- const stats = [`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`];
334
- for (const conf of ['HIGH', 'MEDIUM', 'LOW', 'FALSE_POSITIVE']) {
335
- if (counts[conf]) {
336
- const label = conf === 'FALSE_POSITIVE' ? 'false positive' : conf.toLowerCase();
337
- stats.push(`${counts[conf]} ${label}`);
338
- }
339
- }
340
306
  els.push(React.createElement(
341
- Text, { key: 'stats', color: 'gray' }, stats.join(' · '),
307
+ Text, { key: 'stats', color: 'gray' },
308
+ `${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`,
342
309
  ));
343
310
  }
344
311
 
345
312
  // Status line
346
- if (hasBlocking) {
313
+ if (allSecrets.length > 0) {
347
314
  els.push(React.createElement(
348
315
  Text, { key: 'status', color: 'red', bold: true },
349
- `✗ ${blockingSecrets.length} blocking secret${blockingSecrets.length !== 1 ? 's' : ''} — remove before committing`,
350
- ));
351
- } else if (allSecrets.length > 0) {
352
- els.push(React.createElement(
353
- Text, { key: 'status', color: 'green', bold: true },
354
- '✓ No blocking secrets found',
316
+ `✗ ${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found — remove before committing`,
355
317
  ));
356
318
  } else {
357
319
  els.push(React.createElement(
@@ -364,3 +326,89 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
364
326
  Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els,
365
327
  );
366
328
  }
329
+
330
+
331
+ // ── Bypass Prompt ───────────────────────────────────────────────────────────
332
+
333
+ function BypassPrompt({ secrets, onSelect }) {
334
+ const [selected, setSelected] = React.useState(0);
335
+ const [mode, setMode] = React.useState('menu'); // 'menu' | 'typing'
336
+ const [customReason, setCustomReason] = React.useState('');
337
+ const allSecrets = secrets.flatMap(file =>
338
+ file.secrets.map(s => ({ ...s, file_path: file.file_path }))
339
+ );
340
+
341
+ const options = [
342
+ { label: "It's a false positive", value: 'false_positive' },
343
+ { label: "It's used in tests", value: 'used_in_tests' },
344
+ { label: "I'll fix it later", value: 'fix_later' },
345
+ { label: 'Other (type your reason)', value: 'other' },
346
+ { label: 'Cancel \u2014 block this push', value: 'cancel' },
347
+ ];
348
+
349
+ useInput((input, key) => {
350
+ if (mode === 'menu') {
351
+ if (key.upArrow) {
352
+ setSelected(s => (s - 1 + options.length) % options.length);
353
+ } else if (key.downArrow) {
354
+ setSelected(s => (s + 1) % options.length);
355
+ } else if (key.return) {
356
+ if (options[selected].value === 'other') {
357
+ setMode('typing');
358
+ } else {
359
+ onSelect(options[selected].value);
360
+ }
361
+ }
362
+ } else {
363
+ // typing mode
364
+ if (key.return) {
365
+ const reason = customReason.trim();
366
+ if (reason) {
367
+ onSelect('other:' + reason);
368
+ }
369
+ } else if (key.escape) {
370
+ setMode('menu');
371
+ setCustomReason('');
372
+ } else if (key.backspace || key.delete) {
373
+ setCustomReason(s => s.slice(0, -1));
374
+ } else if (input && !key.ctrl && !key.meta) {
375
+ setCustomReason(s => s + input);
376
+ }
377
+ }
378
+ });
379
+
380
+ const els = [];
381
+ els.push(React.createElement(Text, { key: 'div-top', color: 'gray', dimColor: true }, DIVIDER));
382
+ els.push(React.createElement(
383
+ Text, { key: 'title', color: 'yellow', bold: true },
384
+ `${allSecrets.length} secret(s) detected. Select an action:`,
385
+ ));
386
+ els.push(React.createElement(Text, { key: 'sp' }, ''));
387
+
388
+ if (mode === 'menu') {
389
+ options.forEach((opt, i) => {
390
+ const prefix = i === selected ? '> ' : ' ';
391
+ const color = i === selected ? 'cyan' : 'white';
392
+ els.push(React.createElement(
393
+ Text, { key: `opt-${i}`, color, bold: i === selected },
394
+ `${prefix}${opt.label}`,
395
+ ));
396
+ });
397
+ } else {
398
+ els.push(React.createElement(Text, { key: 'typing-label', color: 'cyan' }, ' Type your reason (Enter to submit, Esc to go back):'));
399
+ els.push(React.createElement(Text, { key: 'typing-sp' }, ''));
400
+ els.push(React.createElement(
401
+ Text, { key: 'typing-input', color: 'white' },
402
+ ` > ${customReason}\u2588`,
403
+ ));
404
+ }
405
+
406
+ els.push(React.createElement(Text, { key: 'sp2' }, ''));
407
+ els.push(React.createElement(Text, { key: 'div-bot', color: 'gray', dimColor: true }, DIVIDER));
408
+
409
+ return React.createElement(Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els);
410
+ }
411
+
412
+ export function renderBypassPrompt(secrets, onSelect) {
413
+ return React.createElement(BypassPrompt, { secrets, onSelect });
414
+ }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import GetBaseUrl from './commands/getBaseUrl.js';
10
10
  import Login from './commands/login.js';
11
11
  import Logout from './commands/logout.js';
12
12
  import Review from './commands/review.js';
13
+ import { runReviewHeadless } from './reviewHeadless.js';
13
14
  import Welcome from './components/Welcome.js';
14
15
  import * as scm from './scm/index.js';
15
16
  import { setConfigValue } from './utils/config.js';
@@ -58,9 +59,9 @@ program
58
59
  .option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
59
60
  .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
60
61
  .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
61
- .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
62
62
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
63
63
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
64
+ .option('--hook', 'Running from pre-push hook (enables bypass prompt)')
64
65
  .action((options) => {
65
66
  let scanType = 'all';
66
67
  let lastNCommits = 1;
@@ -95,9 +96,7 @@ program
95
96
  ? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
96
97
  : [];
97
98
 
98
- const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
99
-
100
- render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
99
+ render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit, hook: options.hook }));
101
100
  });
102
101
 
103
102
  program
@@ -114,7 +113,8 @@ program
114
113
  .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
115
114
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
116
115
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
117
- .action((options) => {
116
+ .option('--headless', 'Output clean JSON with no spinners (for agents and CI)')
117
+ .action(async (options) => {
118
118
  let scanType = 'all';
119
119
  let lastNCommits = 1;
120
120
  let baseBranch = null;
@@ -150,7 +150,23 @@ program
150
150
 
151
151
  const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
152
152
 
153
- render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
153
+ if (options.headless) {
154
+ const result = await runReviewHeadless({
155
+ workspacePath: process.cwd(),
156
+ scanType,
157
+ lastNCommits,
158
+ include,
159
+ exclude,
160
+ baseBranch,
161
+ baseCommit,
162
+ onProgress: (msg) => console.error(`[progress] ${msg}`),
163
+ onFilesReady: (files, meta) => console.error(`[files] Reviewing ${files.length} file(s)`),
164
+ });
165
+ console.log(JSON.stringify(result, null, 2));
166
+ process.exit(result.error ? 1 : 0);
167
+ } else {
168
+ render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
169
+ }
154
170
  });
155
171
 
156
172
  program
@@ -126,9 +126,9 @@ export async function runReviewHeadless(options = {}) {
126
126
 
127
127
  onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
128
128
 
129
- // ── Per-file agent turn loops (parallel) ────────────────────────────
129
+ // ── Per-file agent turn loops (parallel, fault-tolerant) ─────────────
130
130
  const perFileResults = await Promise.all(
131
- perFileRequests.map((fileReq) => {
131
+ perFileRequests.map(async (fileReq) => {
132
132
  const filename = fileReq._filename;
133
133
  delete fileReq._filename;
134
134
 
@@ -138,7 +138,15 @@ export async function runReviewHeadless(options = {}) {
138
138
  }
139
139
  delete fileReq.file_contents;
140
140
 
141
- return runTurnLoop(fileReq, gitRoot, false);
141
+ try {
142
+ onProgress(`Reviewing ${filename}...`);
143
+ const result = await runTurnLoop(fileReq, gitRoot, false);
144
+ onProgress(`Done reviewing ${filename}`);
145
+ return result;
146
+ } catch (err) {
147
+ console.error(`[error] Failed to review ${filename}: ${err.message}`);
148
+ return { finalMessage: null, finalOutput: null };
149
+ }
142
150
  })
143
151
  );
144
152
 
@@ -149,20 +157,26 @@ export async function runReviewHeadless(options = {}) {
149
157
  output: perFileResults[i].finalOutput,
150
158
  })).filter(r => r.output?.code_suggestions?.length > 0);
151
159
 
152
- // ── Per-file reflector loops (parallel) ──────────────────────────────
153
- onProgress('Running reflector...');
160
+ onProgress(`${perFileWithSuggestions.length} file(s) have suggestions, running reflector...`);
161
+
162
+ // ── Per-file reflector loops (parallel, fault-tolerant) ──────────────
154
163
  const reflectorResults = await Promise.all(
155
- perFileWithSuggestions.map(({ diff_content, suggestions }) =>
156
- runTurnLoop(
157
- {
158
- diff_content,
159
- prompt_template_name: 'reflector',
160
- extra_variables: { suggestion_str: suggestions },
161
- },
162
- gitRoot,
163
- true
164
- )
165
- )
164
+ perFileWithSuggestions.map(async ({ diff_content, suggestions }, i) => {
165
+ try {
166
+ return await runTurnLoop(
167
+ {
168
+ diff_content,
169
+ prompt_template_name: 'reflector',
170
+ extra_variables: { suggestion_str: suggestions },
171
+ },
172
+ gitRoot,
173
+ true
174
+ );
175
+ } catch (err) {
176
+ console.error(`[error] Reflector failed for file ${i}: ${err.message}`);
177
+ return { finalMessage: null, finalOutput: null };
178
+ }
179
+ })
166
180
  );
167
181
 
168
182
  // ── Parse results ───────────────────────────────────────────────────
package/src/scm/index.js CHANGED
@@ -95,3 +95,10 @@ export function listCodeReviews(opts) { return getProvider(opts.remote).listCode
95
95
  export function getCodeReview(opts) { return getProvider(opts.remote).getCodeReview(opts); }
96
96
  export function searchComments(opts) { return getProvider(opts.remote).searchComments(opts); }
97
97
  export function resolveConversation(opts) { return getProvider(opts.remote).resolveConversation(opts); }
98
+
99
+ // Auto-detect base URL (git host) from origin
100
+ export function detectBaseUrl() {
101
+ const origin = getOrigin();
102
+ if (!origin) return '';
103
+ const host = parseOriginUrl(origin); return host ? 'https://' + host : '';
104
+ }
@@ -23,6 +23,11 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
23
23
 
24
24
  try {
25
25
  const response = await fetch(url, options);
26
+
27
+ if (response.status === 403) {
28
+ throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
29
+ }
30
+
26
31
  const data = await response.json();
27
32
 
28
33
  if (!response.ok) {
@@ -3,32 +3,29 @@ import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync, mkdirSy
3
3
  import path from 'path';
4
4
 
5
5
  const HOOK_MARKER = '# codeant-push-protection';
6
+ const HOOK_MARKER_END = '# end-codeant-push-protection';
6
7
 
7
8
  /**
8
9
  * Build the full pre-push hook script (with shebang).
9
- * @param {string} failOn - Severity threshold (e.g. "HIGH", "CRITICAL")
10
10
  */
11
- function buildHookScript(failOn = 'HIGH') {
11
+ function buildHookScript() {
12
12
  return `#!/bin/sh
13
- ${buildHookBlock(failOn)}
13
+ ${buildHookBlock()}
14
14
  `;
15
15
  }
16
16
 
17
17
  /**
18
18
  * Build just the CodeAnt block (no shebang), used when appending to an existing hook.
19
- * @param {string} failOn - Severity threshold
20
19
  */
21
- function buildHookBlock(failOn = 'HIGH') {
20
+ function buildHookBlock() {
22
21
  return `${HOOK_MARKER}
23
22
  # Auto-installed by CodeAnt AI — blocks pushes containing secrets.
24
23
  # To disable: delete this hook or run "codeant push-protection disable"
25
24
  command -v codeant >/dev/null 2>&1 || exit 0
26
- codeant secrets --committed --fail-on ${failOn}
25
+ codeant secrets --committed --hook
27
26
  ${HOOK_MARKER_END}`;
28
27
  }
29
28
 
30
- const HOOK_MARKER_END = '# end-codeant-push-protection';
31
-
32
29
  /**
33
30
  * Replace the CodeAnt block in a hook file with new content (or remove it).
34
31
  */
@@ -37,7 +34,6 @@ function replaceCodeAntBlock(fileContent, newBlock) {
37
34
  let endIdx = fileContent.indexOf(HOOK_MARKER_END);
38
35
  if (startIdx === -1) return fileContent;
39
36
  if (endIdx === -1) {
40
- // Legacy hook without end marker — remove from start marker to EOF
41
37
  endIdx = fileContent.length;
42
38
  } else {
43
39
  endIdx += HOOK_MARKER_END.length;
@@ -85,13 +81,9 @@ function getHooksDir(gitRoot) {
85
81
  * Install a pre-push hook that runs secret scanning before push.
86
82
  *
87
83
  * @param {string} workspacePath - Path to the git repository
88
- * @param {object} [options]
89
- * @param {string} [options.failOn="HIGH"] - Severity threshold
90
84
  * @returns {{ installed: boolean, hookPath: string|null, message: string }}
91
85
  */
92
- export function installPushProtectionHook(workspacePath, options = {}) {
93
- const { failOn = 'HIGH' } = options;
94
-
86
+ export function installPushProtectionHook(workspacePath) {
95
87
  const gitRoot = findGitRoot(workspacePath);
96
88
  if (!gitRoot) {
97
89
  return { installed: false, hookPath: null, message: 'Not a git repository' };
@@ -108,19 +100,24 @@ export function installPushProtectionHook(workspacePath, options = {}) {
108
100
  const existing = readFileSync(hookPath, 'utf-8');
109
101
  if (existing.includes(HOOK_MARKER)) {
110
102
  // Replace only our block, preserve everything else
111
- const updated = replaceCodeAntBlock(existing, buildHookBlock(failOn));
103
+ const updated = replaceCodeAntBlock(existing, buildHookBlock());
112
104
  writeFileSync(hookPath, updated, 'utf-8');
113
105
  chmodSync(hookPath, 0o755);
114
106
  return { installed: true, hookPath, message: 'Hook updated' };
115
107
  }
116
- // There's a user-managed hook — append our block (no duplicate shebang)
117
- const appended = existing.trimEnd() + '\n\n' + buildHookBlock(failOn) + '\n';
108
+ // There's a user-managed hook — append only for shell hooks to avoid breaking non-shell scripts
109
+ const firstLine = existing.split('\n', 1)[0] || '';
110
+ const isShellHook = firstLine.startsWith('#!') ? /\/(ba|z|k)?sh(\s|$)/.test(firstLine) : true;
111
+ if (!isShellHook) {
112
+ return { installed: false, hookPath, message: 'Existing pre-push hook is non-shell; cannot append CodeAnt block safely' };
113
+ }
114
+ const appended = existing.trimEnd() + '\n\n' + buildHookBlock() + '\n';
118
115
  writeFileSync(hookPath, appended, 'utf-8');
119
116
  chmodSync(hookPath, 0o755);
120
117
  return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
121
118
  }
122
119
 
123
- writeFileSync(hookPath, buildHookScript(failOn), 'utf-8');
120
+ writeFileSync(hookPath, buildHookScript(), 'utf-8');
124
121
  chmodSync(hookPath, 0o755);
125
122
  return { installed: true, hookPath, message: 'Hook installed' };
126
123
  }
@@ -152,7 +149,6 @@ export function removePushProtectionHook(workspacePath) {
152
149
  // Remove our block (between start and end markers)
153
150
  const remaining = replaceCodeAntBlock(content, '').trim();
154
151
  if (!remaining || remaining === '#!/bin/sh') {
155
- // Nothing left — delete the file
156
152
  unlinkSync(hookPath);
157
153
  return { removed: true, message: 'Hook removed' };
158
154
  }
@@ -185,7 +185,6 @@ export function scanFile(filePath, content) {
185
185
  type: rule.id,
186
186
  description: rule.description,
187
187
  line_number: lineNum,
188
- confidence_score: rule.entropy > 0 ? 'HIGH' : 'MEDIUM',
189
188
  secret_snippet: maskSecret(secret),
190
189
  });
191
190
  }