Skip to content

Commit 5f71102

Browse files
CLI-246 UserPromptSubmit secrets scanner callback
1 parent 6f2f053 commit 5f71102

5 files changed

Lines changed: 210 additions & 7 deletions

File tree

src/cli/command-tree.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ 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';
4242
import { claudePreToolUse } from './commands/callback/claude-pre-tool-use';
43+
import { agentPromptSubmit } from './commands/callback/agent-prompt-submit';
4344

4445
const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;
4546

@@ -249,16 +250,12 @@ callbackCommand
249250
.description('PreToolUse handler for Codex: scan files for secrets before agent reads them')
250251
.anonymousAction(() => claudePreToolUse());
251252

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.
255253
callbackCommand
256254
.command('agent-prompt-submit')
257-
.description('UserPromptSubmit handler: scan prompts for secrets before sending')
258-
.anonymousAction(() => {
259-
return;
260-
});
255+
.description('UserPromptSubmit handler: scan prompt for secrets before sending')
256+
.anonymousAction(() => agentPromptSubmit());
261257

258+
// agent-post-tool-use stub — implemented in CLI-247
262259
callbackCommand
263260
.command('agent-post-tool-use')
264261
.option('-p, --project <project>', 'SonarCloud project key')
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
// UserPromptSubmit callback handler — scans prompt text for secrets before it is sent.
22+
// Replaces the bash/PowerShell logic that was previously embedded in the hook script.
23+
24+
import { resolveAuth } from '../../../lib/auth-resolver';
25+
import logger from '../../../lib/logger';
26+
import { readStdinJson } from './stdin';
27+
import { resolveSecretsBinaryPath, scanText, EXIT_CODE_SECRETS_FOUND } from './secrets-scan';
28+
29+
interface PromptSubmitPayload {
30+
prompt?: string;
31+
}
32+
33+
export async function agentPromptSubmit(): Promise<void> {
34+
let payload: PromptSubmitPayload;
35+
try {
36+
payload = await readStdinJson<PromptSubmitPayload>();
37+
} catch {
38+
return; // unparseable stdin — allow
39+
}
40+
41+
const prompt = payload.prompt;
42+
if (!prompt) return;
43+
44+
const auth = await resolveAuth().catch(() => null);
45+
if (!auth) return; // not authenticated — allow gracefully
46+
47+
const binaryPath = resolveSecretsBinaryPath();
48+
if (!binaryPath) return; // binary not installed — allow gracefully
49+
50+
try {
51+
const exitCode = await scanText(binaryPath, prompt, auth);
52+
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
53+
process.stdout.write(
54+
JSON.stringify({ decision: 'block', reason: 'Sonar detected secrets in prompt' }) + '\n',
55+
);
56+
}
57+
} catch (err) {
58+
logger.debug(`UserPromptSubmit secrets scan failed: ${(err as Error).message}`);
59+
}
60+
}

src/cli/commands/callback/secrets-scan.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@ export async function scanFiles(
7575
}
7676
}
7777

