@clear-capabilities/agentic-security-scanner 0.149.4 → 0.150.1

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/CHANGELOG.md CHANGED
@@ -10,6 +10,68 @@
10
10
 
11
11
 
12
12
 
13
+ ## 0.150.1 - Fix 0.150.0's release-gate failure: @babel/core dependency currency
14
+
15
+ 0.150.0's tag push failed `release.yml`'s gate on its one network-dependent, deliberately-excluded-
16
+ from-local-pre-push check: `scanner`'s `@babel/core` had drifted to 8.0.1 against a since-published
17
+ 8.0.5 on the registry — nothing to do with the Ollama work itself, just registry drift between when
18
+ local verification ran and when the tag reached the clean CI runner. `npm install @babel/core@8.0.5`
19
+ (already within the existing `^8.0.1` semver range) resolves it; `npm run test:smoke` and
20
+ `npm run test:sast` (731 tests) both pass unchanged against the bump. No functional change beyond
21
+ the dependency bump — see 0.150.0's entry for the actual release content.
22
+
23
+ ## 0.150.0 - Offline Ollama support: local models as a first-class provider, not a workaround
24
+
25
+ Every LLM-backed role in this project (`validate`, `verify`, `explain`, `fix`, `poc`, `logic`, `hunt`)
26
+ can now run entirely against a local Ollama install, with the same offline guarantee the existing
27
+ `local` preset already made — nothing leaves the machine unless an operator explicitly opts into a
28
+ remote host — and with zero change to `local`/BYO/`anthropic`/`openai`/`gemini` behavior.
29
+
30
+ 1. **New `ollama` provider** (`src/llm-validator/ollama-provider.js`), a distinct preset from `local`:
31
+ speaks Ollama's native `/api/chat` (messages array, JSON-schema-constrained structured output,
32
+ native tool calling, `think`, `keep_alive`), not the generic `{prompt, model}` shape `local` keeps
33
+ forever for its own existing consumers. Loopback enforced by default, with a named escape hatch
34
+ (`AGENTIC_SECURITY_OLLAMA_ALLOW_REMOTE=1`) for an explicitly-configured remote Ollama server —
35
+ never an accidental one. `AGENTIC_SECURITY_LLM_PRESET=ollama` plus per-role `_MODEL_<ROLE>`
36
+ overrides work exactly like every existing provider.
37
+ 2. **Every role now has a real call site.** `fix`/`explain`/`poc` had reserved env vars and nothing
38
+ that ever invoked them; each is real now: `agentic-security fix` falls back to an Ollama-proposed
39
+ patch when no deterministic one exists (verified through the exact same `applyVerifiedFix`
40
+ rescan/lint/test gate a deterministic patch already goes through — no new bypass path),
41
+ `agentic-security triage --explain <id>` and `--poc <id>` add read-only, narrative-only AI
42
+ assistance to triage, visually separated from deterministic evidence and forbidden from
43
+ fabricating exploit confirmation, cost, or compliance claims.
44
+ 3. **Model capability detection**, three layers, cheapest first: Ollama's own `/api/show` metadata
45
+ (authoritative where present), a non-authoritative family hint (Qwen 3.5, Qwen 3 Coder, Gemma 4,
46
+ FunctionGemma, and more — read from what's installed, never a hardcoded allowlist), and an opt-in
47
+ runtime probe (a real structured-output request, a real one-tool-call request) cached to disk by
48
+ Ollama version + model digest + model name so it never repeats needlessly. `agentic-security
49
+ models test <model>` runs it on demand; `models doctor`/`models inspect --probe` surface it.
50
+ 4. **RAM-aware model admission** — 8GB and 16GB (Qwen/Gemma) memory profiles, each trying its target
51
+ context, then a reduced context, then a smaller model, before ever falling back to
52
+ deterministic-only. Never falls back to a cloud provider under any circumstance.
53
+ 5. **A bounded local agent loop** (`agentic-security ask "<question>" [target]`): free-form Q&A with
54
+ read-only tool access (`read_file`, `list_files`, `search_code`, `read_finding`) confined to the
55
+ scan root, symlink-safe, secret-redacted, every tool result framed as untrusted data before it
56
+ re-enters the model's context. No write or execute tool exists in this cut. Hard-terminates after
57
+ 12 tool-call iterations or a 5-minute wall clock, whichever comes first; a model requesting an
58
+ unregistered tool ends the loop immediately as a policy violation, not a retry.
59
+ 6. **`agentic-security setup --llm ollama [--model <name>]`** discovers the local server, lists
60
+ installed models, and picks a sane default for the detected RAM tier (or uses the one you name) —
61
+ noninteractive throughout, matching every other command in this CLI, rather than introducing this
62
+ codebase's first interactive prompt.
63
+ 7. **Per-scan AI Assistance reporting.** Fixed a real, pre-existing gap along the way: the validator's
64
+ own provider matrix and per-finding model-status summary were computed on every scan and then
65
+ silently discarded before reaching any report. `agentic-security scan` now prints provider, model,
66
+ egress mode, and validate-call success/refusal/failure counts when a model tier actually ran —
67
+ worded as "LLM inference was loopback-only," never as "this scan was fully offline," which is a
68
+ separate claim about the deterministic OSV/KEV/EPSS network path this line does not describe.
69
+
70
+ Full docs at `docs/guides/ollama.md`. See `agentic-security-ollama-offline-prd.md`'s own Definition of
71
+ Done section for the complete requirements matrix this release was built against; local benchmark
72
+ suite / model-quality-history-by-digest (the PRD's own explicitly lowest-priority stretch scope) are
73
+ the one tracked remainder.
74
+
13
75
  ## 0.149.4 - Fix 0.149.3's vendor/ gap: populate it in `prepare`, not only `build`
14
76
 
15
77
  0.149.3's `vendor/java-parser/` was only ever populated by `npm run build` (`scripts/vendor-java-parser.mjs`
@@ -127,6 +127,7 @@ Commands:
127
127
  mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
128
128
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
129
129
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
130
+ ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
130
131
  reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
131
132
  --expired only remove artifacts past their retention-class TTL
132
133
  Every run writes a deletion-report.json proving what was planned/deleted/preserved/failed.
@@ -1452,6 +1453,77 @@ async function cmdProfile(args) {
1452
1453
  // /triage list | assign | transition | trend
1453
1454
  async function cmdTriage(args) {
1454
1455
  const target = path.resolve(args._[args._.length - 1] && !args._[args._.length - 1].startsWith('--') ? args._[args._.length - 1] : '.');
1456
+ // ollama-offline-prd.md §34 — `triage --explain <finding-id>` is read-only
1457
+ // narrative generation, not triage's own assign/transition/lock state
1458
+ // machine, so it doesn't need the pro-tier gate below (and is checked
1459
+ // before it for exactly that reason).
1460
+ if (args.flags.explain) {
1461
+ const id = String(args.flags.explain);
1462
+ const lastScanPath = statePath(target, 'last-scan.json');
1463
+ if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
1464
+ const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
1465
+ const f = (last.findings || []).find(x => x.id === id) || (last.secrets || []).find(x => x.id === id);
1466
+ if (!f) { console.error(`Finding ${id} not found in last scan.`); return 4; }
1467
+ console.log('Deterministic evidence');
1468
+ console.log('-----------------------');
1469
+ console.log(` ${f.vuln || f.title || 'finding'} [${f.severity || 'unknown'}] ${f.cwe || ''}`.trim());
1470
+ console.log(` ${f.file}:${f.line || '?'}`);
1471
+ if (f.description) console.log(` ${f.description}`);
1472
+ const { proposeOllamaExplanation, EXPLAIN_ERROR } = await import('../src/llm-validator/explain-proposal.js');
1473
+ const proposal = await proposeOllamaExplanation({
1474
+ finding: { file: f.file, line: f.line, vuln: f.vuln, cwe: f.cwe, severity: f.severity, confidence: f.confidence ?? f.llm_confidence },
1475
+ contextSnippet: f.snippet || '',
1476
+ scanRoot: target,
1477
+ });
1478
+ console.log('');
1479
+ if (proposal.ok) {
1480
+ console.log(`Model-generated explanation (${proposal.model}) — not deterministic evidence`);
1481
+ console.log('-----------------------------------------------------------------------');
1482
+ console.log(` ${proposal.modelExplanation}`);
1483
+ if (proposal.confidenceNote) console.log(` (${proposal.confidenceNote})`);
1484
+ } else if (proposal.code === EXPLAIN_ERROR.NOT_CONFIGURED) {
1485
+ console.log('(No AI-assisted explanation — configure AGENTIC_SECURITY_LLM_PRESET=ollama for one.)');
1486
+ } else {
1487
+ console.log(`(AI-assisted explanation unavailable: ${proposal.code} — ${proposal.reason})`);
1488
+ }
1489
+ return 0;
1490
+ }
1491
+ // ollama-offline-prd.md §18.1 — `triage --poc <finding-id>` is a headless,
1492
+ // Ollama-backed PoC SKETCH (narrative only — never executed, never written
1493
+ // to disk) for when Claude Code's own security-poc-generator agent isn't
1494
+ // available. Same read-only exemption from the pro-tier gate as --explain.
1495
+ if (args.flags.poc) {
1496
+ const id = String(args.flags.poc);
1497
+ const lastScanPath = statePath(target, 'last-scan.json');
1498
+ if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
1499
+ const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
1500
+ const f = (last.findings || []).find(x => x.id === id) || (last.secrets || []).find(x => x.id === id);
1501
+ if (!f) { console.error(`Finding ${id} not found in last scan.`); return 4; }
1502
+ console.log('Deterministic evidence');
1503
+ console.log('-----------------------');
1504
+ console.log(` ${f.vuln || f.title || 'finding'} [${f.severity || 'unknown'}] ${f.cwe || ''}`.trim());
1505
+ console.log(` ${f.file}:${f.line || '?'}`);
1506
+ if (f.description) console.log(` ${f.description}`);
1507
+ const { proposeOllamaPoc, POC_PROPOSAL_ERROR } = await import('../src/llm-validator/poc-proposal.js');
1508
+ const proposal = await proposeOllamaPoc({
1509
+ finding: { file: f.file, line: f.line, vuln: f.vuln, cwe: f.cwe, severity: f.severity },
1510
+ contextSnippet: f.snippet || '',
1511
+ scanRoot: target,
1512
+ });
1513
+ console.log('');
1514
+ if (proposal.ok) {
1515
+ console.log(`Model-generated PoC sketch (${proposal.model}) — unverified, not executed`);
1516
+ console.log('-----------------------------------------------------------------------');
1517
+ console.log(` ${proposal.pocNarrative}`);
1518
+ if (proposal.exampleInput) console.log(` Example input: ${proposal.exampleInput}`);
1519
+ if (proposal.expectedResult) console.log(` Expected result: ${proposal.expectedResult}`);
1520
+ } else if (proposal.code === POC_PROPOSAL_ERROR.NOT_CONFIGURED) {
1521
+ console.log('(No AI-assisted PoC sketch — configure AGENTIC_SECURITY_LLM_PRESET=ollama for one.)');
1522
+ } else {
1523
+ console.log(`(AI-assisted PoC sketch unavailable: ${proposal.code} — ${proposal.reason})`);
1524
+ }
1525
+ return 0;
1526
+ }
1455
1527
  const profile = loadProfile(target);
1456
1528
  if (profile.profile !== 'pro') {
1457
1529
  console.error('Triage is a pro-mode feature. Run `agentic-security profile set pro` to enable.');
@@ -1848,6 +1920,260 @@ async function cmdValidatorCache(args) {
1848
1920
  return 4;
1849
1921
  }
1850
1922
 
1923
+ // `agentic-security models list|status|doctor|inspect <model>`
1924
+ //
1925
+ // agentic-security-ollama-offline-prd.md §11.2/§31 — the local-AI UX. Every
1926
+ // subcommand is read-only and never itself triggers a model pull or a chat
1927
+ // call (models test/benchmark, which DO run inference, are P1/P2 scope not
1928
+ // implemented here — see the PRD coverage note in docs/guides/ollama.md).
1929
+ async function cmdModels(args) {
1930
+ const sub = args._[1] || 'status';
1931
+ const {
1932
+ ollamaEndpointConfig, listOllamaModels, DEFAULT_OLLAMA_HOST,
1933
+ } = await import('../src/llm-validator/ollama-provider.js');
1934
+ const {
1935
+ classifyModelFamily, capabilitiesFromFamilyHint, detectSystemMemory,
1936
+ detectMemoryTier, recommendAdmission, MEMORY_PROFILES,
1937
+ } = await import('../src/llm-validator/model-capabilities.js');
1938
+ const { getModelCapabilities } = await import('../src/llm-validator/model-probe.js');
1939
+ const { resolveProvider } = await import('../src/llm-validator/providers.js');
1940
+ const wantsProbe = !!args.flags.probe;
1941
+
1942
+ const envOverride = {};
1943
+ if (args.flags.host) envOverride.AGENTIC_SECURITY_OLLAMA_HOST = String(args.flags.host);
1944
+ if (args.flags['allow-remote-ollama']) envOverride.AGENTIC_SECURITY_OLLAMA_ALLOW_REMOTE = '1';
1945
+ const env = { ...process.env, ...envOverride };
1946
+
1947
+ if (sub === 'list' || sub === 'status' || sub === 'doctor') {
1948
+ const cfg = ollamaEndpointConfig(env);
1949
+ if (!cfg.ok) {
1950
+ console.log(`✗ ${cfg.reason}`);
1951
+ console.log('\nThe deterministic scanner will still run.\nNo cloud provider will be used automatically.');
1952
+ return args.flags.json ? (writeStdout(JSON.stringify({ ok: false, code: cfg.code, reason: cfg.reason }, null, 2) + '\n'), 0) : 1;
1953
+ }
1954
+ const modelsResult = await listOllamaModels({ host: cfg.config.host });
1955
+ if (!modelsResult.ok) {
1956
+ console.log(`✗ Ollama server is not reachable at ${cfg.config.host}`);
1957
+ console.log(` (${modelsResult.code}: ${modelsResult.reason})`);
1958
+ console.log('\nThe deterministic scanner will still run.\nNo cloud provider will be used automatically.');
1959
+ if (args.flags.json) writeStdout(JSON.stringify({ ok: false, code: modelsResult.code, reason: modelsResult.reason }, null, 2) + '\n');
1960
+ return 1;
1961
+ }
1962
+
1963
+ if (sub === 'list') {
1964
+ if (args.flags.json) {
1965
+ writeStdout(JSON.stringify({ ok: true, host: cfg.config.host, models: modelsResult.models }, null, 2) + '\n');
1966
+ return 0;
1967
+ }
1968
+ console.log(`Installed models (${cfg.config.host}):`);
1969
+ if (modelsResult.models.length === 0) console.log(' (none installed)');
1970
+ for (const m of modelsResult.models) {
1971
+ const family = classifyModelFamily(m.name);
1972
+ const caps = capabilitiesFromFamilyHint(m.name);
1973
+ const sizeGb = Number.isFinite(m.sizeBytes) ? (m.sizeBytes / (1024 ** 3)).toFixed(1) + 'GB' : '?';
1974
+ console.log(` ${m.name.padEnd(24)} family=${family.padEnd(14)} size=${sizeGb.padEnd(8)} tools=${caps.tools === true ? 'yes' : caps.tools === false ? 'no' : 'not-detected'}`);
1975
+ }
1976
+ return 0;
1977
+ }
1978
+
1979
+ // status / doctor
1980
+ const mem = detectSystemMemory();
1981
+ const tier = detectMemoryTier(mem.totalBytes);
1982
+ const resolved = resolveProvider({ role: 'validate', env });
1983
+ const model = resolved.ok ? resolved.config.model : null;
1984
+ const totalGb = (mem.totalBytes / (1024 ** 3)).toFixed(1);
1985
+ const freeGb = (mem.freeBytes / (1024 ** 3)).toFixed(1);
1986
+
1987
+ const lines = [];
1988
+ lines.push(sub === 'doctor' ? 'agentic-security local AI doctor' : 'agentic-security models status');
1989
+ lines.push('');
1990
+ lines.push('✓ Ollama server reachable');
1991
+ lines.push(cfg.config.offline ? '✓ Endpoint is loopback-only' : `↗ Endpoint is REMOTE (${cfg.config.host}) — offline guarantee does not apply`);
1992
+ lines.push(`✓ ${modelsResult.models.length} model(s) installed`);
1993
+ lines.push('');
1994
+ lines.push(`System RAM: ${totalGb} GB (free: ${freeGb} GB)`);
1995
+ lines.push(`Memory tier: ${tier}`);
1996
+ if (model) {
1997
+ const installed = modelsResult.models.some((m) => m.name === model);
1998
+ lines.push(`Default model: ${model} ${installed ? '' : '(NOT currently installed)'}`);
1999
+ const profileKey = tier === '8gb' ? '8gb' : (classifyModelFamily(model) === 'gemma4' ? '16gb-gemma' : '16gb-qwen');
2000
+ const admission = recommendAdmission({ profile: profileKey, freeBytes: mem.freeBytes, requestedModel: model });
2001
+ if (admission.admitted) {
2002
+ lines.push(` ✓ memory admission passed — context ${admission.contextTokens} tokens` +
2003
+ (admission.fellBackToSmallerModel ? ` (fell back to ${admission.model})` : admission.reducedContext ? ' (context reduced)' : ''));
2004
+ } else {
2005
+ lines.push(` ✗ memory admission FAILED — ${admission.reason}`);
2006
+ }
2007
+ const capsResult = await getModelCapabilities({ host: cfg.config.host, model, probe: wantsProbe });
2008
+ const caps = capsResult.capabilities;
2009
+ const capSourceLabel = caps.source?.runtimeProbe ? (capsResult.cached ? 'runtime-probed, cached' : 'runtime-probed')
2010
+ : caps.source?.metadata ? 'Ollama metadata' : 'family hint — not runtime-probed';
2011
+ lines.push(` chat=${caps.chat ? 'yes' : 'no'} structuredJson=${caps.structuredJson} tools=${caps.tools} (${capSourceLabel})`);
2012
+ if (wantsProbe) lines.push(capsResult.cached ? ' ✓ capability probe cached' : ' ✓ capability probe ran (now cached)');
2013
+ else lines.push(' ↗ run with --probe to runtime-verify structured output / tool calling (consumes inference time)');
2014
+ } else {
2015
+ lines.push('Default model: (none resolved)');
2016
+ }
2017
+ lines.push('');
2018
+ lines.push('Cloud fallback: disabled');
2019
+ lines.push('Deterministic scanner: enabled');
2020
+
2021
+ if (args.flags.json) {
2022
+ writeStdout(JSON.stringify({
2023
+ ok: true, host: cfg.config.host, offline: cfg.config.offline, egress: cfg.config.egress,
2024
+ installedModels: modelsResult.models.map((m) => m.name), systemRamGb: Number(totalGb), freeRamGb: Number(freeGb),
2025
+ memoryTier: tier, defaultModel: model, cloudFallback: false,
2026
+ }, null, 2) + '\n');
2027
+ } else {
2028
+ console.log(lines.join('\n'));
2029
+ }
2030
+ return 0;
2031
+ }
2032
+
2033
+ if (sub === 'inspect') {
2034
+ const name = args._[2];
2035
+ if (!name) { console.error('Usage: agentic-security models inspect <model>'); return 4; }
2036
+ const cfg = ollamaEndpointConfig(env);
2037
+ if (!cfg.ok) { console.log(`✗ ${cfg.reason}`); return 1; }
2038
+ const modelsResult = await listOllamaModels({ host: cfg.config.host });
2039
+ if (!modelsResult.ok) { console.log(`✗ Ollama server is not reachable at ${cfg.config.host} (${modelsResult.code})`); return 1; }
2040
+ const info = modelsResult.models.find((m) => m.name === name);
2041
+ const family = classifyModelFamily(name);
2042
+ const capsResult = await getModelCapabilities({ host: cfg.config.host, model: name, probe: wantsProbe });
2043
+ const caps = capsResult.capabilities;
2044
+ const out = { name, installed: !!info, family, capabilities: caps, cached: capsResult.cached, metadata: info || null };
2045
+ if (args.flags.json) { writeStdout(JSON.stringify(out, null, 2) + '\n'); return 0; }
2046
+ const capSourceLabel = caps.source?.runtimeProbe ? (capsResult.cached ? 'runtime-probed, cached' : 'runtime-probed')
2047
+ : caps.source?.metadata ? 'Ollama metadata' : 'hint only';
2048
+ console.log(`${name}`);
2049
+ console.log(` installed: ${out.installed ? 'yes' : 'no'}`);
2050
+ console.log(` family (hint): ${family}`);
2051
+ console.log(` capabilities (${capSourceLabel}): chat=${caps.chat} structuredJson=${caps.structuredJson} tools=${caps.tools} thinking=${caps.thinking}`);
2052
+ if (!wantsProbe) console.log(' (run with --probe to runtime-verify structured output / tool calling)');
2053
+ if (info) {
2054
+ console.log(` size: ${info.sizeBytes ? (info.sizeBytes / (1024 ** 3)).toFixed(1) + 'GB' : '?'}`);
2055
+ console.log(` parameters: ${info.parameterSize || '?'} quantization: ${info.quantization || '?'}`);
2056
+ }
2057
+ return 0;
2058
+ }
2059
+
2060
+ if (sub === 'test') {
2061
+ // ollama-offline-prd.md §11.2 — `models test <model>` always runs the
2062
+ // real Layer C runtime probes (structured output + tool calling), unlike
2063
+ // doctor/inspect where --probe is opt-in: the whole point of `test` is
2064
+ // to spend the inference time and get a definitive answer, cached
2065
+ // afterward the same as any other probe.
2066
+ const name = args._[2];
2067
+ if (!name) { console.error('Usage: agentic-security models test <model>'); return 4; }
2068
+ const cfg = ollamaEndpointConfig(env);
2069
+ if (!cfg.ok) { console.log(`✗ ${cfg.reason}`); return 1; }
2070
+ const modelsResult = await listOllamaModels({ host: cfg.config.host });
2071
+ if (!modelsResult.ok) { console.log(`✗ Ollama server is not reachable at ${cfg.config.host} (${modelsResult.code})`); return 1; }
2072
+ if (!modelsResult.models.some((m) => m.name === name)) {
2073
+ console.log(`✗ Model '${name}' is not installed. Run \`ollama pull ${name}\` first.`);
2074
+ return 1;
2075
+ }
2076
+ const capsResult = await getModelCapabilities({ host: cfg.config.host, model: name, probe: true });
2077
+ const caps = capsResult.capabilities;
2078
+ if (args.flags.json) {
2079
+ writeStdout(JSON.stringify({ ok: true, name, capabilities: caps, cached: capsResult.cached }, null, 2) + '\n');
2080
+ return 0;
2081
+ }
2082
+ console.log(`agentic-security models test ${name}`);
2083
+ console.log('');
2084
+ console.log(` chat: ${caps.chat ? '✓ yes' : '✗ no'}`);
2085
+ console.log(` structured JSON: ${caps.structuredJson === true ? '✓ yes' : caps.structuredJson === false ? '✗ no' : '? unknown (probe inconclusive)'}`);
2086
+ console.log(` tool calling: ${caps.tools === true ? '✓ yes' : caps.tools === false ? '✗ no' : '? unknown (probe inconclusive)'}`);
2087
+ if (caps.contextTokens) console.log(` context window: ${caps.contextTokens} tokens`);
2088
+ console.log(` ${capsResult.cached ? '✓ capability probe cached (already ran before)' : '✓ capability probe ran (now cached)'}`);
2089
+ return 0;
2090
+ }
2091
+
2092
+ if (sub === 'pull') {
2093
+ // ollama-offline-prd.md §11.2/§24: "models pull must be REFUSED when
2094
+ // strict offline mode is active because pulling a model requires network
2095
+ // access." This CLI treats loopback-enforced Ollama as the default,
2096
+ // always-on safety posture (see ollama-provider.js's header) rather than
2097
+ // a separately-toggled "strict mode", so `models pull` refuses
2098
+ // unconditionally and points at the real `ollama pull` instead of
2099
+ // silently shelling out to it — downloading model weights is
2100
+ // deliberately not something this CLI does on a user's behalf.
2101
+ const name = args._[2] || '<model>';
2102
+ console.log(
2103
+ `Model '${name}' is not installed, or you asked to pull it.\n\n` +
2104
+ 'agentic-security never downloads model weights on your behalf — that keeps ' +
2105
+ '"offline mode" honest (no surprise egress the moment you run a scan).\n\n' +
2106
+ 'Install it yourself, before disconnecting:\n' +
2107
+ ` ollama pull ${name === '<model>' ? '<model>' : name}`,
2108
+ );
2109
+ return 1;
2110
+ }
2111
+
2112
+ console.error('Usage: agentic-security models <list|status|doctor|inspect <model>|test <model>|pull <model>> [--host <url>] [--json] [--probe]');
2113
+ console.error(`Default Ollama host: ${DEFAULT_OLLAMA_HOST}`);
2114
+ return 4;
2115
+ }
2116
+
2117
+ // `agentic-security ask "<question>" [target] [--max-iterations N]`
2118
+ //
2119
+ // ollama-offline-prd.md §18.2 — the one CLI surface for the bounded local
2120
+ // tool-calling agent loop (src/llm-validator/agent-loop.js). Deliberately a
2121
+ // separate command from `hunt` (structured candidate discovery, its own
2122
+ // propose/confirm/refute/judge pipeline) and `triage --explain/--poc`
2123
+ // (single-finding narrative, no tool access at all) — this is free-form Q&A
2124
+ // over the scanned project with READ-ONLY tool access, for questions that
2125
+ // don't map to either of those shapes ("which files touch this env var",
2126
+ // "does this project have a rate limiter").
2127
+ async function cmdAsk(args) {
2128
+ const goal = args._[1];
2129
+ if (!goal) { console.error('Usage: agentic-security ask "<question>" [target] [--max-iterations N]'); return 4; }
2130
+ const target = path.resolve(args._[2] && !args._[2].startsWith('--') ? args._[2] : '.');
2131
+ const { runAgentLoop, AGENT_LOOP_ERROR, DEFAULT_MAX_TOOL_ITERATIONS } = await import('../src/llm-validator/agent-loop.js');
2132
+
2133
+ const envOverride = {};
2134
+ if (args.flags.host) envOverride.AGENTIC_SECURITY_OLLAMA_HOST = String(args.flags.host);
2135
+ if (args.flags['allow-remote-ollama']) envOverride.AGENTIC_SECURITY_OLLAMA_ALLOW_REMOTE = '1';
2136
+ const env = { ...process.env, ...envOverride };
2137
+ const maxToolIterations = args.flags['max-iterations'] ? parseInt(args.flags['max-iterations'], 10) : DEFAULT_MAX_TOOL_ITERATIONS;
2138
+
2139
+ const r = await runAgentLoop({ goal, scanRoot: target, env, maxToolIterations });
2140
+
2141
+ if (!r.ok) {
2142
+ if (r.code === AGENT_LOOP_ERROR.NOT_CONFIGURED) {
2143
+ console.log('No local model configured for tool use — set AGENTIC_SECURITY_LLM_PRESET=ollama (see `agentic-security models doctor`).');
2144
+ } else if (r.code === AGENT_LOOP_ERROR.TOOLS_UNSUPPORTED) {
2145
+ console.log(`✗ ${r.reason}`);
2146
+ } else {
2147
+ console.log(`✗ ${r.code}: ${r.reason}`);
2148
+ }
2149
+ return 1;
2150
+ }
2151
+
2152
+ if (args.flags.json) {
2153
+ writeStdout(JSON.stringify(r, null, 2) + '\n');
2154
+ return r.stopReason === 'complete' ? 0 : 1;
2155
+ }
2156
+
2157
+ for (const call of r.toolCalls) {
2158
+ console.log(` → ${call.name}(${JSON.stringify(call.args)}) ${call.ok ? 'ok' : `failed: ${call.code}`}`);
2159
+ }
2160
+ console.log('');
2161
+ if (r.stopReason === 'complete') {
2162
+ console.log(r.finalText || '(no answer)');
2163
+ return 0;
2164
+ }
2165
+ if (r.stopReason === 'policy-violation') {
2166
+ console.log('✗ Stopped: the model requested a tool it was never offered.');
2167
+ return 1;
2168
+ }
2169
+ if (r.stopReason === 'max-iterations') {
2170
+ console.log(`✗ Stopped after the ${DEFAULT_MAX_TOOL_ITERATIONS}-iteration bound without a final answer. Try narrowing the question.`);
2171
+ return 1;
2172
+ }
2173
+ console.log('✗ Stopped: wall-clock timeout reached without a final answer.');
2174
+ return 1;
2175
+ }
2176
+
1851
2177
  // `agentic-security verify [--finding <id>] [--target <url>] [--live]`
1852
2178
  //
1853
2179
  // Re-runs the verifier loop over the most-recent scan. Without --live, it
@@ -3080,11 +3406,44 @@ async function cmdFix(args) {
3080
3406
  }
3081
3407
  }
3082
3408
 
3409
+ let ollamaFixMeta = null;
3410
+ if (newContent === null) {
3411
+ // ollama-offline-prd.md §33 — when no stored/deterministic patch exists,
3412
+ // AND an Ollama provider is configured for the fix role, ask it for a
3413
+ // proposal. Whatever comes back goes through the EXACT SAME
3414
+ // applyVerifiedFix() rescan/lint/test gate below as a deterministic
3415
+ // patch — this branch only ever changes where `newContent` came from,
3416
+ // never how it's verified.
3417
+ const { proposeOllamaFix, FIX_PROPOSAL_ERROR } = await import('../src/llm-validator/fix-proposal.js');
3418
+ const proposal = await proposeOllamaFix({
3419
+ finding: { file: f.file, line: f.line, vuln: f.vuln, cwe: f.cwe, severity: f.severity },
3420
+ fileContent: originalContent,
3421
+ scanRoot,
3422
+ });
3423
+ if (proposal.ok) {
3424
+ newContent = proposal.replacement;
3425
+ ollamaFixMeta = { model: proposal.model, rationale: proposal.rationale, expectedSecurityEffect: proposal.expectedSecurityEffect };
3426
+ } else if (proposal.code !== FIX_PROPOSAL_ERROR.NOT_CONFIGURED) {
3427
+ // A configured Ollama fix attempt that failed is worth surfacing
3428
+ // distinctly from "nothing was configured" — the operator asked for
3429
+ // AI-assisted fix and it didn't work, which is different from never
3430
+ // having asked.
3431
+ console.error(`Ollama fix proposal unavailable (${proposal.code}): ${proposal.reason}`);
3432
+ }
3433
+ }
3434
+
3083
3435
  if (newContent === null) {
3084
- console.error('No mechanical fix is available for this finding. Use the security-fixer subagent (default `fix` mode) and apply with `--apply` after it produces a replacement.');
3436
+ console.error('No mechanical fix is available for this finding. Use the security-fixer subagent (default `fix` mode), or configure AGENTIC_SECURITY_LLM_PRESET=ollama for an AI-assisted proposal, then apply with `--apply`.');
3085
3437
  return 4;
3086
3438
  }
3087
3439
 
3440
+ if (ollamaFixMeta) {
3441
+ console.log(`AI-assisted proposal (model: ${ollamaFixMeta.model}) — not yet mechanically verified:`);
3442
+ if (ollamaFixMeta.rationale) console.log(` rationale: ${ollamaFixMeta.rationale}`);
3443
+ if (ollamaFixMeta.expectedSecurityEffect) console.log(` expected effect: ${ollamaFixMeta.expectedSecurityEffect}`);
3444
+ console.log('');
3445
+ }
3446
+
3088
3447
  if (isPreview) {
3089
3448
  console.log(previewDiff(originalContent, newContent, f.file));
3090
3449
  console.log('\nRun with --apply to write this change. Use `agentic-security undo` to revert.');
@@ -3180,7 +3539,104 @@ async function cmdUndo(args) {
3180
3539
  return 0;
3181
3540
  }
3182
3541
 
3542
+ // ollama-offline-prd.md §11.1's example transcript is an INTERACTIVE numbered
3543
+ // menu. This codebase has no interactive-prompt precedent anywhere else —
3544
+ // every other command here is flag-driven and scriptable, deliberately (CI
3545
+ // use is the primary case). Rather than introduce the first readline-based
3546
+ // prompt in the CLI, `setup --llm ollama` follows the PRD's own explicitly-
3547
+ // supported NONINTERACTIVE shape as the only shape: discover, pick a sane
3548
+ // memory-tier default when `--model` is omitted (recommend-and-proceed, not
3549
+ // block-and-ask), confirm the offline guarantee, print exactly what to
3550
+ // export. `--offline` is accepted for parity with the PRD's example command
3551
+ // line but is confirmatory only — Ollama's loopback enforcement is already
3552
+ // always-on by default (ollama-provider.js), not a mode this flag toggles.
3553
+ async function cmdSetupLlmOllama(args) {
3554
+ const {
3555
+ ollamaEndpointConfig, listOllamaModels, DEFAULT_OLLAMA_HOST,
3556
+ } = await import('../src/llm-validator/ollama-provider.js');
3557
+ const { detectSystemMemory, detectMemoryTier, recommendAdmission, classifyModelFamily } = await import('../src/llm-validator/model-capabilities.js');
3558
+
3559
+ const envOverride = {};
3560
+ if (args.flags.host) envOverride.AGENTIC_SECURITY_OLLAMA_HOST = String(args.flags.host);
3561
+ const env = { ...process.env, ...envOverride };
3562
+ const cfg = ollamaEndpointConfig(env);
3563
+ console.log('Local LLM provider: Ollama');
3564
+ if (!cfg.ok) {
3565
+ console.log(`✗ ${cfg.reason}`);
3566
+ return 1;
3567
+ }
3568
+ console.log(`Server: ${cfg.config.host}`);
3569
+
3570
+ const modelsResult = await listOllamaModels({ host: cfg.config.host });
3571
+ if (!modelsResult.ok) {
3572
+ console.log(`✗ Ollama server is not reachable at ${cfg.config.host} (${modelsResult.code}: ${modelsResult.reason})`);
3573
+ console.log('\nInstall Ollama and pull a model first — see docs/guides/ollama.md.');
3574
+ console.log('The deterministic scanner will still run. No cloud provider will be used automatically.');
3575
+ return 1;
3576
+ }
3577
+ console.log('Status: reachable');
3578
+ console.log('');
3579
+ if (modelsResult.models.length === 0) {
3580
+ console.log('No models installed. Pull one first, e.g.:');
3581
+ console.log(' ollama pull qwen3.5:4b');
3582
+ return 1;
3583
+ }
3584
+ console.log('Installed models:');
3585
+ for (const m of modelsResult.models) {
3586
+ const sizeGb = Number.isFinite(m.sizeBytes) ? (m.sizeBytes / (1024 ** 3)).toFixed(1) + 'GB' : '?';
3587
+ console.log(` ${m.name.padEnd(24)} ${sizeGb}`);
3588
+ }
3589
+ console.log('');
3590
+
3591
+ let chosen = args.flags.model ? String(args.flags.model) : null;
3592
+ if (chosen && !modelsResult.models.some((m) => m.name === chosen)) {
3593
+ console.log(`↗ '${chosen}' is not currently installed — pull it first with \`ollama pull ${chosen}\`. Continuing with this choice anyway.`);
3594
+ }
3595
+ if (!chosen) {
3596
+ // Recommend-and-proceed: pick the memory-tier's preferred model if it's
3597
+ // installed, else the first installed model, rather than blocking on an
3598
+ // interactive prompt this CLI has no precedent for.
3599
+ const mem = detectSystemMemory();
3600
+ const tier = detectMemoryTier(mem.totalBytes);
3601
+ const installedNames = new Set(modelsResult.models.map((m) => m.name));
3602
+ const preferredByTier = tier === '8gb' ? 'qwen3.5:4b'
3603
+ : modelsResult.models.some((m) => classifyModelFamily(m.name) === 'gemma4') ? null : 'qwen3.5:9b';
3604
+ chosen = (preferredByTier && installedNames.has(preferredByTier)) ? preferredByTier : modelsResult.models[0].name;
3605
+ console.log(`Choose default model: ${chosen} (auto-selected for your ${tier === 'unknown' ? 'detected' : tier} memory tier — pass --model to choose a different installed model)`);
3606
+ } else {
3607
+ console.log(`Choose default model: ${chosen}`);
3608
+ }
3609
+ console.log('');
3610
+
3611
+ const mem = detectSystemMemory();
3612
+ const tier = detectMemoryTier(mem.totalBytes);
3613
+ const profileKey = tier === '8gb' ? '8gb' : (classifyModelFamily(chosen) === 'gemma4' ? '16gb-gemma' : '16gb-qwen');
3614
+ const admission = recommendAdmission({ profile: profileKey, freeBytes: mem.freeBytes, requestedModel: chosen });
3615
+
3616
+ console.log('Offline LLM mode:');
3617
+ console.log(' ✓ Only loopback model requests are allowed');
3618
+ console.log(' ✓ No cloud fallback');
3619
+ console.log(' ✓ Missing local model falls back to deterministic-only scanning');
3620
+ if (!admission.admitted) {
3621
+ console.log(` ↗ Memory admission check: ${admission.reason}`);
3622
+ }
3623
+ console.log('');
3624
+ console.log('To use this configuration, export:');
3625
+ console.log(' export AGENTIC_SECURITY_LLM_PRESET=ollama');
3626
+ console.log(` export AGENTIC_SECURITY_LLM_MODEL=${chosen}`);
3627
+ if (cfg.config.host !== DEFAULT_OLLAMA_HOST) console.log(` export AGENTIC_SECURITY_OLLAMA_HOST=${cfg.config.host}`);
3628
+ console.log('');
3629
+ console.log('Then verify with: agentic-security models doctor');
3630
+ return 0;
3631
+ }
3632
+
3183
3633
  async function cmdSetup(args) {
3634
+ if (args.flags.llm) {
3635
+ const provider = String(args.flags.llm).toLowerCase();
3636
+ if (provider === 'ollama') return cmdSetupLlmOllama(args);
3637
+ console.error(`agentic-security setup --llm: unsupported provider "${provider}" (only "ollama" runs a discovery/config flow here — other providers (anthropic/openai/gemini/local) configure via their AGENTIC_SECURITY_LLM_* env vars directly, no setup step needed).`);
3638
+ return 4;
3639
+ }
3184
3640
  const projectDir = path.resolve(args._[1] || '.');
3185
3641
  const commandsDir = path.join(projectDir, '.claude', 'commands');
3186
3642
  await fsp.mkdir(commandsDir, { recursive: true });
@@ -6353,6 +6809,16 @@ async function main() {
6353
6809
  checkNodeVersionOrExit();
6354
6810
  const args = parseArgs(process.argv.slice(2));
6355
6811
  const cmd = args._[0];
6812
+ // ollama-offline-prd.md §11.3/§10.2 — CLI flags win over every other
6813
+ // configuration layer. A generic bridge here (rather than per-command
6814
+ // flag parsing in cmdHunt/cmdSecure/etc.) means `--llm`/`--model` work
6815
+ // the same way on any command without duplicating the same six lines —
6816
+ // commands that don't consult an LLM provider simply never read the env
6817
+ // vars this sets, so this is a no-op for them.
6818
+ if (args.flags.llm) process.env.AGENTIC_SECURITY_LLM_PRESET = String(args.flags.llm);
6819
+ if (args.flags.model) process.env.AGENTIC_SECURITY_LLM_MODEL = String(args.flags.model);
6820
+ if (args.flags['allow-remote-ollama']) process.env.AGENTIC_SECURITY_OLLAMA_ALLOW_REMOTE = '1';
6821
+ if (args.flags['ollama-host']) process.env.AGENTIC_SECURITY_OLLAMA_HOST = String(args.flags['ollama-host']);
6356
6822
  try {
6357
6823
  switch (cmd) {
6358
6824
  case 'scan': process.exit(await cmdScan(args));
@@ -6370,6 +6836,8 @@ async function main() {
6370
6836
  case 'secure': process.exit(await cmdSecure(args));
6371
6837
  case 'packs': process.exit(await cmdPacks(args));
6372
6838
  case 'validator-cache': process.exit(await cmdValidatorCache(args));
6839
+ case 'models': process.exit(await cmdModels(args));
6840
+ case 'ask': process.exit(await cmdAsk(args));
6373
6841
  case 'verify': process.exit(await cmdVerify(args));
6374
6842
  case 'reset': process.exit(await cmdReset(args));
6375
6843
  case 'export': process.exit(await cmdExport(args));