Skip to content

Commit e44c9d3

Browse files
CLI-245 PreToolUse secrets scanner callback
1 parent 5531733 commit e44c9d3

File tree

12 files changed

+587
-314
lines changed

12 files changed

+587
-314
lines changed

src/cli/command-tree.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { parseInteger } from './commands/_common/parsing';
3939
import { MAX_PAGE_SIZE } from '../sonarqube/projects';
4040
import { apiCommand, apiExtraHelpText, type ApiCommandOptions } from './commands/api/api';
4141
import { GENERIC_HTTP_METHODS } from '../sonarqube/client';
42+
import { claudePreToolUse } from './commands/hook/claude-pre-tool-use';
4243

4344
const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;
4445

@@ -229,14 +230,43 @@ COMMAND_TREE.command('self-update')
229230
.anonymousAction((options: SelfUpdateOptions) => selfUpdate(options));
230231

231232
// Hidden callback command — internal handlers for agent and git hooks.
232-
// Shell hook scripts call `sonar callback <event>` to delegate all business logic to TypeScript.
233-
export const callbackCommand = COMMAND_TREE.command('callback', { hidden: true })
233+
// Shell hook scripts call `sonar hook <event>` to delegate all business logic to TypeScript.
234+
export const callbackCommand = COMMAND_TREE.command('hook', { hidden: true })
234235
.description('Internal callback handlers for agent and git hooks')
235236
.enablePositionalOptions()
236237
.anonymousAction(function (this: Command) {
237238
this.outputHelp();
238239
});
239240