78+
/**
79+
* Run secrets scan on arbitrary text via sonar-secrets --input (stdin). Returns the binary exit code.
80+
* Never throws — errors are surfaced as non-zero exit codes.
81+
*/
82+
export async function scanText(
83+
binaryPath: string,
84+
text: string,
85+
auth: ResolvedAuth,
86+
): Promise<number> {
87+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
88+
try {
89+
const result = await Promise.race([
90+
spawnProcess(binaryPath, ['--input'], {
91+
stdin: 'pipe',
92+
stdinData: text,
93+
stdout: 'pipe',
94+
stderr: 'pipe',
95+
env: buildAuthEnv(auth),
96+
}),
97+
new Promise<never>((_, reject) => {
98+
timeoutId = setTimeout(() => {
99+
reject(new Error(`Scan timed out after ${SCAN_TIMEOUT_MS}ms`));
100+
}, SCAN_TIMEOUT_MS);
101+
}),
102+
]);
103+
return result.exitCode ?? 1;
104+
} finally {
105+
clearTimeout(timeoutId);
106+
}
107+
}
108+
78109
function buildAuthEnv(auth: ResolvedAuth): Record<string, string> {
79110
if (auth.serverUrl && auth.token) {
80111
return { [BINARY_AUTH_URL_ENV]: auth.serverUrl, [BINARY_AUTH_TOKEN_ENV]: auth.token };

src/lib/process.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface SpawnOptions {
2828
cwd?: string;
2929
env?: Record<string, string>;
3030
stdin?: StdioMode;
31+
stdinData?: string;
3132
stdout?: StdioMode;
3233
stderr?: StdioMode;
3334
detached?: boolean;
@@ -70,6 +71,11 @@ export async function spawnProcess(
7071
});
7172
}
7273

74+
if (options.stdinData !== undefined && proc.stdin) {
75+
proc.stdin.write(options.stdinData);
76+
proc.stdin.end();
77+
}
78+
7379
proc.on('error', reject);
7480

7581
proc.on('exit', (code) => {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
22+
import * as authResolver from '../../src/lib/auth-resolver';
23+
import * as stdinModule from '../../src/cli/commands/callback/stdin';
24+
import * as secretsScan from '../../src/cli/commands/callback/secrets-scan';
25+
import { agentPromptSubmit } from '../../src/cli/commands/callback/agent-prompt-submit';
26+
27+
const { EXIT_CODE_SECRETS_FOUND } = secretsScan;
28+
29+
describe('agentPromptSubmit', () => {
30+
let stdoutSpy: ReturnType<typeof spyOn>;
31+
let resolveAuthSpy: ReturnType<typeof spyOn>;
32+
let readStdinJsonSpy: ReturnType<typeof spyOn>;
33+
let resolveSecretsBinaryPathSpy: ReturnType<typeof spyOn>;
34+
let scanTextSpy: ReturnType<typeof spyOn>;
35+
36+
beforeEach(() => {
37+
stdoutSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
38+
resolveAuthSpy = spyOn(authResolver, 'resolveAuth').mockResolvedValue({
39+
token: 'tok',
40+
serverUrl: 'https://sonarcloud.io',
41+
connectionType: 'cloud',
42+
orgKey: 'myorg',
43+
});
44+
readStdinJsonSpy = spyOn(stdinModule, 'readStdinJson').mockResolvedValue({
45+
prompt: 'please help me push code with my token ghp_secret',
46+
});
47+
resolveSecretsBinaryPathSpy = spyOn(secretsScan, 'resolveSecretsBinaryPath').mockReturnValue(
48+
'/usr/bin/sonar-secrets',
49+
);
50+
scanTextSpy = spyOn(secretsScan, 'scanText').mockResolvedValue(0);
51+
});
52+
53+
afterEach(() => {
54+
stdoutSpy.mockRestore();
55+
resolveAuthSpy.mockRestore();
56+
readStdinJsonSpy.mockRestore();
57+
resolveSecretsBinaryPathSpy.mockRestore();
58+
scanTextSpy.mockRestore();
59+
});
60+
61+
it('writes block JSON to stdout when secrets are found in prompt', async () => {
62+
scanTextSpy.mockResolvedValue(EXIT_CODE_SECRETS_FOUND);
63+
64+
await agentPromptSubmit();
65+
66+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
67+
const output = JSON.parse((stdoutSpy.mock.calls[0][0] as string).trim());
68+
expect(output.decision).toBe('block');
69+
expect(output.reason).toContain('secrets');
70+
});
71+
72+
it('passes prompt text directly to scanText', async () => {
73+
await agentPromptSubmit();
74+
75+
expect(scanTextSpy).toHaveBeenCalledTimes(1);
76+
const [, text] = scanTextSpy.mock.calls[0] as [string, string, unknown];
77+
expect(text).toContain('ghp_secret');
78+
});
79+
80+
it('writes nothing when no secrets are found', async () => {
81+
await agentPromptSubmit();
82+
expect(stdoutSpy).not.toHaveBeenCalled();
83+
});
84+
85+
it('returns without output when prompt is empty', async () => {
86+
readStdinJsonSpy.mockResolvedValue({ prompt: '' });
87+
88+
await agentPromptSubmit();
89+
90+
expect(scanTextSpy).not.toHaveBeenCalled();
91+
expect(stdoutSpy).not.toHaveBeenCalled();
92+
});
93+
94+
it('returns without output when auth is unavailable', async () => {
95+
resolveAuthSpy.mockResolvedValue(null);
96+
97+
await agentPromptSubmit();
98+
99+
expect(scanTextSpy).not.toHaveBeenCalled();
100+
});
101+
102+
it('returns without output when binary is not installed', async () => {
103+
resolveSecretsBinaryPathSpy.mockReturnValue(null);
104+
105+
await agentPromptSubmit();
106+
107+
expect(scanTextSpy).not.toHaveBeenCalled();
108+
});
109+
});

0 commit comments

Comments
 (0)