mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-19 08:22:30 +02:00
merge: integrate Shannon 3.0 with public v2.6.0
- preserve the versioned and non-TTY banners from public main - keep workspace launch classification ahead of shared infrastructure setup - carry the eleven-commit Agentic SAST feature history unchanged - normalize Capella prompt endings to the accepted candidate tree
This commit is contained in:
+109
-19
@@ -18,14 +18,21 @@ import { setup } from './commands/setup.js';
|
||||
import { start } from './commands/start.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { stop } from './commands/stop.js';
|
||||
import { crash, fail, failUsage } from './errors.js';
|
||||
import { availableCommands, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { crash, enableJsonErrors, fail, failUsage, failWith, jsonErrorsEnabled } from './errors.js';
|
||||
import { availableCommands, helpTopics, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { commandPrefix, getMode, isLocal, type Mode } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
import { closestMatch } from './suggest.js';
|
||||
import { stdoutIsTerminal } from './tty.js';
|
||||
import { getVersion, getVersionLine } from './version.js';
|
||||
import { resolveDefaultWorkspace } from './workspaces.js';
|
||||
|
||||
/**
|
||||
* Refuse to run as root or under sudo. The worker container's Linux UID remapping
|
||||
* (docker.ts) stamps bind-mounted files with the invoking user's real uid/gid; under
|
||||
* sudo that uid is 0, so the repo, workspace, and report files would come back
|
||||
* owned by root instead of the person who ran the scan.
|
||||
*/
|
||||
function blockSudo(): void {
|
||||
const isSudo = !!process.env.SUDO_USER;
|
||||
const isRoot = process.geteuid?.() === 0;
|
||||
@@ -37,15 +44,38 @@ function blockSudo(): void {
|
||||
: [];
|
||||
|
||||
if (isSudo) {
|
||||
fail('Shannon must not be run with sudo.', 'Re-run this command as your normal user.', ...linuxHints);
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run with sudo.',
|
||||
'Re-run this command as your normal user.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run as the root user.',
|
||||
'Switch to a regular user account and re-run this command.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
|
||||
/** Commands whose `--json` output contract extends to failures. */
|
||||
const JSON_CAPABLE_COMMANDS = new Set(['status', 'scans', 'version', '--version', '-v']);
|
||||
|
||||
/**
|
||||
* Raw-argv sniff for the JSON error latch, decided before any guard or parse so even
|
||||
* a pre-dispatch failure honors it. Latches on `--json` or a malformed `--json=<value>`
|
||||
* (which still fails as a parse error — inside the envelope). Any other command that
|
||||
* receives `--json` keeps its normal unknown-option behavior.
|
||||
*/
|
||||
function wantsJsonErrors(argv: readonly string[]): boolean {
|
||||
const command = argv[0];
|
||||
if (command === undefined || !JSON_CAPABLE_COMMANDS.has(command)) {
|
||||
return false;
|
||||
}
|
||||
return argv.slice(1).some((arg) => arg === '--json' || arg.startsWith('--json='));
|
||||
}
|
||||
|
||||
/** Render `start`'s flags for the global help, from the same source as `start --help`. */
|
||||
function renderStartOptions(): string {
|
||||
const flagWidth = Math.max(...START_OPTIONS.map(([flag]) => flag.length));
|
||||
@@ -60,12 +90,16 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
const rows: ReadonlyArray<readonly [string, string]> = [
|
||||
...(mode === 'local' ? [] : [[`${prefix} setup`, 'Configure credentials'] as const]),
|
||||
[`${prefix} start --url <url> --repo <path> [options]`, 'Start a pentest scan'],
|
||||
[`${prefix} stop <workspace> [--yes]`, 'Stop one scan'],
|
||||
[`${prefix} stop [<workspace>] [--yes]`, 'Stop one scan (default: the single running scan)'],
|
||||
[`${prefix} stop --all [--yes]`, 'Stop all scans (Temporal stays up)'],
|
||||
[`${prefix} reset`, 'Stop everything and wipe all Temporal data'],
|
||||
[`${prefix} logs <workspace>`, "Show a scan's live log"],
|
||||
[`${prefix} status <workspace> [--json]`, 'Live phase/agent progress of one scan'],
|
||||
[`${prefix} scans [--json]`, 'List completed scans and their reports'],
|
||||
[`${prefix} logs [<workspace>]`, "Show a scan's live log (default: running or most recent)"],
|
||||
[`${prefix} logs [<workspace>] --agent <name>`, "Tail one agent's log; --list-agents to list them"],
|
||||
[
|
||||
`${prefix} status [<workspace>] [--json]`,
|
||||
'Live phase/agent progress of one scan (default: running or most recent)',
|
||||
],
|
||||
[`${prefix} scans [--json]`, 'List running and completed scans'],
|
||||
...(mode === 'local' ? [[`${prefix} build [--no-cache]`, 'Build worker image'] as const] : []),
|
||||
[`${prefix} version [--json]`, 'Show version'],
|
||||
[`${prefix} help`, 'Show this help'],
|
||||
@@ -152,6 +186,32 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace a viewing command (`logs`, `status`) acts on: the name the user
|
||||
* gave, or an inferred default. An inferred choice is announced on stderr so it is never a
|
||||
* silent guess; when nothing can be inferred, exit with usage guidance.
|
||||
*/
|
||||
function resolveViewingWorkspace(positional: string | undefined, usage: string): string {
|
||||
if (positional) {
|
||||
return positional;
|
||||
}
|
||||
|
||||
const target = resolveDefaultWorkspace({ allowFinished: true });
|
||||
if (target.kind === 'ok') {
|
||||
// In JSON mode stderr is reserved for the single error envelope, so a successful
|
||||
// inference stays silent — the JSON payload itself names the chosen workspace.
|
||||
if (!jsonErrorsEnabled()) {
|
||||
const which = target.running ? 'running scan' : 'most recent scan';
|
||||
console.error(`No workspace given; using ${which} "${target.workspace}".`);
|
||||
}
|
||||
return target.workspace;
|
||||
}
|
||||
if (target.kind === 'ambiguous') {
|
||||
failUsage('Multiple scans are running — specify which one:', ` ${target.running.join(', ')}`, '', usage);
|
||||
}
|
||||
failUsage('Workspace is required', usage);
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -163,13 +223,17 @@ async function main(): Promise<void> {
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (wantsJsonErrors(process.argv.slice(2))) {
|
||||
enableJsonErrors();
|
||||
}
|
||||
|
||||
blockSudo();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
|
||||
if (command === undefined || command === '--help' || command === '-h') {
|
||||
const topic = rest[0];
|
||||
if (topic && isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
@@ -181,6 +245,27 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// An explicit `help <topic>` names a topic on purpose, so an unknown one is a usage
|
||||
// error — unlike `--help <junk>`, where the junk is ignored and global help wins.
|
||||
if (command === 'help') {
|
||||
const topic = rest[0];
|
||||
// A flag (`help --help`) is a help request, not a topic name.
|
||||
if (topic === undefined || topic === 'help' || topic.startsWith('-')) {
|
||||
showHelp(false);
|
||||
return;
|
||||
}
|
||||
if (isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
return;
|
||||
}
|
||||
const suggestion = closestMatch(topic, helpTopics());
|
||||
failUsage(
|
||||
`Unknown help topic: ${topic}`,
|
||||
...(suggestion ? [`Did you mean '${suggestion}'?`] : []),
|
||||
`Run '${commandPrefix()} help' to see available commands.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reachable from any invocation: `-h`/`--help` anywhere wins over the rest of the line.
|
||||
if (isHelpableCommand(command) && (rest.includes('-h') || rest.includes('--help'))) {
|
||||
printCommandHelp(command);
|
||||
@@ -210,20 +295,25 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
|
||||
}
|
||||
logs(workspaceId);
|
||||
const { flags, values, positionals } = parseArgs(rest, {
|
||||
booleans: { listAgents: ['--list-agents'] },
|
||||
values: { agent: ['--agent'] },
|
||||
maxPositionals: 1,
|
||||
});
|
||||
const workspaceId = resolveViewingWorkspace(
|
||||
positionals[0],
|
||||
`Usage: ${commandPrefix()} logs [<workspace>] [--agent <name>] [--list-agents]`,
|
||||
);
|
||||
logs(workspaceId, {
|
||||
...(values.agent !== undefined && { agent: values.agent }),
|
||||
...(flags.listAgents && { listAgents: true }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { flags, positionals } = parseArgs(rest, { booleans: { json: ['--json'] }, maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace is required', `Usage: ${commandPrefix()} status <workspace> [--json]`);
|
||||
}
|
||||
const usage = `Usage: ${commandPrefix()} status [<workspace>] [--json]`;
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], usage);
|
||||
await status(workspaceId, { json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user