241+
callbackCommand
242+
.command('claude-pre-tool-use')
243+
.description('PreToolUse handler: scan files for secrets before agent reads them')
244+
.anonymousAction(() => claudePreToolUse());
245+
246+
// codex-pre-tool-use reuses the same handler (same hook event, different agent name)
247+
callbackCommand
248+
.command('codex-pre-tool-use')
249+
.description('PreToolUse handler for Codex: scan files for secrets before agent reads them')
250+
.anonymousAction(() => claudePreToolUse());
251+
252+
// agent-prompt-submit and agent-post-tool-use are installed by `sonar integrate claude`.
253+
// They are implemented in subsequent PRs; stubs here ensure scripts don't fail with
254+
// "unknown command" on a CLI that only has CLI-244/245.
255+
callbackCommand
256+
.command('agent-prompt-submit')
257+
.description('UserPromptSubmit handler: scan prompts for secrets before sending')
258+
.anonymousAction(() => {
259+
return;
260+
});
261+
262+
callbackCommand
263+
.command('agent-post-tool-use')
264+
.option('-p, --project <project>', 'SonarCloud project key')
265+
.description('PostToolUse handler: run SQAA analysis on modified files')
266+
.anonymousAction(() => {
267+
return;
268+
});
269+
240270
// Hidden flush command — only registered when running as a telemetry worker.
241271
if (process.env[TELEMETRY_FLUSH_MODE_ENV]) {
242272
COMMAND_TREE.command('flush-telemetry', { hidden: true }).anonymousAction(flushTelemetry);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// PreToolUse callback handler — scans files for secrets before the agent reads them.
22+
// Replaces the bash/PowerShell logic that was previously embedded in the hook script.
23+
24+
import { existsSync } from 'node:fs';
25+
import { resolveAuth } from '../../../lib/auth-resolver';
26+
import logger from '../../../lib/logger';
27+
import { readStdinJson } from './stdin';
28+
import { resolveSecretsBinaryPath, scanFiles, EXIT_CODE_SECRETS_FOUND } from './secrets-scan';
29+
30+
interface PreToolUsePayload {
31+
tool_name?: string;
32+
tool_input?: { file_path?: string };
33+
}
34+
35+
export async function claudePreToolUse(): Promise<void> {
36+
let payload: PreToolUsePayload;
37+
try {
38+
payload = await readStdinJson<PreToolUsePayload>();
39+
} catch {
40+
return; // unparseable stdin — allow
41+
}
42+
43+
if (payload.tool_name !== 'Read') return;
44+
45+
const filePath = payload.tool_input?.file_path;
46+
if (!filePath || !existsSync(filePath)) return;
47+
48+
const auth = await resolveAuth().catch(() => null);
49+
if (!auth) return; // not authenticated — allow gracefully
50+
51+
const binaryPath = resolveSecretsBinaryPath();
52+
if (!binaryPath) return; // binary not installed — allow gracefully
53+
54+
try {
55+
const exitCode = await scanFiles(binaryPath, [filePath], auth);
56+
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
57+
process.stdout.write(
58+
JSON.stringify({
59+
hookSpecificOutput: {
60+
hookEventName: 'PreToolUse',
61+
permissionDecision: 'deny',
62+
permissionDecisionReason: `Sonar detected secrets in file: ${filePath}`,
63+
},
64+
}) + '\n',
65+
);
66+
}
67+
} catch (err) {
68+
logger.debug(`PreToolUse secrets scan failed: ${(err as Error).message}`);
69+
}
70+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// Shared sonar-secrets invocation for all callback handlers.
22+
// Resolves the installed binary path silently — no download, no UI output.
23+
24+
import { existsSync } from 'node:fs';
25+
import { join } from 'node:path';
26+
import type { ResolvedAuth } from '../../../lib/auth-resolver';
27+
import { spawnProcess } from '../../../lib/process';
28+
import { BIN_DIR } from '../../../lib/config-constants';
29+
import { detectPlatform } from '../../../lib/platform-detector';
30+
import { buildLocalBinaryName } from '../_common/install/secrets';
31+
32+
const BINARY_AUTH_URL_ENV = 'SONAR_SECRETS_AUTH_URL';
33+
const BINARY_AUTH_TOKEN_ENV = 'SONAR_SECRETS_TOKEN';
34+
const SCAN_TIMEOUT_MS = 30000;
35+
36+
export const EXIT_CODE_SECRETS_FOUND = 51;
37+
38+
/**
39+
* Returns the path to the installed sonar-secrets binary, or null if not present.
40+
* Never downloads — use this in callback handlers where silent operation is required.
41+
*/
42+
export function resolveSecretsBinaryPath(): string | null {
43+
const platform = detectPlatform();
44+
const binaryPath = join(BIN_DIR, buildLocalBinaryName(platform));
45+
return existsSync(binaryPath) ? binaryPath : null;
46+
}
47+
48+
/**
49+
* Run secrets scan on the given files. Returns the binary exit code.
50+
* May throw on timeout; other errors are surfaced as non-zero exit codes.
51+
*/
52+
export async function scanFiles(
53+
binaryPath: string,
54+
files: string[],
55+
auth: ResolvedAuth,
56+
): Promise<number> {
57+
let killChild: (() => void) | undefined;
58+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
59+
try {
60+
const result = await Promise.race([
61+
spawnProcess(binaryPath, ['--non-interactive', ...files], {
62+
stdin: 'pipe',
63+
stdout: 'pipe',
64+
stderr: 'pipe',
65+
env: buildAuthEnv(auth),
66+
onSpawn: (kill) => {
67+
killChild = kill;
68+
},
69+
}),
70+
new Promise<never>((_, reject) => {
71+
timeoutId = setTimeout(() => {
72+
killChild?.();
73+
reject(new Error(`Scan timed out after ${SCAN_TIMEOUT_MS}ms`));
74+
}, SCAN_TIMEOUT_MS);
75+
}),
76+
]);
77+
return result.exitCode ?? 1;
78+
} finally {
79+
clearTimeout(timeoutId);
80+
}
81+
}
82+
83+
function buildAuthEnv(auth: ResolvedAuth): Record<string, string> {
84+
return { [BINARY_AUTH_URL_ENV]: auth.serverUrl, [BINARY_AUTH_TOKEN_ENV]: auth.token };
85+
}
Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,24 @@ export async function readStdinJson<T>(): Promise<T> {
3737
}
3838

3939
async function readRawStdin(): Promise<string> {
40-
return Promise.race([
41-
new Promise<string>((resolve, reject) => {
42-
const chunks: Buffer[] = [];
43-
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
44-
process.stdin.on('end', () => {
45-
resolve(Buffer.concat(chunks).toString('utf-8'));
46-
});
47-
process.stdin.on('error', reject);
48-
}),
49-
new Promise<never>((_, reject) =>
50-
setTimeout(() => {
51-
reject(new Error(`stdin read timed out after ${STDIN_TIMEOUT_MS}ms`));
52-
}, STDIN_TIMEOUT_MS),
53-
),
54-
]);
40+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
41+
try {
42+
return await Promise.race([
43+
new Promise<string>((resolve, reject) => {
44+
const chunks: Buffer[] = [];
45+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
46+
process.stdin.on('end', () => {
47+
resolve(Buffer.concat(chunks).toString('utf-8'));
48+
});
49+
process.stdin.on('error', reject);
50+
}),
51+
new Promise<never>((_, reject) => {
52+
timeoutId = setTimeout(() => {
53+
reject(new Error(`stdin read timed out after ${STDIN_TIMEOUT_MS}ms`));
54+
}, STDIN_TIMEOUT_MS);
55+
}),
56+
]);
57+
} finally {
58+
clearTimeout(timeoutId);
59+
}
5560
}

0 commit comments

Comments
 (0)