@shomra/agent 0.3.17 → 0.3.18
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/NOTICE +1 -1
- package/README.md +57 -57
- package/package.json +3 -9
- package/shomra.mjs +9 -7168
- package/src/agents/hook-command.mjs +19 -0
- package/src/agents/hook-files.mjs +41 -0
- package/src/agents/installers.mjs +203 -0
- package/src/artifacts/matchers.mjs +59 -0
- package/src/artifacts/report.mjs +50 -0
- package/src/cli/flags.mjs +68 -0
- package/src/cli/help-sections.mjs +309 -0
- package/src/cli/help.mjs +27 -0
- package/src/cli/main.mjs +55 -0
- package/src/cli/registry.mjs +80 -0
- package/src/cli/suggestions.mjs +33 -0
- package/src/commands/add.mjs +149 -0
- package/src/commands/agent-identity.mjs +46 -0
- package/src/commands/check.mjs +194 -0
- package/src/commands/corpus.mjs +126 -0
- package/src/commands/design.mjs +168 -0
- package/src/commands/doctor.mjs +209 -0
- package/src/commands/fix.mjs +115 -0
- package/src/commands/gate.mjs +154 -0
- package/src/commands/git-hooks.mjs +163 -0
- package/src/commands/init.mjs +36 -0
- package/src/commands/install-hook.mjs +51 -0
- package/src/commands/llm-proxy.mjs +153 -0
- package/src/commands/mcp-add.mjs +185 -0
- package/src/commands/mcp.mjs +143 -0
- package/src/commands/memory-scan.mjs +181 -0
- package/src/commands/model-scan.mjs +99 -0
- package/src/commands/models.mjs +145 -0
- package/src/commands/new.mjs +64 -0
- package/src/commands/plan.mjs +87 -0
- package/src/commands/pr.mjs +249 -0
- package/src/commands/protect.mjs +38 -0
- package/src/commands/provenance.mjs +91 -0
- package/src/commands/redteam.mjs +166 -0
- package/src/commands/rules.mjs +220 -0
- package/src/commands/run.mjs +128 -0
- package/src/commands/scan-zip.mjs +118 -0
- package/src/commands/scan.mjs +102 -0
- package/src/commands/secrets.mjs +99 -0
- package/src/commands/status.mjs +50 -0
- package/src/commands/why.mjs +88 -0
- package/src/core/api-client.mjs +66 -0
- package/src/core/api-key.mjs +6 -0
- package/src/core/circuit-breaker.mjs +42 -0
- package/src/core/config.mjs +37 -0
- package/src/core/exit-codes.mjs +9 -0
- package/src/core/json-file.mjs +13 -0
- package/src/core/numbers.mjs +4 -0
- package/src/core/package-root.mjs +10 -0
- package/src/core/terminal.mjs +16 -0
- package/src/core/version.mjs +14 -0
- package/src/core/wire-limits.mjs +53 -0
- package/src/corpus/screening.mjs +127 -0
- package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
- package/src/detect/code-sast.mjs +2 -0
- package/{design.mjs → src/detect/design.mjs} +17 -106
- package/src/detect/guard-signals.mjs +18 -0
- package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
- package/src/detect/sast/chains.mjs +30 -0
- package/src/detect/sast/path-expressions.mjs +76 -0
- package/src/detect/sast/rules-chains.mjs +33 -0
- package/src/detect/sast/rules-config.mjs +51 -0
- package/src/detect/sast/rules-javascript.mjs +109 -0
- package/src/detect/sast/rules-python.mjs +292 -0
- package/src/detect/sast/scanner.mjs +104 -0
- package/src/detect/sast/source-lines.mjs +115 -0
- package/src/detect/sast/taint.mjs +71 -0
- package/src/detect/signals/artifacts.mjs +113 -0
- package/src/detect/signals/autonomy.mjs +55 -0
- package/src/detect/signals/config-markers.mjs +28 -0
- package/src/detect/signals/credential-harvest.mjs +64 -0
- package/src/detect/signals/durable-claims.mjs +73 -0
- package/src/detect/signals/egress.mjs +56 -0
- package/src/detect/signals/execution-hijack.mjs +128 -0
- package/src/detect/signals/gate.mjs +91 -0
- package/src/detect/signals/injection.mjs +55 -0
- package/src/detect/signals/lines.mjs +42 -0
- package/src/detect/signals/masking.mjs +99 -0
- package/src/detect/signals/memory.mjs +357 -0
- package/src/detect/signals/packages.mjs +45 -0
- package/src/detect/signals/propagation.mjs +86 -0
- package/src/detect/signals/prose-context.mjs +82 -0
- package/src/detect/signals/scan.mjs +91 -0
- package/src/detect/signals/secrets.mjs +85 -0
- package/src/detect/signals/sensitive.mjs +9 -0
- package/src/detect/signals/severity.mjs +10 -0
- package/src/detect/signals/shell.mjs +96 -0
- package/src/detect/signals/staged-fetch.mjs +66 -0
- package/src/detect/signals/text-match.mjs +35 -0
- package/src/gate/batch.mjs +157 -0
- package/src/gate/environment.mjs +122 -0
- package/src/gate/repo-policy.mjs +65 -0
- package/src/gate/result.mjs +53 -0
- package/src/gate/sarif.mjs +33 -0
- package/src/gate/sast.mjs +64 -0
- package/src/gate/suppressions.mjs +0 -0
- package/src/guard/classify.mjs +50 -0
- package/src/guard/emit.mjs +51 -0
- package/src/guard/ignore.mjs +24 -0
- package/src/guard/ledger.mjs +112 -0
- package/src/guard/model-load.mjs +50 -0
- package/src/guard/normalize.mjs +77 -0
- package/src/guard/options.mjs +10 -0
- package/src/guard/prompt-guard.mjs +184 -0
- package/src/guard/report.mjs +35 -0
- package/src/guard/result-guard.mjs +140 -0
- package/src/guard/tool-guard.mjs +166 -0
- package/src/inventory/agent-artifacts.mjs +5 -0
- package/src/inventory/agent-posture.mjs +249 -0
- package/src/inventory/artifacts/classify.mjs +27 -0
- package/src/inventory/artifacts/discover.mjs +187 -0
- package/src/inventory/artifacts/file-read.mjs +42 -0
- package/src/inventory/artifacts/hooks.mjs +14 -0
- package/src/inventory/artifacts/limits.mjs +37 -0
- package/src/inventory/artifacts/marketplaces.mjs +45 -0
- package/src/inventory/artifacts/roots.mjs +20 -0
- package/src/inventory/artifacts/walk.mjs +36 -0
- package/src/inventory/discovery/ai-dependencies.mjs +161 -0
- package/src/inventory/discovery/ai-tools.mjs +23 -0
- package/src/inventory/discovery/all.mjs +40 -0
- package/src/inventory/discovery/coding-agents.mjs +77 -0
- package/src/inventory/discovery/fs-read.mjs +36 -0
- package/src/inventory/discovery/local-runtimes.mjs +53 -0
- package/src/inventory/discovery/mcp-clients.mjs +67 -0
- package/src/inventory/discovery/mcp-servers.mjs +78 -0
- package/src/inventory/discovery/model-keys.mjs +97 -0
- package/src/inventory/discovery/platform.mjs +16 -0
- package/src/inventory/discovery/rules-files.mjs +25 -0
- package/src/inventory/discovery/vector-stores.mjs +176 -0
- package/src/inventory/discovery/workspace.mjs +124 -0
- package/src/inventory/discovery.mjs +10 -0
- package/src/mcp/child-process.mjs +50 -0
- package/src/mcp/config-wrapping.mjs +75 -0
- package/src/mcp/connect-gate.mjs +45 -0
- package/src/mcp/hosts.mjs +16 -0
- package/src/mcp/jsonrpc.mjs +48 -0
- package/src/mcp/lookup.mjs +50 -0
- package/src/mcp/screening.mjs +103 -0
- package/src/mcp/server-tools.mjs +97 -0
- package/src/mcp/server.mjs +102 -0
- package/src/mcp/shim.mjs +205 -0
- package/src/models/lookup.mjs +79 -0
- package/src/models/references.mjs +103 -0
- package/src/rules/context.mjs +98 -0
- package/src/rules/generate.mjs +103 -0
- package/src/rules/sections.mjs +145 -0
- package/src/scaffold/agent-project.mjs +185 -0
- package/src/scaffold/artifact-templates.mjs +35 -0
- package/code-sast.mjs +0 -1063
- package/discovery.mjs +0 -977
- package/guard-ledger.mjs +0 -239
- package/guard-signals.mjs +0 -2055
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { gateMachine } from '../core/api-client.mjs';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { EXIT_USAGE } from '../core/exit-codes.mjs';
|
|
6
|
+
import { bold, dim, red, yellow } from '../core/terminal.mjs';
|
|
7
|
+
import { CAP_LABEL, analyzeDesign } from '../detect/design.mjs';
|
|
8
|
+
import { detectEnv } from '../gate/environment.mjs';
|
|
9
|
+
import { emitGuardAsk } from '../guard/emit.mjs';
|
|
10
|
+
import { envFlag, resolveAgentFlag } from '../guard/options.mjs';
|
|
11
|
+
import { reportGuardDecision } from '../guard/report.mjs';
|
|
12
|
+
|
|
13
|
+
function planAdvice(r, { maxControls = 5 } = {}) {
|
|
14
|
+
if (r.verdict !== 'OPEN_PATH') return null;
|
|
15
|
+
const worst = r.paths.filter((p) => p.severity === r.worst).slice(0, 3);
|
|
16
|
+
const lines = [
|
|
17
|
+
`[Shomra] This plan closes ${r.paths.length} attack path${r.paths.length === 1 ? '' : 's'}. Build the guarded version now - it is far cheaper than retrofitting it:`,
|
|
18
|
+
];
|
|
19
|
+
for (const p of worst) lines.push(`- ${p.severity}: ${CAP_LABEL[p.source]} reaches ${CAP_LABEL[p.sink]}. ${p.story}`);
|
|
20
|
+
lines.push('Satisfy these as you implement:');
|
|
21
|
+
for (const c of r.controls.slice(0, maxControls)) lines.push(`- ${c.text}`);
|
|
22
|
+
lines.push('If the plan does not actually involve one of these, say so and continue - this reads your plan text, not your intent.');
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function cmdPlan(flags, positional) {
|
|
27
|
+
const target = positional[0] || flags.path;
|
|
28
|
+
if (!target) {
|
|
29
|
+
console.error(red('✗') + ' Usage: ' + bold('shomra plan <file|->') + dim(' (use - to pipe the plan on stdin)'));
|
|
30
|
+
process.exit(EXIT_USAGE);
|
|
31
|
+
}
|
|
32
|
+
let text;
|
|
33
|
+
if (target === '-' || flags.stdin) text = fs.readFileSync(0, 'utf8');
|
|
34
|
+
else {
|
|
35
|
+
const abs = path.resolve(String(target));
|
|
36
|
+
if (!fs.existsSync(abs)) { console.error(red('✗') + ` Not found: ${target}`); process.exit(EXIT_USAGE); }
|
|
37
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const r = analyzeDesign(text, { name: typeof target === 'string' ? String(target) : 'plan' });
|
|
41
|
+
const advice = planAdvice(r);
|
|
42
|
+
|
|
43
|
+
if (flags.json) console.log(JSON.stringify({ verdict: r.verdict, worst: r.worst, paths: r.paths, controls: r.controls, advice }, null, 2));
|
|
44
|
+
else if (advice) console.log('\n' + advice + '\n');
|
|
45
|
+
else console.log('\n ' + yellow('• No closed attack path in this plan text.') + dim(' Not a clearance - it reads the plan, not the code you will write.\n'));
|
|
46
|
+
|
|
47
|
+
if (r.worst === 'CRITICAL') process.exitCode = 1;
|
|
48
|
+
else if (r.verdict === 'OPEN_PATH' && flags.strict) process.exitCode = 2;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function cmdPlanGuard(flags) {
|
|
52
|
+
const agent = resolveAgentFlag(flags);
|
|
53
|
+
if (envFlag('SHOMRA_PLAN_GUARD_OFF')) process.exit(0);
|
|
54
|
+
|
|
55
|
+
let payload = {};
|
|
56
|
+
try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }
|
|
57
|
+
|
|
58
|
+
const input = payload.tool_input ?? payload.input ?? payload.arguments ?? payload;
|
|
59
|
+
const text = [input.plan, input.content, input.text, input.message, payload.plan]
|
|
60
|
+
.find((v) => typeof v === 'string' && v.trim().length > 40);
|
|
61
|
+
if (!text) process.exit(0);
|
|
62
|
+
|
|
63
|
+
const r = analyzeDesign(text, { name: 'plan' });
|
|
64
|
+
const advice = planAdvice(r);
|
|
65
|
+
if (!advice) process.exit(0);
|
|
66
|
+
|
|
67
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
68
|
+
await reportGuardDecision(url, apiKey, null, {
|
|
69
|
+
tool_name: 'PlanSubmit',
|
|
70
|
+
tool_input: { plan: text.slice(0, 4000) },
|
|
71
|
+
cwd: payload.cwd,
|
|
72
|
+
session_id: payload.session_id,
|
|
73
|
+
machine: gateMachine(),
|
|
74
|
+
env: detectEnv(),
|
|
75
|
+
agent,
|
|
76
|
+
client_decision: 'FLAG',
|
|
77
|
+
client_reason: `plan closes ${r.paths.length} attack path(s); worst ${r.worst}`,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (r.worst === 'CRITICAL' && envFlag('SHOMRA_GUARD_STRICT')) {
|
|
81
|
+
emitGuardAsk(agent, advice);
|
|
82
|
+
}
|
|
83
|
+
process.stdout.write(JSON.stringify({
|
|
84
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: advice },
|
|
85
|
+
}));
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ARTIFACT_MATCHERS, walkArtifacts } from '../artifacts/matchers.mjs';
|
|
5
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
6
|
+
import { EXIT_USAGE } from '../core/exit-codes.mjs';
|
|
7
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
8
|
+
import { gateArtifactList } from '../gate/batch.mjs';
|
|
9
|
+
import { detectEnv } from '../gate/environment.mjs';
|
|
10
|
+
import { toSarif } from '../gate/sarif.mjs';
|
|
11
|
+
|
|
12
|
+
const PR_WORKFLOW = `name: Shomra AI Security
|
|
13
|
+
on: pull_request
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
checks: write
|
|
17
|
+
jobs:
|
|
18
|
+
shomra:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
with: { fetch-depth: 0 } # full history so the PR diff resolves
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with: { node-version: 20 }
|
|
25
|
+
- run: npx @shomra/agent pr
|
|
26
|
+
env:
|
|
27
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
28
|
+
SHOMRA_API_KEY: \${{ secrets.SHOMRA_API_KEY }} # optional - applies org policy
|
|
29
|
+
SHOMRA_URL: \${{ secrets.SHOMRA_URL }} # optional - your backend
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function readGithubEvent() {
|
|
33
|
+
try { return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); } catch { return {}; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function githubApi(token, method, apiPath, body) {
|
|
37
|
+
const res = await fetch(`https://api.github.com${apiPath}`, {
|
|
38
|
+
method,
|
|
39
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json', 'User-Agent': 'shomra-agent' },
|
|
40
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) throw new Error(`GitHub ${method} ${apiPath} → ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
43
|
+
return res.json();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function gitChangedVsBase(root, base) {
|
|
47
|
+
const run = (args) => { try { return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString(); } catch { return null; } };
|
|
48
|
+
let out = null;
|
|
49
|
+
for (const b of [`origin/${base}`, base]) {
|
|
50
|
+
out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
|
|
51
|
+
if (out !== null) break;
|
|
52
|
+
}
|
|
53
|
+
if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
|
|
54
|
+
if (out === null) return null;
|
|
55
|
+
const files = out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
56
|
+
return files.filter((rel) => ARTIFACT_MATCHERS.some((m) => m.re.test(rel)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const GH_LEVEL = { CRITICAL: 'failure', HIGH: 'failure', MEDIUM: 'warning', LOW: 'notice', INFO: 'notice' };
|
|
60
|
+
|
|
61
|
+
const CHECK_RUN_NAME = 'Shomra AI Security';
|
|
62
|
+
const MAX_ANNOTATIONS = 50;
|
|
63
|
+
|
|
64
|
+
function writeWorkflow(flags) {
|
|
65
|
+
const workflow = path.resolve('.github/workflows/shomra.yml');
|
|
66
|
+
if (fs.existsSync(workflow) && !flags.force) {
|
|
67
|
+
console.error(`${red('✗')} ${path.relative(process.cwd(), workflow)} exists. Use ${bold('--force')}.`);
|
|
68
|
+
process.exit(EXIT_USAGE);
|
|
69
|
+
}
|
|
70
|
+
fs.mkdirSync(path.dirname(workflow), { recursive: true });
|
|
71
|
+
fs.writeFileSync(workflow, PR_WORKFLOW);
|
|
72
|
+
console.log(`\n ${green('✓ Wrote')} ${bold('.github/workflows/shomra.yml')} ${dim('- commit it; PRs will get an inline Shomra review.')}`);
|
|
73
|
+
console.log(`${dim(' Optional: add ') + bold('SHOMRA_API_KEY') + dim(' as a repo secret to also apply org policy.')}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function headShaFromGit() {
|
|
77
|
+
try {
|
|
78
|
+
return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolvePrContext(flags) {
|
|
85
|
+
const event = readGithubEvent();
|
|
86
|
+
const context = {
|
|
87
|
+
repo: flags.repo || process.env.GITHUB_REPOSITORY,
|
|
88
|
+
token: flags.token || process.env.SHOMRA_GH_TOKEN || process.env.GITHUB_TOKEN,
|
|
89
|
+
base: flags.base || process.env.GITHUB_BASE_REF || event.pull_request?.base?.ref || 'main',
|
|
90
|
+
headSha: flags.sha || event.pull_request?.head?.sha || process.env.GITHUB_SHA || headShaFromGit(),
|
|
91
|
+
prNumber: flags.pr || event.pull_request?.number || (String(process.env.GITHUB_REF || '').match(/refs\/pull\/(\d+)\//) || [])[1],
|
|
92
|
+
root: path.resolve(flags.path || '.'),
|
|
93
|
+
dryRun: !!flags['dry-run'],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (!context.repo || !context.headSha) {
|
|
97
|
+
console.error(`${red('✗')} Not in a GitHub PR context (need GITHUB_REPOSITORY + a head sha). Pass --repo / --sha, or use --dry-run.`);
|
|
98
|
+
process.exit(EXIT_USAGE);
|
|
99
|
+
}
|
|
100
|
+
if (!context.token && !context.dryRun) {
|
|
101
|
+
console.error(`${red('✗')} No GitHub token. Set GITHUB_TOKEN (CI) or --token, or preview with --dry-run.`);
|
|
102
|
+
process.exit(EXIT_USAGE);
|
|
103
|
+
}
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function emitSarif(flags, results) {
|
|
108
|
+
if (!flags.sarif) return;
|
|
109
|
+
const sarif = JSON.stringify(toSarif(results), null, 2);
|
|
110
|
+
if (typeof flags.sarif === 'string') {
|
|
111
|
+
fs.writeFileSync(path.resolve(String(flags.sarif)), sarif);
|
|
112
|
+
if (!flags.json) console.error(dim(` SARIF written → ${flags.sarif}`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(sarif);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildAnnotations(results) {
|
|
119
|
+
const annotations = [];
|
|
120
|
+
for (const result of results) {
|
|
121
|
+
for (const finding of result.findings || []) {
|
|
122
|
+
annotations.push({
|
|
123
|
+
path: finding.file || result.path,
|
|
124
|
+
start_line: finding.line || 1,
|
|
125
|
+
end_line: finding.line || 1,
|
|
126
|
+
annotation_level: GH_LEVEL[finding.severity] || 'warning',
|
|
127
|
+
title: `${finding.severity}: ${result.kind}`,
|
|
128
|
+
message: [
|
|
129
|
+
finding.title,
|
|
130
|
+
finding.remediationText ? `Fix: ${finding.remediationText}` : '',
|
|
131
|
+
`Run \`shomra fix ${result.path}\` to remediate.`,
|
|
132
|
+
].filter(Boolean).join('\n'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return annotations;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkRunConclusion(blocked, flagged, strict) {
|
|
140
|
+
if (blocked) return 'failure';
|
|
141
|
+
if (flagged) return strict ? 'failure' : 'neutral';
|
|
142
|
+
return 'success';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkRunSummary({ results, annotations, blocked, flagged, suppressed }) {
|
|
146
|
+
const headline = blocked ? `**${blocked} blocked**` : flagged ? `**${flagged} flagged**` : '**All clear**';
|
|
147
|
+
const suppressedNote = suppressed ? ` · ${suppressed} suppressed` : '';
|
|
148
|
+
return [
|
|
149
|
+
headline,
|
|
150
|
+
`· ${results.length} artifact(s) changed · ${annotations.length} finding(s)${suppressedNote}`,
|
|
151
|
+
'',
|
|
152
|
+
'| Artifact | Kind | Verdict | Findings |',
|
|
153
|
+
'| --- | --- | --- | --- |',
|
|
154
|
+
...results.map((r) => `| \`${r.path}\` | ${r.kind} | ${r.decision} | ${(r.findings || []).length} |`),
|
|
155
|
+
annotations.length > MAX_ANNOTATIONS ? `\n_Showing first ${MAX_ANNOTATIONS} of ${annotations.length} annotations._` : '',
|
|
156
|
+
].join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildCheckRun({ headSha, results, annotations, blocked, flagged, suppressed, strict }) {
|
|
160
|
+
const verdict = blocked ? `${blocked} blocked` : flagged ? `${flagged} flagged` : 'Clean';
|
|
161
|
+
return {
|
|
162
|
+
name: CHECK_RUN_NAME,
|
|
163
|
+
head_sha: headSha,
|
|
164
|
+
status: 'completed',
|
|
165
|
+
conclusion: checkRunConclusion(blocked, flagged, strict),
|
|
166
|
+
output: {
|
|
167
|
+
title: `${verdict} - ${results.length} changed artifact(s)`,
|
|
168
|
+
summary: checkRunSummary({ results, annotations, blocked, flagged, suppressed }),
|
|
169
|
+
annotations: annotations.slice(0, MAX_ANNOTATIONS),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function postCheckRun(token, repo, checkRun, flags) {
|
|
175
|
+
try {
|
|
176
|
+
const run = await githubApi(token, 'POST', `/repos/${repo}/check-runs`, checkRun);
|
|
177
|
+
if (flags.json) return;
|
|
178
|
+
const mark = checkRun.conclusion === 'failure' ? red('✗') : checkRun.conclusion === 'neutral' ? yellow('⚠') : green('✓');
|
|
179
|
+
console.log(` ${mark} Check run posted → ${dim(run.html_url || '')}`);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error(` ${yellow('⚠')} ${dim(`could not post check-run: ${error.message}`)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function reportNothingChanged({ flags, token, dryRun, repo, headSha }) {
|
|
186
|
+
emitSarif(flags, []);
|
|
187
|
+
if (!flags.json && flags.sarif !== true) console.log(green('\n ✓ No AI artifacts changed in this PR.\n'));
|
|
188
|
+
if (!token || dryRun) return;
|
|
189
|
+
|
|
190
|
+
await githubApi(token, 'POST', `/repos/${repo}/check-runs`, {
|
|
191
|
+
name: CHECK_RUN_NAME,
|
|
192
|
+
head_sha: headSha,
|
|
193
|
+
status: 'completed',
|
|
194
|
+
conclusion: 'success',
|
|
195
|
+
output: {
|
|
196
|
+
title: 'No AI artifacts changed',
|
|
197
|
+
summary: 'No MCP configs, skills, rules, hooks or agent cards changed in this PR.',
|
|
198
|
+
},
|
|
199
|
+
}).catch((error) => console.error(dim(` check-run: ${error.message}`)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function cmdPr(flags, positional) {
|
|
203
|
+
if (flags.init) {
|
|
204
|
+
writeWorkflow(flags);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
209
|
+
const { repo, token, base, headSha, prNumber, root, dryRun } = resolvePrContext(flags);
|
|
210
|
+
|
|
211
|
+
const changed = gitChangedVsBase(root, base);
|
|
212
|
+
const allArtifacts = walkArtifacts(root);
|
|
213
|
+
const artifacts = changed === null
|
|
214
|
+
? allArtifacts
|
|
215
|
+
: allArtifacts.filter((artifact) => new Set(changed).has(artifact.rel));
|
|
216
|
+
|
|
217
|
+
if (!artifacts.length) {
|
|
218
|
+
await reportNothingChanged({ flags, token, dryRun, repo, headSha });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { results, blocked, flagged, suppressed } = await gateArtifactList(artifacts, {
|
|
223
|
+
apiKey, url, root, env: detectEnv(), flags: { ...flags, json: true },
|
|
224
|
+
});
|
|
225
|
+
emitSarif(flags, results);
|
|
226
|
+
|
|
227
|
+
const annotations = buildAnnotations(results);
|
|
228
|
+
const checkRun = buildCheckRun({ headSha, results, annotations, blocked, flagged, suppressed, strict: flags.strict });
|
|
229
|
+
|
|
230
|
+
if (dryRun || flags.json) {
|
|
231
|
+
console.log(JSON.stringify({
|
|
232
|
+
repo,
|
|
233
|
+
prNumber: prNumber ?? null,
|
|
234
|
+
headSha,
|
|
235
|
+
base,
|
|
236
|
+
conclusion: checkRun.conclusion,
|
|
237
|
+
artifacts: results.length,
|
|
238
|
+
findings: annotations.length,
|
|
239
|
+
checkRun: dryRun ? checkRun : undefined,
|
|
240
|
+
}, null, 2));
|
|
241
|
+
} else if (flags.sarif !== true) {
|
|
242
|
+
console.log(bold(cyan('\n Shomra pr')) + dim(` - ${repo} #${prNumber ?? '?'} · ${results.length} changed artifact(s) · ${annotations.length} finding(s)`));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (token && !dryRun) await postCheckRun(token, repo, checkRun, flags);
|
|
246
|
+
|
|
247
|
+
if (blocked) process.exitCode = 1;
|
|
248
|
+
else if (flagged && flags.strict) process.exitCode = 2;
|
|
249
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AGENT_INSTALLERS, AGENT_LABELS } from '../agents/installers.mjs';
|
|
2
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
3
|
+
import { discoverAll } from '../inventory/discovery.mjs';
|
|
4
|
+
|
|
5
|
+
export function cmdProtect(flags) {
|
|
6
|
+
const assets = discoverAll();
|
|
7
|
+
const labelToKey = Object.fromEntries(Object.entries(AGENT_LABELS).map(([k, v]) => [v, k]));
|
|
8
|
+
const detected = assets
|
|
9
|
+
.filter((a) => a.type === 'AI_AGENT')
|
|
10
|
+
.map((a) => ({ label: a.name, key: labelToKey[a.name], guarded: !!a.metadata?.guarded }))
|
|
11
|
+
.filter((a) => a.key && AGENT_INSTALLERS[a.key]);
|
|
12
|
+
|
|
13
|
+
if (!detected.length) {
|
|
14
|
+
console.log(dim('\n No supported coding agents detected on this machine.'));
|
|
15
|
+
console.log(dim(' Install one (Claude Code, Cursor, Gemini/Codex/Copilot CLI, Cline, Aider…) and re-run, or force all: ') + bold('shomra install-hook --agent all') + '\n');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const global = !flags.local;
|
|
20
|
+
console.log(bold(cyan('\n Shomra protect')) + dim(` - wiring the runtime firewall for ${detected.length} coding agent${detected.length > 1 ? 's' : ''} (${global ? 'machine-wide' : 'this repo'})`));
|
|
21
|
+
let wired = 0, already = 0;
|
|
22
|
+
for (const a of detected) {
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const { file, changed } = AGENT_INSTALLERS[a.key](global);
|
|
26
|
+
if (changed) { wired++; console.log(` ${green('✓')} Protected ${bold(AGENT_LABELS[a.key])} ${dim('→ ' + file)}`); }
|
|
27
|
+
else { already++; console.log(` ${yellow('•')} ${AGENT_LABELS[a.key]} ${dim('already protected (' + file + ')')}`); }
|
|
28
|
+
if (a.key === 'aider') console.log(dim(' Aider has no tool hook - routes model calls through the LLM Guard proxy. Start ') + bold('shomra llm-proxy') + dim('.'));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.log(` ${red('✗')} ${AGENT_LABELS[a.key]} ${dim('- ' + e.message)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
console.log(`\n ${wired ? green(`✓ ${wired} newly protected`) : green('✓ Already protected')}${already ? dim(` · ${already} already wired`) : ''}${dim(' - tool calls, results and prompts now screened on-machine.')}`);
|
|
34
|
+
|
|
35
|
+
console.log(dim('\n Get in front of the model too - both write into this repo, so run them where you mean to:'));
|
|
36
|
+
console.log(` ${bold('shomra rules --write')} ${dim('teach the agent what gets blocked, so it never writes it')}`);
|
|
37
|
+
console.log(` ${bold('shomra mcp install')} ${dim('let the agent gate its own proposed content before writing')}\n`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { api } from '../core/api-client.mjs';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { EXIT_USAGE } from '../core/exit-codes.mjs';
|
|
6
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
7
|
+
|
|
8
|
+
function gitChangedPaths(root, { staged, base }) {
|
|
9
|
+
const run = (args) => {
|
|
10
|
+
try {
|
|
11
|
+
return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
let out = null;
|
|
17
|
+
if (staged) {
|
|
18
|
+
out = run('diff --cached --name-only --relative --diff-filter=ACM');
|
|
19
|
+
} else if (base) {
|
|
20
|
+
for (const b of [`origin/${base}`, base]) {
|
|
21
|
+
out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
|
|
22
|
+
if (out !== null) break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
|
|
26
|
+
if (out === null) return null;
|
|
27
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function cmdProvenance(flags, positional) {
|
|
31
|
+
const root = path.resolve(flags.path || positional[0] || '.');
|
|
32
|
+
const staged = !!flags.staged;
|
|
33
|
+
const base = flags.base || (staged ? null : process.env.GITHUB_BASE_REF || 'main');
|
|
34
|
+
|
|
35
|
+
const paths = gitChangedPaths(root, { staged, base });
|
|
36
|
+
if (paths === null) {
|
|
37
|
+
console.error(red('✗') + ' Not a git repository (or no diff available). Run inside a repo, or pass --base <ref>.');
|
|
38
|
+
process.exit(EXIT_USAGE);
|
|
39
|
+
}
|
|
40
|
+
if (!paths.length) {
|
|
41
|
+
if (flags.json) console.log(JSON.stringify({ files: [], agentAuthored: 0, coverage: 'NO_TELEMETRY', summary: 'no changed files' }, null, 2));
|
|
42
|
+
else console.log(green('\n ✓ No changed files to attribute.\n'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cfg = loadConfig();
|
|
47
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await api(url, apiKey, '/gate/provenance', {
|
|
51
|
+
paths,
|
|
52
|
+
repo: flags.repo || process.env.GITHUB_REPOSITORY || undefined,
|
|
53
|
+
sessionId: flags.session || undefined,
|
|
54
|
+
sinceHours: flags.since ? Number(flags.since) : undefined,
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
|
|
58
|
+
console.error(yellow('!') + ` Provenance unavailable (${e.message}). Authorship not established.`);
|
|
59
|
+
process.exit(flags['fail-on-blocked'] ? 1 : EXIT_USAGE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (flags.json) {
|
|
63
|
+
console.log(JSON.stringify(res, null, 2));
|
|
64
|
+
} else if (flags.trailer) {
|
|
65
|
+
for (const t of res.trailers || []) console.log(t);
|
|
66
|
+
} else {
|
|
67
|
+
const noTel = res.coverage === 'NO_TELEMETRY';
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(` ${bold('Commit provenance')} ${dim(`· ${res.files.length} changed file(s)`)}`);
|
|
70
|
+
console.log(` ${noTel ? yellow('⚠ ' + res.summary) : res.summary}`);
|
|
71
|
+
if (noTel) {
|
|
72
|
+
console.log(dim(' No firewall telemetry for this range - this is NOT a claim that a human wrote them.'));
|
|
73
|
+
console.log(dim(' Install the runtime hook with ') + bold('shomra protect') + dim(' to attribute future work.'));
|
|
74
|
+
}
|
|
75
|
+
console.log('');
|
|
76
|
+
for (const f of res.files.slice(0, 40)) {
|
|
77
|
+
const tag =
|
|
78
|
+
f.authorship === 'AGENT' ? cyan('agent') : f.authorship === 'BLOCKED_ATTEMPT' ? red('blocked') : dim('unattributed');
|
|
79
|
+
const who = f.agents?.length ? dim(` ${f.agents.join(', ')}`) : '';
|
|
80
|
+
const amb = f.ambiguous ? yellow(' ~ambiguous') : '';
|
|
81
|
+
console.log(` ${tag.padEnd(22)} ${f.path}${who}${amb}`);
|
|
82
|
+
}
|
|
83
|
+
if (res.files.length > 40) console.log(dim(` …and ${res.files.length - 40} more`));
|
|
84
|
+
console.log('');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (flags['fail-on-blocked'] && res.blockedAttempts > 0) {
|
|
88
|
+
console.error(red('✗') + ` ${res.blockedAttempts} file(s) the firewall blocked were modified anyway.`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { api } from '../core/api-client.mjs';
|
|
3
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
4
|
+
import { exitNotConfigured } from '../core/exit-codes.mjs';
|
|
5
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
6
|
+
|
|
7
|
+
export async function cmdRedteam(flags) {
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
10
|
+
if (!apiKey) {
|
|
11
|
+
exitNotConfigured();
|
|
12
|
+
}
|
|
13
|
+
const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
|
|
14
|
+
const scenarioKeys = typeof flags.scenarios === 'string' ? flags.scenarios.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
15
|
+
|
|
16
|
+
if (!flags.json) process.stdout.write(dim(`\n Red-teaming your ${targetKind === 'model' ? 'model' : 'LLM Guard'}… `));
|
|
17
|
+
let run;
|
|
18
|
+
try {
|
|
19
|
+
run = await api(url, apiKey, '/redteam/agent-run', {
|
|
20
|
+
targetKind,
|
|
21
|
+
...(scenarioKeys ? { scenarioKeys } : {}),
|
|
22
|
+
...(flags.project ? { projectId: String(flags.project) } : {}),
|
|
23
|
+
...(flags.evolve ? { evolutionary: true } : flags.adaptive ? { adaptive: true } : {}),
|
|
24
|
+
actor: `${os.hostname()}/${os.userInfo().username}`,
|
|
25
|
+
});
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (!flags.json) console.log(red('failed'));
|
|
28
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
if (!flags.json) console.log(green('done'));
|
|
32
|
+
|
|
33
|
+
if (flags.json) {
|
|
34
|
+
console.log(JSON.stringify(run, null, 2));
|
|
35
|
+
} else {
|
|
36
|
+
const rc = run.resilience >= 80 ? green : run.resilience >= 60 ? yellow : red;
|
|
37
|
+
console.log(`\n ${bold(run.label)} ${dim(`· ${run.targetKind} · ${run.scenarioCount} scenarios · ${run.attemptCount} attempts`)}`);
|
|
38
|
+
console.log(` ${rc('●')} Resilience ${rc(bold(run.resilience + '/100'))} ${dim(`· ${run.breachedCount} breached · ${run.blockedCount} blocked${run.regressedCount ? ' · ' : ''}`)}${run.regressedCount ? red(run.regressedCount + ' regressed') : ''}`);
|
|
39
|
+
for (const r of (run.results || []).filter((x) => x.breached)) {
|
|
40
|
+
console.log(` ${red('✗')} ${bold(r.title)} ${dim(r.technique)} ${r.regressed ? red('· REGRESSED') : ''}`);
|
|
41
|
+
}
|
|
42
|
+
const held = (run.results || []).filter((x) => !x.breached).length;
|
|
43
|
+
if (held) console.log(` ${green('✓')} ${dim(`${held} scenario(s) held`)}`);
|
|
44
|
+
console.log(
|
|
45
|
+
'\n ' +
|
|
46
|
+
(run.breachedCount === 0
|
|
47
|
+
? green('✓ All scenarios defended.')
|
|
48
|
+
: yellow(`⚠ ${run.breachedCount} scenario(s) breached your defenses.`)) +
|
|
49
|
+
dim(' Full report in the Shomra dashboard → Red Team.\n'),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const min = flags.min != null ? parseInt(flags.min, 10) : null;
|
|
54
|
+
const belowFloor = Number.isFinite(min) && run.resilience < min;
|
|
55
|
+
const regressed = flags['fail-on-regression'] && run.regressedCount > 0;
|
|
56
|
+
if (belowFloor) console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
|
|
57
|
+
if (regressed) console.error(red(` ✗ ${run.regressedCount} scenario(s) regressed since the last run.`));
|
|
58
|
+
if (belowFloor || regressed) process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function cmdCampaign(flags) {
|
|
62
|
+
const cfg = loadConfig();
|
|
63
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
exitNotConfigured();
|
|
66
|
+
}
|
|
67
|
+
const objectiveKeys = typeof flags.objectives === 'string' ? flags.objectives.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
68
|
+
const turns = flags.turns != null ? parseInt(flags.turns, 10) : undefined;
|
|
69
|
+
|
|
70
|
+
if (!flags.json) process.stdout.write(dim('\n Running an autonomous adversary campaign against your assistant… '));
|
|
71
|
+
let run;
|
|
72
|
+
try {
|
|
73
|
+
run = await api(url, apiKey, '/redteam/agent-campaign', {
|
|
74
|
+
...(objectiveKeys ? { objectiveKeys } : {}),
|
|
75
|
+
...(Number.isFinite(turns) ? { turns } : {}),
|
|
76
|
+
...(flags.project ? { projectId: String(flags.project) } : {}),
|
|
77
|
+
actor: `${os.hostname()}/${os.userInfo().username}`,
|
|
78
|
+
});
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (!flags.json) console.log(red('failed'));
|
|
81
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
if (!flags.json) console.log(green('done'));
|
|
85
|
+
|
|
86
|
+
if (flags.json) {
|
|
87
|
+
console.log(JSON.stringify(run, null, 2));
|
|
88
|
+
} else {
|
|
89
|
+
const rc = run.resilience >= 80 ? green : run.resilience >= 60 ? yellow : red;
|
|
90
|
+
console.log(`\n ${bold(run.label)} ${dim(`· ${run.scenarioCount} objective(s) · ${run.attemptCount} turns fired`)}`);
|
|
91
|
+
console.log(` ${rc('●')} Resilience ${rc(bold(run.resilience + '/100'))} ${dim(`· ${run.breachedCount} objective(s) achieved · ${run.blockedCount} turn(s) blocked by the guard`)}`);
|
|
92
|
+
for (const r of (run.results || []).filter((x) => x.breached)) {
|
|
93
|
+
const bt = (r.evidenceJson?.outcomes || []).filter((o) => o.breached).map((o) => o.index + 1);
|
|
94
|
+
const inTurns = bt.length ? Math.min(...bt) : r.attempts;
|
|
95
|
+
console.log(` ${red('✗')} ${bold(r.title)} ${dim(`${r.technique} · achieved in ${inTurns} turn(s)`)}`);
|
|
96
|
+
}
|
|
97
|
+
const held = (run.results || []).filter((x) => !x.breached).length;
|
|
98
|
+
if (held) console.log(` ${green('✓')} ${dim(`${held} objective(s) defended`)}`);
|
|
99
|
+
console.log(
|
|
100
|
+
'\n ' +
|
|
101
|
+
(run.breachedCount === 0
|
|
102
|
+
? green('✓ Every objective was defended.')
|
|
103
|
+
: yellow(`⚠ ${run.breachedCount} objective(s) achieved by the autonomous attacker.`)) +
|
|
104
|
+
dim(' Full transcript in the Shomra dashboard → Red Team. Harden the guard against the winning turns.\n'),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const min = flags.min != null ? parseInt(flags.min, 10) : null;
|
|
109
|
+
if (Number.isFinite(min) && run.resilience < min) {
|
|
110
|
+
console.error(red(` ✗ Resilience ${run.resilience} is below the required ${min}.`));
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function cmdHarden(flags) {
|
|
116
|
+
const cfg = loadConfig();
|
|
117
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
118
|
+
if (!apiKey) {
|
|
119
|
+
exitNotConfigured();
|
|
120
|
+
}
|
|
121
|
+
const targetKind = flags.target === 'model' ? 'model' : 'llm-guard';
|
|
122
|
+
const apply = !!flags.apply;
|
|
123
|
+
const runId = flags.run ? String(flags.run) : undefined;
|
|
124
|
+
|
|
125
|
+
if (!flags.json) process.stdout.write(
|
|
126
|
+
dim(`\n ${runId ? 'Hardening from run ' + runId : 'Red-teaming your ' + (targetKind === 'model' ? 'model' : 'LLM Guard') + ', then hardening'}… `),
|
|
127
|
+
);
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await api(url, apiKey, '/flywheel/agent-harden', {
|
|
131
|
+
...(runId ? { runId } : {}),
|
|
132
|
+
targetKind,
|
|
133
|
+
apply,
|
|
134
|
+
actor: `${os.hostname()}/${os.userInfo().username}`,
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (!flags.json) console.log(red('failed'));
|
|
138
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (!flags.json) console.log(green('done'));
|
|
142
|
+
|
|
143
|
+
if (flags.json) {
|
|
144
|
+
console.log(JSON.stringify(res, null, 2));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const sc = res.status === 'APPLIED' ? green : res.status === 'VERIFIED' ? cyan : res.status === 'REJECTED' ? yellow : red;
|
|
149
|
+
console.log(`\n ${sc('●')} ${bold(res.status)} ${dim('· ' + (res.origin === 'ai' ? 'AI-generated' : 'mined') + ((res.techniques || []).length ? ' · ' + res.techniques.join(', ') : ''))}`);
|
|
150
|
+
if (res.gapTotal) {
|
|
151
|
+
console.log(` ${res.gapClosed === res.gapTotal ? green('✓') : yellow('◑')} Closes ${bold(res.gapClosed + '/' + res.gapTotal)} breaching attempts`);
|
|
152
|
+
}
|
|
153
|
+
console.log(
|
|
154
|
+
` ${green('✓')} ${bold(String(res.signatures))} signature(s) passed the FP-gate ` +
|
|
155
|
+
dim(`· ${res.falsePositives} false positives across ${res.benignTested} benign samples`),
|
|
156
|
+
);
|
|
157
|
+
if (res.applied) {
|
|
158
|
+
const lift = res.resilienceBefore != null && res.resilienceAfter != null ? `${res.resilienceBefore} → ${res.resilienceAfter}/100` : 'live';
|
|
159
|
+
console.log(` ${green('✓')} Applied - signatures are ${bold('live')} with no redeploy. Resilience ${bold(lift)}`);
|
|
160
|
+
} else if (res.status === 'VERIFIED') {
|
|
161
|
+
console.log(` ${cyan('→')} Ready. Re-run with ${bold('--apply')} to push them live, or review in the dashboard → Self-Hardening.`);
|
|
162
|
+
} else if (res.status === 'REJECTED') {
|
|
163
|
+
console.log(` ${yellow('⚠')} No candidate was both effective and false-positive-free - nothing applied.`);
|
|
164
|
+
}
|
|
165
|
+
console.log(dim('\n Full detail in the Shomra dashboard → Self-Hardening.\n'));
|
|
166
|
+
}
|