@bergabruh/code-scanner 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,55 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
10
+
11
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
12
+
13
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
14
+
15
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
16
+
17
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
18
+
19
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
20
+
21
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
22
+
23
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
24
+
25
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
26
+
27
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
28
+
29
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
30
+
31
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
32
+
33
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
34
+
35
+ (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
36
+
37
+ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
38
+
39
+ (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
40
+
41
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
42
+
43
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
+
45
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
+
47
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
+
49
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
+
51
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
+
53
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
+
55
+ END OF TERMS AND CONDITIONS
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Mnogovid Code Scanner
2
+
3
+ Mnogovid Code Scanner runs approved local security tools, turns their output
4
+ into a readable Markdown report, and can add an evidence-bound AI explanation
5
+ or an independent agent review. It does not install scanners, change project
6
+ files, or apply fixes.
7
+
8
+ ## Install and run
9
+
10
+ All hosts need Python 3 and the scanner executables they intend to run on
11
+ `PATH`. The normal entrypoint bootstraps the profile and toolchain itself; the
12
+ manual initializer below is only for inspection outside an agent session.
13
+
14
+ Choose the section for your agent host. The scan modes and consent policy are
15
+ the same everywhere; only installation and invocation differ.
16
+
17
+ ### Codex
18
+
19
+ Install from the marketplace that contains the plugin:
20
+
21
+ ```bash
22
+ codex plugin add mnogovid-code-scanner@<marketplace-name>
23
+ ```
24
+
25
+ Open a new Codex task after installation, then invoke the plugin or its one
26
+ unified command:
27
+
28
+ ```text
29
+ @mnogovid-code-scanner
30
+ /mnogovid-code-scanner:security-scan
31
+ ```
32
+
33
+ The onboarding workflow checks the profile and scanner toolchain first. On the
34
+ first run it asks before creating `.mnogovid-code-scanner.json`; later runs
35
+ validate that profile and show missing adapters before asking how to analyze the
36
+ evidence: adapters only, adapters plus AI triage, or adapters plus AI triage
37
+ and independent review. Each scanner still needs a preview and approval.
38
+
39
+ ### Claude Code
40
+
41
+ Install the plugin from your configured Claude Code marketplace. Its
42
+ `.claude-plugin/plugin.json` and bundled MCP configuration provide the plugin
43
+ entry point. Start Claude in the target project:
44
+
45
+ ```bash
46
+ cd /path/to/project
47
+ claude
48
+ ```
49
+
50
+ Then invoke one of the plugin's slash commands:
51
+
52
+ ```text
53
+ /security-scan
54
+ ```
55
+
56
+ ### OpenCode
57
+
58
+ Add the package name to the project or global `opencode.json` and restart
59
+ OpenCode:
60
+
61
+ ```json
62
+ {
63
+ "$schema": "https://opencode.ai/config.json",
64
+ "plugin": ["@bergabruh/code-scanner"]
65
+ }
66
+ ```
67
+
68
+ OpenCode installs the package through Bun. The package starts its bundled,
69
+ dependency-free Python scanner bridge itself; no `cwd`, MCP block, or copied
70
+ `.opencode` assets are needed. Ask OpenCode to run a workspace security scan;
71
+ it receives the `mnogovid_code_scanner` tool and must still request every
72
+ recorded consent. `python3` and individual scanner executables remain system
73
+ prerequisites and are never installed by the package.
74
+
75
+ ## Choose a mode
76
+
77
+ | Mode | Use it when | Result |
78
+ | --- | --- | --- |
79
+ | Adapters only | You need reproducible scanner evidence. | Findings and coverage report. |
80
+ | Adapters + AI triage | You want bounded, redacted AI classification. | Scanner evidence plus AI notes. |
81
+ | Adapters + AI triage + independent review | You need a second assessment. | Scanner evidence, AI notes, and review. |
82
+
83
+ The AI and agent are reviewers, not vulnerability scanners. Scanner evidence
84
+ remains the source of record.
85
+
86
+ ## Scope and consent
87
+
88
+ Say “scan the whole workspace” to authorize discovery and planning across the
89
+ entire current project. The normal scanner exclusions still apply: `.git`,
90
+ dependency/vendor directories, virtual environments, caches, build artifacts,
91
+ and previous `.mnogovid` reports.
92
+
93
+ Each permission is independent:
94
+
95
+ | Permission | What it allows |
96
+ | --- | --- |
97
+ | Profile creation | Create a missing `.mnogovid-code-scanner.json`; an invalid profile stops the workflow. |
98
+ | Network consent | Run a network-dependent scanner after it is separately approved. It is a policy gate, not network isolation. |
99
+ | Per-scanner approval | Start one specific scanner process after its command preview and recorded lifecycle `runId`. |
100
+ | AI sharing | Send only a bounded, redacted findings payload to the host AI. |
101
+ | Agent review | Give the same bounded evidence and recorded AI triage to `security-triage`. |
102
+
103
+ No permission implies another. A whole-workspace authorization does not permit
104
+ network access, profile writing, or scanner execution by itself.
105
+
106
+ ## Report
107
+
108
+ Every scan writes a new report to:
109
+
110
+ ```text
111
+ <project>/.mnogovid/code-scanner/<timestamp>/result.md
112
+ ```
113
+
114
+ The report is reader-first:
115
+
116
+ 1. **Verdict** — whether action or human review is needed and whether scan
117
+ coverage is incomplete.
118
+ 2. **What needs attention** — one item per finding with scanner, location,
119
+ severity, AI assessment, and a detailed explanation.
120
+ 3. **Scan coverage** — completed, incomplete, and failed scanners.
121
+ 4. **Coverage gaps** — a concrete recovery step and a short redacted
122
+ diagnostic when a scanner did not produce usable output.
123
+
124
+ For AI and agent modes, every scanner finding must have a detailed AI note.
125
+ The report cannot finalize with an approved but unrecorded AI or agent stage.
126
+
127
+ ## Initialize a project profile manually
128
+
129
+ Initialization only discovers the project and checks which scanner executables
130
+ are available; it does not run a scan, install software, or use the network.
131
+
132
+ ```bash
133
+ python3 /path/to/mnogovid-code-scanner/scripts/init.py /path/to/project --json
134
+ ```
135
+
136
+ To create the optional local profile and record that network scanners may be
137
+ considered later:
138
+
139
+ ```bash
140
+ python3 /path/to/mnogovid-code-scanner/scripts/init.py /path/to/project --json --write --allow-network
141
+ ```
142
+
143
+ The command prints missing executables and package-manager installation
144
+ templates. It never installs them. The unified workflow is preferred because it
145
+ also validates the existing profile and binds every execution to a recorded
146
+ preview.
147
+
148
+ ## Develop and update the Codex plugin
149
+
150
+ After changing this local plugin, validate it and bump its normal semantic
151
+ version before publishing:
152
+
153
+ ```bash
154
+ python3 /path/to/plugin-creator/scripts/validate_plugin.py /path/to/mnogovid-code-scanner
155
+ ```
156
+
157
+ Publish the updated marketplace source, then reinstall the plugin:
158
+
159
+ ```bash
160
+ codex plugin add mnogovid-code-scanner@<marketplace-name>
161
+ ```
162
+
163
+ Start a new Codex task after reinstalling so the updated skills and MCP tools
164
+ are loaded.
165
+
166
+ ## Troubleshooting
167
+
168
+ - **Profile missing:** rerun the unified workflow and approve profile creation.
169
+ - **Profile invalid:** do not scan; inspect or deliberately replace it through
170
+ the manual initializer.
171
+ - **Adapter missing:** install it through normal system administration, then
172
+ restart the workflow; the plugin never installs tools.
173
+ - **MCP not visible:** reinstall/update the plugin and start a new Codex or
174
+ Claude Code session. In OpenCode, confirm `opencode mcp list` shows it as
175
+ connected after merging the example config.
176
+ - **No findings:** this is not proof that the workspace is secure; review
177
+ skipped and failed adapters first.
178
+
179
+ ## Safety model
180
+
181
+ Only allowlisted scanner executables run, always as an argv array without a
182
+ shell. AI receives only the redacted payload produced by the MCP server. OSV
183
+ advisory lookup requires separate network approval. The plugin never performs
184
+ automatic remediation.
185
+
186
+ Licensed under the Apache License 2.0. See [LICENSE](LICENSE).
package/opencode.js ADDED
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process"
2
+ import { fileURLToPath } from "node:url"
3
+ import { tool } from "@opencode-ai/plugin"
4
+
5
+ const METHODS = new Set([
6
+ "security_catalog",
7
+ "security_doctor",
8
+ "security_bootstrap",
9
+ "security_plan",
10
+ "security_virtual_run",
11
+ "security_run",
12
+ "security_ingest",
13
+ "security_start_run",
14
+ "security_record_run",
15
+ "security_finalize_run",
16
+ "security_advisory_lookup",
17
+ "security_ai_triage_payload",
18
+ ])
19
+
20
+ class PythonMcpBridge {
21
+ constructor(script) {
22
+ this.nextId = 1
23
+ this.pending = new Map()
24
+ this.buffer = ""
25
+ this.queue = Promise.resolve()
26
+ this.stderr = ""
27
+ this.child = spawn("python3", [script], { stdio: ["pipe", "pipe", "pipe"] })
28
+ this.child.stdout.setEncoding("utf8")
29
+ this.child.stdout.on("data", (chunk) => this.receive(chunk))
30
+ this.child.stderr.setEncoding("utf8")
31
+ this.child.stderr.on("data", (chunk) => {
32
+ this.stderr = (this.stderr + chunk).slice(-4096)
33
+ })
34
+ this.child.on("error", (error) => this.failAll(error))
35
+ this.child.on("exit", (code, signal) => {
36
+ this.failAll(new Error(`Python scanner exited (${signal ?? code ?? "unknown"})${this.stderr ? `: ${this.stderr}` : ""}`))
37
+ })
38
+ process.once("exit", () => this.child.kill())
39
+ }
40
+
41
+ receive(chunk) {
42
+ this.buffer += chunk
43
+ let newline
44
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
45
+ const line = this.buffer.slice(0, newline)
46
+ this.buffer = this.buffer.slice(newline + 1)
47
+ if (!line.trim()) continue
48
+ try {
49
+ const message = JSON.parse(line)
50
+ const pending = this.pending.get(message.id)
51
+ if (!pending) continue
52
+ this.pending.delete(message.id)
53
+ if (message.error) pending.reject(new Error(message.error.message ?? "Python scanner protocol error"))
54
+ else pending.resolve(message.result)
55
+ } catch (error) {
56
+ this.failAll(new Error(`Invalid response from Python scanner: ${error.message}`))
57
+ }
58
+ }
59
+ }
60
+
61
+ failAll(error) {
62
+ for (const { reject } of this.pending.values()) reject(error)
63
+ this.pending.clear()
64
+ }
65
+
66
+ call(method, args) {
67
+ const request = () => new Promise((resolve, reject) => {
68
+ if (this.child.exitCode !== null || this.child.killed) {
69
+ reject(new Error("Python scanner is unavailable; verify that python3 is installed."))
70
+ return
71
+ }
72
+ const id = this.nextId++
73
+ this.pending.set(id, { resolve, reject })
74
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name: method, arguments: args } })}\n`)
75
+ })
76
+ const result = this.queue.then(request, request)
77
+ this.queue = result.catch(() => undefined)
78
+ return result
79
+ }
80
+ }
81
+
82
+ function parseArguments(raw) {
83
+ try {
84
+ const value = JSON.parse(raw)
85
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object")
86
+ return value
87
+ } catch (error) {
88
+ throw new Error(`argumentsJson ${error.message}`)
89
+ }
90
+ }
91
+
92
+ export const MnogovidCodeScanner = async () => {
93
+ const script = fileURLToPath(new URL("./scripts/security_mcp.py", import.meta.url))
94
+ const bridge = new PythonMcpBridge(script)
95
+
96
+ return {
97
+ tool: {
98
+ mnogovid_code_scanner: tool({
99
+ description: "Call one consent-gated Mnogovid Code Scanner operation. Valid operations: security_catalog, security_doctor, security_bootstrap, security_plan, security_virtual_run, security_run, security_ingest, security_start_run, security_record_run, security_finalize_run, security_advisory_lookup, security_ai_triage_payload. Pass argumentsJson as a JSON object. The Python core validates workspace paths, recorded lifecycle and consent before any scanner starts.",
100
+ args: {
101
+ operation: tool.schema.string(),
102
+ argumentsJson: tool.schema.string(),
103
+ },
104
+ async execute({ operation, argumentsJson }) {
105
+ if (!METHODS.has(operation)) return `Unknown Mnogovid Code Scanner operation: ${operation}`
106
+ try {
107
+ return JSON.stringify(await bridge.call(operation, parseArguments(argumentsJson)))
108
+ } catch (error) {
109
+ return JSON.stringify({ error: error.message })
110
+ }
111
+ },
112
+ }),
113
+ },
114
+ }
115
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@bergabruh/code-scanner",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for consent-gated workspace security scanning.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./opencode.js",
8
+ "module": "./opencode.js",
9
+ "exports": "./opencode.js",
10
+ "files": [
11
+ "opencode.js",
12
+ "scripts/*.py",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "security",
20
+ "sast"
21
+ ],
22
+ "devDependencies": {
23
+ "@opencode-ai/plugin": "^1.18.11"
24
+ },
25
+ "scripts": {
26
+ "check": "node --check opencode.js"
27
+ },
28
+ "homepage": "https://github.com/BergaBruh/mnogovid",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/BergaBruh/mnogovid.git"
32
+ }
33
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """Initialize a non-destructive Mnogovid Code Scanner project profile.
3
+
4
+ The script only inspects the target directory by default. Pass ``--write`` to
5
+ create the missing ``.mnogovid-code-scanner.json`` profile; an existing profile is
6
+ never changed unless ``--force`` is supplied as well. ``--allow-network`` is
7
+ recorded as a preference for later scanner runs and never opens a connection.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import platform
14
+ import shutil
15
+ import sys
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from security_mcp import ADAPTERS, discover, recommend
20
+
21
+
22
+ PROFILE_NAME = ".mnogovid-code-scanner.json"
23
+ PACKAGE_MANAGER_HINTS = (
24
+ ("apt", "apt-get", "sudo apt-get install <package>"),
25
+ ("dnf", "dnf", "sudo dnf install <package>"),
26
+ ("yum", "yum", "sudo yum install <package>"),
27
+ ("pacman", "pacman", "sudo pacman -S <package>"),
28
+ ("zypper", "zypper", "sudo zypper install <package>"),
29
+ ("Homebrew", "brew", "brew install <package>"),
30
+ ("winget", "winget", "winget install <package>"),
31
+ ("Chocolatey", "choco", "choco install <package>"),
32
+ ("Scoop", "scoop", "scoop install <package>"),
33
+ ("pipx", "pipx", "pipx install <package>"),
34
+ ("Cargo", "cargo", "cargo install <package>"),
35
+ ("Go", "go", "go install <module>@latest"),
36
+ ("npm", "npm", "npm install --global <package>"),
37
+ )
38
+
39
+
40
+ def installation_guide(missing: list[str]) -> dict[str, object]:
41
+ managers = [
42
+ {"name": name, "executable": executable, "commandTemplate": command}
43
+ for name, executable, command in PACKAGE_MANAGER_HINTS
44
+ if shutil.which(executable) is not None
45
+ ]
46
+ return {
47
+ "message": "If you want to add more tools, follow the instructions for a package manager available on this system.",
48
+ "platform": platform.platform(),
49
+ "missingExecutables": missing,
50
+ "packageManagers": managers,
51
+ "note": "Replace <package> or <module> with the scanner's verified package or module name before installing.",
52
+ }
53
+
54
+
55
+ def build_result(project: Path) -> dict[str, object]:
56
+ found = discover(project)
57
+ recommended = recommend(found)
58
+ checks = [
59
+ {
60
+ "adapter": adapter,
61
+ "executable": ADAPTERS[adapter]["exe"],
62
+ "available": shutil.which(ADAPTERS[adapter]["exe"]) is not None,
63
+ "requiresNetwork": ADAPTERS[adapter]["network"],
64
+ }
65
+ for adapter in recommended
66
+ ]
67
+ missing = sorted({str(item["executable"]) for item in checks if not item["available"]})
68
+ return {
69
+ "project": str(project),
70
+ "detected": {
71
+ "ecosystems": found["ecosystems"],
72
+ "languages": found["languages"],
73
+ "surfaces": found["surfaces"],
74
+ },
75
+ "checks": checks,
76
+ "availableAdapters": [item["adapter"] for item in checks if item["available"]],
77
+ "missingExecutables": missing,
78
+ "installationGuide": installation_guide(missing),
79
+ }
80
+
81
+
82
+ def profile(result: dict[str, object], allow_network: bool) -> dict[str, object]:
83
+ return {
84
+ "schemaVersion": 1,
85
+ "generatedBy": "mnogovid-code-scanner init",
86
+ "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
87
+ "recommendedAdapters": [item["adapter"] for item in result["checks"]],
88
+ "availableAdapters": result["availableAdapters"],
89
+ "allowNetwork": allow_network,
90
+ "installationGuide": result["installationGuide"],
91
+ "notes": [
92
+ "Review recommendedAdapters before running scanners.",
93
+ "Network-dependent scanners still require explicit host approval.",
94
+ ],
95
+ }
96
+
97
+
98
+ def main() -> int:
99
+ parser = argparse.ArgumentParser(
100
+ description="Check project-relevant security scanners and optionally create a local profile."
101
+ )
102
+ parser.add_argument("project", nargs="?", default=".", help="project directory (default: current directory)")
103
+ parser.add_argument("--write", action="store_true", help=f"create {PROFILE_NAME} if it is missing")
104
+ parser.add_argument("--force", action="store_true", help=f"replace an existing {PROFILE_NAME}; requires --write")
105
+ parser.add_argument(
106
+ "--allow-network",
107
+ action="store_true",
108
+ help="record approval for network-dependent scanner runs; this command does not use the network",
109
+ )
110
+ parser.add_argument("--json", action="store_true", help="print the result as JSON")
111
+ args = parser.parse_args()
112
+ if args.force and not args.write:
113
+ parser.error("--force requires --write")
114
+
115
+ project = Path(args.project).expanduser().resolve()
116
+ if not project.is_dir():
117
+ parser.error(f"project is not a directory: {project}")
118
+
119
+ result = build_result(project)
120
+ destination = project / PROFILE_NAME
121
+ existed = destination.exists()
122
+ if args.write and (not existed or args.force):
123
+ destination.write_text(json.dumps(profile(result, args.allow_network), indent=2) + "\n", encoding="utf-8")
124
+ result["profile"] = {"path": str(destination), "action": "replaced" if existed else "created"}
125
+ elif args.write:
126
+ result["profile"] = {"path": str(destination), "action": "unchanged", "reason": "already exists"}
127
+ else:
128
+ result["profile"] = {"path": str(destination), "action": "not_written"}
129
+ result["allowNetwork"] = args.allow_network
130
+
131
+ if args.json:
132
+ print(json.dumps(result, ensure_ascii=False, indent=2))
133
+ else:
134
+ print(f"Project: {project}")
135
+ for item in result["checks"]:
136
+ status = "available" if item["available"] else "missing"
137
+ print(f"- {item['adapter']}: {item['executable']} ({status})")
138
+ if result["missingExecutables"]:
139
+ print("Missing programs: " + ", ".join(result["missingExecutables"]))
140
+ guide = result["installationGuide"]
141
+ print("If you want to add more tools, follow the instructions for this system:")
142
+ print(f"- Platform: {guide['platform']}")
143
+ for manager in guide["packageManagers"]:
144
+ print(f"- {manager['name']}: {manager['commandTemplate']}")
145
+ if not guide["packageManagers"]:
146
+ print("- No supported package manager was detected; use the scanner's official installation instructions.")
147
+ print(f"Profile: {result['profile']['action']} ({destination})")
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env python3
2
+ """Dependency-free MCP security scanner orchestrator.
3
+
4
+ This is deliberately independent from Mnogovid/Rust. It discovers a workspace,
5
+ plans allowlisted scanner commands, executes them without a shell only after a
6
+ host-approved tool call, normalizes common report formats, and creates a
7
+ bounded/redacted payload for the host model's AI triage.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json, os, re, shutil, subprocess, sys, time, urllib.error, urllib.request
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ MAX_OUTPUT = 256 * 1024
17
+ RUNS: dict[str, dict[str, Any]] = {}
18
+ IGNORE = {".git", "node_modules", "target", ".venv", "__pycache__", "vendor", ".mnogovid"}
19
+ PROFILE_NAME = ".mnogovid-code-scanner.json"
20
+ ADAPTERS: dict[str, dict[str, Any]] = {
21
+ "semgrep": {"category":"sast","exe":"semgrep","network":True,"cmd":lambda p:["--json","--config","auto",str(p)]},
22
+ "gosec": {"category":"sast","exe":"gosec","network":False,"cmd":lambda p:["-fmt=json","./..."],"cwd":True},
23
+ "bandit": {"category":"sast","exe":"bandit","network":False,"cmd":lambda p:["-f","json","-r",str(p)]},
24
+ "brakeman": {"category":"sast","exe":"brakeman","network":False,"cmd":lambda p:["-f","json","-p",str(p)]},
25
+ "gitleaks": {"category":"secrets","exe":"gitleaks","network":False,"cmd":lambda p:["detect","--source",str(p),"--report-format","json","--no-banner"]},
26
+ "trufflehog": {"category":"secrets","exe":"trufflehog","network":True,"cmd":lambda p:["filesystem",str(p),"--json"]},
27
+ "detect-secrets": {"category":"secrets","exe":"detect-secrets","network":False,"cmd":lambda p:["scan",str(p)]},
28
+ "trivy-fs": {"category":"sca","exe":"trivy","network":True,"cmd":lambda p:["fs","--format","json",str(p)]},
29
+ "osv-scanner": {"category":"sca","exe":"osv-scanner","network":True,"cmd":lambda p:["scan","source","--format","json",str(p)]},
30
+ "grype": {"category":"sca","exe":"grype","network":True,"cmd":lambda p:[f"dir:{p}","-o","json"]},
31
+ "syft": {"category":"sbom","exe":"syft","network":False,"cmd":lambda p:[f"dir:{p}","-o","json"]},
32
+ "cargo-audit": {"category":"sca","exe":"cargo-audit","network":True,"cmd":lambda p:["audit","--json"],"cwd":True},
33
+ "cargo-deny": {"category":"sca","exe":"cargo-deny","network":True,"cmd":lambda p:["check","advisories","--format","json"],"cwd":True},
34
+ "pip-audit": {"category":"sca","exe":"pip-audit","network":True,"cmd":lambda p:["-f","json"] ,"cwd":True},
35
+ "npm-audit": {"category":"sca","exe":"npm","network":True,"cmd":lambda p:["audit","--json"],"cwd":True},
36
+ "pnpm-audit": {"category":"sca","exe":"pnpm","network":True,"cmd":lambda p:["audit","--json"],"cwd":True},
37
+ "yarn-audit": {"category":"sca","exe":"yarn","network":True,"cmd":lambda p:["audit","--json"],"cwd":True},
38
+ "govulncheck": {"category":"sca","exe":"govulncheck","network":True,"cmd":lambda p:["./..."],"cwd":True},
39
+ "bundler-audit": {"category":"sca","exe":"bundle-audit","network":True,"cmd":lambda p:["check","--format","json"],"cwd":True},
40
+ "composer-audit": {"category":"sca","exe":"composer","network":True,"cmd":lambda p:["audit","--format=json"],"cwd":True},
41
+ "checkov": {"category":"iac","exe":"checkov","network":False,"cmd":lambda p:["-d",str(p),"-o","json"]},
42
+ "kics": {"category":"iac","exe":"kics","network":False,"cmd":lambda p:["scan","-p",str(p),"--output-path","-"]},
43
+ }
44
+ TOOLS = [
45
+ {"name":"security_catalog","description":"List independent scanner adapters, categories, executables, and advisory/web data requirements.","inputSchema":{"type":"object","properties":{},"additionalProperties":False}},
46
+ {"name":"security_doctor","description":"Discover languages, manifests, infrastructure files, recommended scanners, and installed executables. Does not execute a scanner.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"}},"required":["workspace"],"additionalProperties":False}},
47
+ {"name":"security_bootstrap","description":"Check the code-scanner profile and workspace toolchain before a scan. Set createProfile=true only after explicit user approval to create a missing profile; it does not execute a scanner.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"createProfile":{"type":"boolean"}},"required":["workspace"],"additionalProperties":False}},
48
+ {"name":"security_plan","description":"Create a non-executing workspace scan plan from detected files and available scanner programs.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"}},"required":["workspace"],"additionalProperties":False}},
49
+ {"name":"security_virtual_run","description":"Preview one allowlisted scanner command without executing it.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"adapter":{"type":"string"},"allowNetwork":{"type":"boolean"}},"required":["workspace","adapter"],"additionalProperties":False}},
50
+ {"name":"security_run","description":"Execute one allowlisted scanner without a shell. It requires a started lifecycle and an identical recorded preview; network-dependent scanners require recorded lifecycle consent.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"runId":{"type":"string"},"adapter":{"type":"string"}},"required":["workspace","runId","adapter"],"additionalProperties":False}},
51
+ {"name":"security_ingest","description":"Normalize an existing local JSON, JSON-lines, or SARIF report without executing any program.","inputSchema":{"type":"object","properties":{"report":{"type":"string"},"format":{"enum":["json","sarif"]},"adapter":{"type":"string"}},"required":["report","format"],"additionalProperties":False}},
52
+ {"name":"security_start_run","description":"Create an in-memory, schema-owned scan lifecycle. Record only the user approvals supplied by the host; it does not execute or write anything.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"mode":{"enum":["scan","scan-ai","scan-agent"]},"consent":{"type":"object","properties":{"profileWrite":{"type":"boolean"},"network":{"type":"boolean"},"aiTriage":{"type":"boolean"},"agentReview":{"type":"boolean"}},"additionalProperties":False}},"required":["workspace","mode","consent"],"additionalProperties":False}},
53
+ {"name":"security_record_run","description":"Append scanner evidence, a preview, a skipped-scanner reason, host-AI triage, or independent agent review to a durable started scan lifecycle. It does not execute a process.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"runId":{"type":"string"},"kind":{"enum":["scanner","preview","skipped","host_ai_triage","agent_review"]},"entry":{"type":"object"}},"required":["workspace","runId","kind","entry"],"additionalProperties":False}},
54
+ {"name":"security_finalize_run","description":"Finalize a durable started scan lifecycle and store its schema-owned redacted Markdown report, including any recorded host-AI triage and independent agent review. The result is <workspace>/.mnogovid/code-scanner/<unixtime>/result.md.","inputSchema":{"type":"object","properties":{"workspace":{"type":"string"},"runId":{"type":"string"},"initialization":{"type":"object"},"doctor":{"type":"object"},"plan":{"type":"object"},"hostAiTriage":{"type":"object"},"aiTriage":{"type":"object"},"agentReview":{"type":"object"}},"required":["workspace","runId"],"additionalProperties":False}},
55
+ {"name":"security_advisory_lookup","description":"Query the OSV vulnerability advisory website for one package version. It makes an HTTPS request only when allowNetwork=true.","inputSchema":{"type":"object","properties":{"ecosystem":{"type":"string"},"package":{"type":"string"},"version":{"type":"string"},"allowNetwork":{"type":"boolean"}},"required":["ecosystem","package","version"],"additionalProperties":False}},
56
+ {"name":"security_ai_triage_payload","description":"Prepare bounded, secret-redacted findings for host-model AI triage. It does not contact any model or website itself.","inputSchema":{"type":"object","properties":{"findings":{"type":"array"},"remediation":{"type":"boolean"}},"required":["findings"],"additionalProperties":False}},
57
+ ]
58
+
59
+ def workspace(value: Any) -> Path:
60
+ if not isinstance(value,str) or not value: raise ValueError("workspace must be a non-empty string")
61
+ path=Path(value).expanduser().resolve()
62
+ if not path.is_dir(): raise ValueError(f"workspace is not a directory: {path}")
63
+ return path
64
+
65
+ def discover(root: Path) -> dict[str, Any]:
66
+ manifests=[]; languages=set(); surfaces=set(); visited=0
67
+ map_lang={".rs":"rust",".py":"python",".go":"go",".js":"javascript",".ts":"typescript",".java":"java",".rb":"ruby",".php":"php",".cs":"csharp",".cpp":"cpp",".c":"cpp"}
68
+ markers={"Cargo.toml":"cargo","package.json":"npm","pnpm-lock.yaml":"pnpm","yarn.lock":"yarn","pyproject.toml":"pypi","requirements.txt":"pypi","go.mod":"go","Gemfile.lock":"rubygems","composer.lock":"composer","pom.xml":"maven","build.gradle":"gradle","packages.lock.json":"nuget"}
69
+ for base,dirs,files in os.walk(root):
70
+ dirs[:]=[d for d in dirs if d not in IGNORE]
71
+ for name in files:
72
+ visited+=1; path=Path(base,name); rel=str(path.relative_to(root))
73
+ if name in markers: manifests.append({"path":rel,"ecosystem":markers[name]})
74
+ if name.startswith("Dockerfile"): surfaces.add("containers")
75
+ if path.suffix==".tf": surfaces.add("terraform")
76
+ if any(x in rel.lower() for x in ("k8s/","kubernetes/","helm/")) and path.suffix in (".yml",".yaml"): surfaces.add("kubernetes")
77
+ if path.suffix in map_lang: languages.add(map_lang[path.suffix])
78
+ ecosystems=sorted({x["ecosystem"] for x in manifests})
79
+ return {"workspacePath":str(root),"manifests":manifests,"ecosystems":ecosystems,"languages":sorted(languages),"surfaces":sorted(surfaces),"visitedEntries":visited}
80
+
81
+ def recommend(found: dict[str,Any]) -> list[str]:
82
+ eco=set(found["ecosystems"]); lang=set(found["languages"]); surf=set(found["surfaces"]); out=["gitleaks"]
83
+ if lang: out.append("semgrep")
84
+ if "rust" in lang: out += ["cargo-audit","cargo-deny"]
85
+ if "python" in lang: out += ["bandit","pip-audit"]
86
+ if "go" in lang: out += ["gosec","govulncheck"]
87
+ if "ruby" in lang: out += ["brakeman","bundler-audit"]
88
+ if "npm" in eco or "pnpm" in eco or "yarn" in eco: out.append("npm-audit")
89
+ if "composer" in eco: out.append("composer-audit")
90
+ if surf: out += ["checkov","kics","trivy-fs"]
91
+ if eco: out += ["osv-scanner","grype","syft"]
92
+ return list(dict.fromkeys(out))
93
+
94
+ def plan(root: Path) -> dict[str,Any]:
95
+ found=discover(root); ids=recommend(found)
96
+ runs=[]
97
+ for ident in ids:
98
+ item=ADAPTERS[ident]; exe=shutil.which(item["exe"])
99
+ runs.append({"adapter":ident,"category":item["category"],"executable":item["exe"],"available":bool(exe),"requiresNetwork":item["network"],"execution":"not_executed"})
100
+ return {**found,"recommendedAdapters":ids,"runs":runs,"networkUsed":False,"processStarted":False}
101
+
102
+ def bootstrap(root: Path, create_profile: bool) -> dict[str,Any]:
103
+ if not isinstance(create_profile,bool): raise ValueError("createProfile must be boolean when supplied")
104
+ profile_path=root/PROFILE_NAME
105
+ profile: dict[str,Any]
106
+ if profile_path.exists() or profile_path.is_symlink():
107
+ if profile_path.is_symlink() or not profile_path.is_file(): raise ValueError("refusing non-regular or symlinked code-scanner profile")
108
+ try: saved=json.loads(profile_path.read_text(encoding="utf-8"))
109
+ except (OSError,json.JSONDecodeError): saved=None
110
+ valid=isinstance(saved,dict) and saved.get("schemaVersion")==1 and saved.get("generatedBy") in {"mnogovid-code-scanner init","mnogovid-code-scanner bootstrap"}
111
+ profile={"path":str(profile_path),"action":"verified" if valid else "invalid","valid":valid}
112
+ elif create_profile:
113
+ discovered=plan(root)
114
+ saved={"schemaVersion":1,"generatedBy":"mnogovid-code-scanner bootstrap","generatedAt":datetime.now(timezone.utc).replace(microsecond=0).isoformat(),"recommendedAdapters":discovered["recommendedAdapters"],"availableAdapters":[item["adapter"] for item in discovered["runs"] if item["available"]],"notes":["This profile records discovery only and grants no scanner permission.","Every scanner still requires an explicit preview and approval."]}
115
+ descriptor=os.open(profile_path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0),0o600)
116
+ with os.fdopen(descriptor,"w",encoding="utf-8") as handle: handle.write(json.dumps(saved,ensure_ascii=False,indent=2)+"\n")
117
+ profile={"path":str(profile_path),"action":"created","valid":True}
118
+ else: profile={"path":str(profile_path),"action":"missing","valid":False}
119
+ discovered=plan(root)
120
+ return {"profile":profile,"doctor":{**discovered,"missingExecutables":[item["executable"] for item in discovered["runs"] if not item["available"]]},"processStarted":False}
121
+
122
+ def command(root: Path, ident: str) -> tuple[dict[str,Any],list[str],str]:
123
+ if ident not in ADAPTERS: raise ValueError(f"unknown adapter: {ident}")
124
+ spec=ADAPTERS[ident]; args=spec["cmd"](root); exe=shutil.which(spec["exe"])
125
+ return spec, [exe or spec["exe"],*args], str(root if spec.get("cwd") else root)
126
+
127
+ def redact(value: Any) -> Any:
128
+ if isinstance(value,dict): return {str(k):redact("[REDACTED]" if re.search(r"(token|secret|password|api.?key|private.?key)",str(k),re.I) else v) for k,v in value.items()}
129
+ if isinstance(value,list): return [redact(v) for v in value]
130
+ if isinstance(value,str) and re.search(r"(ghp_|sk-|AKIA|-----BEGIN|(?:token|secret|password|api[_-]?key)\s*[=:])",value,re.I): return "[REDACTED]"
131
+ return value
132
+
133
+ def markdown_cell(value: Any) -> str:
134
+ if value is None: return "—"
135
+ if isinstance(value,bool): return "yes" if value else "no"
136
+ if isinstance(value,(dict,list)): return "See details below"
137
+ return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
138
+
139
+ def markdown_table(headers: list[str], rows: list[list[Any]]) -> list[str]:
140
+ if not rows: return ["No data recorded.", ""]
141
+ return [
142
+ "| " + " | ".join(headers) + " |",
143
+ "| " + " | ".join("---" for _ in headers) + " |",
144
+ *["| " + " | ".join(markdown_cell(cell) for cell in row) + " |" for row in rows],
145
+ "",
146
+ ]
147
+
148
+ def markdown_value(title: str, value: Any, level: int = 2) -> list[str]:
149
+ lines = ["#" * level + " " + title, ""]
150
+ if isinstance(value,dict):
151
+ scalars = [(str(key), item) for key,item in value.items() if not isinstance(item,(dict,list))]
152
+ if scalars: lines += markdown_table(["Field","Value"], scalars)
153
+ for key,item in value.items():
154
+ if isinstance(item,(dict,list)): lines += markdown_value(str(key),item,level+1)
155
+ elif isinstance(value,list) and all(isinstance(item,dict) for item in value):
156
+ keys = list(dict.fromkeys(str(key) for item in value for key in item if not isinstance(item[key],(dict,list))))[:8]
157
+ lines += markdown_table(keys, [[item.get(key) for key in keys] for item in value]) if keys else ["No scalar fields recorded.", ""]
158
+ elif isinstance(value,list):
159
+ lines += [f"- {markdown_cell(item)}" for item in value] or ["No data recorded."]
160
+ lines.append("")
161
+ else: lines += [markdown_cell(value), ""]
162
+ return lines
163
+
164
+ def scanner_results(report: dict[str, Any]) -> list[dict[str, Any]]:
165
+ candidates = report.get("scannerResults", report.get("results", []))
166
+ return [item for item in candidates if isinstance(item,dict)] if isinstance(candidates,list) else []
167
+
168
+ def findings_from(report: dict[str, Any], runs: list[dict[str, Any]]) -> list[dict[str, Any]]:
169
+ findings = [item for item in report.get("findings",[]) if isinstance(item,dict)] if isinstance(report.get("findings"),list) else []
170
+ for run in runs:
171
+ for finding in run.get("findings",[]) if isinstance(run.get("findings"),list) else []:
172
+ if isinstance(finding,dict): findings.append({"adapter":run.get("adapter"),**finding})
173
+ return findings[:200]
174
+
175
+ def finding_value(finding: dict[str, Any], names: tuple[str, ...]) -> Any:
176
+ for name in names:
177
+ value = finding.get(name)
178
+ if value not in (None,""): return value
179
+ for container in ("package","dependency","artifact","component"):
180
+ nested = finding.get(container)
181
+ if isinstance(nested,dict):
182
+ value = finding_value(nested,names)
183
+ if value not in (None,""): return value
184
+ return None
185
+
186
+ def finding_location_or_library(finding: dict[str, Any]) -> str:
187
+ location = finding_value(finding,("location","path","file","uri"))
188
+ line = finding_value(finding,("line","lineNumber","startLine"))
189
+ library = finding_value(finding,("library","packageName","name","componentName"))
190
+ parts = []
191
+ if location: parts.append(f"{location}:{line}" if line else str(location))
192
+ if library and str(library) != str(location): parts.append(str(library))
193
+ return "; ".join(parts) if parts else "—"
194
+
195
+ def vulnerability_row(finding: dict[str, Any]) -> list[Any]:
196
+ return [
197
+ finding_value(finding,("vulnerability","ruleId","id","cve","advisory","title")),
198
+ finding_value(finding,("severity","level","risk")),
199
+ finding_value(finding,("affectedVersion","installedVersion","currentVersion","version")),
200
+ finding_value(finding,("fixedVersion","fixedIn","fixVersion","fixed_version")),
201
+ finding_location_or_library(finding),
202
+ ]
203
+
204
+ def finding_title(finding: dict[str, Any]) -> str:
205
+ identifier = finding_value(finding,("ruleId","id","cve","advisory","testId"))
206
+ title = finding_value(finding,("title","vulnerability","issueText","message"))
207
+ if identifier and title: return f"{identifier}: {title}"
208
+ return str(title or identifier or "Scanner finding")
209
+
210
+ def scan_verdict(findings: list[dict[str, Any]], notes: dict[int, dict[str, Any]]) -> tuple[str,str]:
211
+ if not findings: return ("No scanner findings", "No findings were reported by completed scanners. This is not proof that the project has no vulnerabilities.")
212
+ classifications = [str(note.get("classification","")).lower() for note in notes.values()]
213
+ if any(value=="true_positive" for value in classifications): return ("Action required", "At least one scanner finding was assessed as a likely real vulnerability.")
214
+ if any(value=="needs_review" for value in classifications) or not notes: return ("Review required", "Scanner findings need human verification before they are treated as vulnerabilities or dismissed.")
215
+ return ("No confirmed vulnerabilities", "The recorded AI assessment did not confirm the scanner findings; retain the evidence for review.")
216
+
217
+ def scanner_recovery(run: dict[str, Any]) -> str:
218
+ adapter = str(run.get("adapter") or "scanner")
219
+ steps = {
220
+ "semgrep":"Re-run the previewed Semgrep command and confirm that it produces JSON with a `results` array. If configuration download was blocked, grant network access for that run or use a local ruleset.",
221
+ "osv-scanner":"Run `osv-scanner --version`, then re-run the previewed OSV Scanner command. If it still fails, update or reinstall OSV Scanner and retain its diagnostic output for review.",
222
+ "pip-audit":"Re-run `pip-audit -f json` from the project root. If it cannot resolve dependencies, verify the project dependency files and the approved network connection.",
223
+ "npm-audit":"Re-run `npm audit --json` from the project root. If it cannot resolve the registry, verify the lockfile and the approved network connection.",
224
+ "grype":"Re-run the previewed Grype command. If its vulnerability database could not update, verify the approved network connection and refresh the local Grype database.",
225
+ }
226
+ diagnostic = str(redact(run.get("stderrSnippet") or "")).strip().replace("\n"," ")
227
+ diagnostic = diagnostic[:500]
228
+ suffix = f" Diagnostic (redacted): {diagnostic}" if diagnostic else ""
229
+ return steps.get(adapter,"Re-run the previewed scanner command and inspect its diagnostic output before treating this area as covered.") + suffix
230
+
231
+ def host_ai_notes(report: dict[str, Any], finding_count: int) -> dict[int, dict[str, Any]]:
232
+ triage = report.get("hostAiTriage")
233
+ items = triage.get("findingNotes") if isinstance(triage,dict) else None
234
+ if not isinstance(items,list): return {}
235
+ notes: dict[int,dict[str,Any]] = {}
236
+ for item in items:
237
+ if isinstance(item,dict) and isinstance(item.get("findingIndex"),int) and 0 <= item["findingIndex"] < finding_count:
238
+ notes[item["findingIndex"]]=item
239
+ return notes
240
+
241
+ def validate_host_ai_triage(run: dict[str, Any], entry: dict[str, Any]) -> None:
242
+ finding_count = sum(len(item.get("findings",[])) for item in run["scannerResults"] if isinstance(item.get("findings"),list))
243
+ if finding_count == 0: return
244
+ notes = entry.get("findingNotes")
245
+ if not isinstance(notes,list): raise ValueError("host-AI triage must include findingNotes for every scanner finding")
246
+ indexed = {item.get("findingIndex"):item for item in notes if isinstance(item,dict) and isinstance(item.get("findingIndex"),int)}
247
+ missing = [index for index in range(finding_count) if not isinstance(indexed.get(index,{}).get("note"),str) or not indexed[index]["note"].strip()]
248
+ if missing: raise ValueError(f"host-AI triage is missing detailed notes for finding indexes: {missing}")
249
+
250
+ def render_report(root: Path, mode: str, report_id: str, generated: str, report: dict[str, Any]) -> str:
251
+ lines = ["# Security scan report", ""]
252
+ runs = scanner_results(report); findings = findings_from(report,runs); ai_notes = host_ai_notes(report,len(findings))
253
+ completed = [run for run in runs if run.get("resultStatus")=="complete"]
254
+ incomplete = [run for run in runs if run.get("resultStatus")=="incomplete"]
255
+ failed = [run for run in runs if run.get("resultStatus")=="failed"]
256
+ verdict, explanation = scan_verdict(findings,ai_notes)
257
+ lines += ["## Verdict", "", f"**{verdict}.** {explanation}", ""]
258
+ lines += markdown_table(["Workspace","Mode","Findings","Completed scanners","Incomplete / failed"], [[root,mode,len(findings),len(completed),len(incomplete) + len(failed)]])
259
+ if incomplete or failed:
260
+ lines += ["### Coverage gaps", ""]
261
+ for run in incomplete:
262
+ lines += [f"- **{run.get('adapter') or 'unknown'}:** exited with code {run.get('exitCode')} but produced no findings that this version could normalize. This is not a clean result. **How to fix:** {scanner_recovery(run)}"]
263
+ for run in failed:
264
+ lines += [f"- **{run.get('adapter') or 'unknown'}:** stopped with code {run.get('exitCode')} before a usable result was produced. This does not prove that no vulnerabilities exist. **How to fix:** {scanner_recovery(run)}"]
265
+ lines.append("")
266
+ lines += ["## What needs attention", ""]
267
+ if not findings:
268
+ lines += ["No scanner findings were reported. Review failed or skipped scanners before treating this as a clean result.", ""]
269
+ for index,finding in enumerate(findings):
270
+ note = ai_notes.get(index,{})
271
+ lines += [f"### {index + 1}. {finding_title(finding)}", ""]
272
+ lines += [f"**Scanner:** {finding.get('adapter') or 'unknown'} ", f"**Severity:** {finding_value(finding,('severity','level','risk')) or 'not provided'} ", f"**Location:** {finding_location_or_library(finding)}", ""]
273
+ if note:
274
+ lines += [f"**AI assessment:** {note.get('classification','needs_review')} (confidence: {note.get('confidence','not provided')}) ", f"**Why it matters:** {note.get('note','No detailed AI note was recorded.')}", ""]
275
+ elif mode in ("scan-ai","scan-agent"):
276
+ lines += ["**AI assessment:** not recorded — this report is incomplete and should not have been finalized.", ""]
277
+ else:
278
+ lines += ["**Next step:** inspect the surrounding code and decide whether this scanner finding is exploitable or a false positive.", ""]
279
+ lines += ["## Scan coverage", ""]
280
+ coverage_rows=[]
281
+ for run in runs:
282
+ count=(run.get("counts") or {}).get("findings")
283
+ if count is None: count=len(run.get("findings",[])) if isinstance(run.get("findings"),list) else 0
284
+ coverage_rows.append([run.get("adapter"),run.get("resultStatus"),count,"network" if run.get("requiresNetwork") else "local"])
285
+ lines += markdown_table(["Scanner","Result","Findings","Access"],coverage_rows)
286
+ lines += ["## Report details", ""]
287
+ lines += markdown_table(["Report ID", "Generated", "AI analysis", "Independent review"], [[report_id,generated,"included" if "hostAiTriage" in report else "not requested","included" if "agentReview" in report else "not requested"]])
288
+ return "\n".join(lines).rstrip() + "\n"
289
+
290
+ def write_report(root: Path, mode: str, report: dict[str, Any]) -> dict[str, Any]:
291
+ reports = root / ".mnogovid"
292
+ scanner_reports = reports / "code-scanner"
293
+ if reports.exists() and reports.is_symlink(): raise ValueError("refusing to write reports through a symlinked .mnogovid directory")
294
+ if scanner_reports.exists() and scanner_reports.is_symlink(): raise ValueError("refusing to write reports through a symlinked code-scanner directory")
295
+ report_id = str(time.time_ns()); destination = scanner_reports / report_id / "result.md"
296
+ generated = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
297
+ document = render_report(root,mode,report_id,generated,redact(report))
298
+ truncated = len(document.encode("utf-8")) > MAX_OUTPUT
299
+ if truncated: document = document.encode("utf-8")[:MAX_OUTPUT].decode("utf-8","ignore") + "\n\n_Report truncated at the storage limit._\n"
300
+ destination.parent.mkdir(parents=True, exist_ok=False)
301
+ destination.write_text(document,encoding="utf-8")
302
+ return {"reportId":report_id,"path":str(destination),"redacted":True,"truncated":truncated}
303
+
304
+ def state_path(root: Path, run_id: Any) -> Path:
305
+ if not isinstance(run_id,str) or not run_id.isdigit(): raise ValueError("runId must be a Unix timestamp")
306
+ return root / ".mnogovid" / "code-scanner" / run_id / "run-state.json"
307
+
308
+ def save_run(root: Path, run_id: str, run: dict[str, Any]) -> None:
309
+ path=state_path(root,run_id); path.parent.mkdir(parents=True,exist_ok=True)
310
+ stored={**run,"workspace":str(root)}; temp=path.with_suffix(".tmp")
311
+ temp.write_text(json.dumps(redact(stored),ensure_ascii=False,indent=2)+"\n",encoding="utf-8"); temp.replace(path)
312
+
313
+ def started_run(root: Path, run_id: Any) -> dict[str, Any]:
314
+ if isinstance(run_id,str) and run_id in RUNS: return RUNS[run_id]
315
+ path=state_path(root,run_id)
316
+ if not path.is_file(): raise ValueError("unknown or expired runId")
317
+ run=json.loads(path.read_text(encoding="utf-8"))
318
+ if run.get("workspace") != str(root): raise ValueError("runId belongs to a different workspace")
319
+ RUNS[str(run_id)]=run; return run
320
+
321
+ def parse_report(value: Any, adapter: str|None=None) -> list[dict[str,Any]]:
322
+ findings=[]
323
+ if isinstance(value,dict) and isinstance(value.get("runs"),list):
324
+ for run in value["runs"]:
325
+ for result in run.get("results",[]) or []:
326
+ loc=((result.get("locations") or [{}])[0].get("physicalLocation") or {})
327
+ findings.append({"adapter":adapter,"ruleId":result.get("ruleId"),"severity":((result.get("level") or "unknown").upper()),"title":result.get("message",{}).get("text","") if isinstance(result.get("message"),dict) else "","location":loc.get("artifactLocation",{}).get("uri"),"line":(loc.get("region",{}) or {}).get("startLine")})
328
+ elif adapter=="bandit" and isinstance(value,dict) and isinstance(value.get("results"),list):
329
+ for result in value["results"][:200]:
330
+ if isinstance(result,dict): findings.append({"adapter":adapter,"ruleId":result.get("test_id"),"severity":result.get("issue_severity"),"confidence":result.get("issue_confidence"),"title":result.get("issue_text"),"location":result.get("filename"),"line":result.get("line_number")})
331
+ elif isinstance(value,dict) and isinstance(value.get("results"),list):
332
+ for result in value["results"][:200]: findings.append({"adapter":adapter,"ruleId":result.get("check_id") or result.get("rule_id"),"severity":result.get("extra",{}).get("severity") if isinstance(result.get("extra"),dict) else None,"title":result.get("extra",{}).get("message","") if isinstance(result.get("extra"),dict) else str(result)[:300],"location":result.get("path"),"line":result.get("start",{}).get("line") if isinstance(result.get("start"),dict) else None})
333
+ elif adapter=="pip-audit" and isinstance(value,list):
334
+ for dependency in value[:200]:
335
+ if not isinstance(dependency,dict): continue
336
+ for vuln in dependency.get("vulns",[]) or []:
337
+ if isinstance(vuln,dict): findings.append({"adapter":adapter,"ruleId":vuln.get("id"),"title":vuln.get("description"),"severity":"unknown","library":dependency.get("name"),"installedVersion":dependency.get("version"),"fixedVersion":", ".join(vuln.get("fix_versions",[])) if isinstance(vuln.get("fix_versions"),list) else None})
338
+ elif adapter=="npm-audit" and isinstance(value,dict) and isinstance(value.get("vulnerabilities"),dict):
339
+ for package_name,detail in value["vulnerabilities"].items():
340
+ if not isinstance(detail,dict): continue
341
+ via=detail.get("via",[]); via=via if isinstance(via,list) else [via]
342
+ advisory_items=[item for item in via if isinstance(item,dict)] or [{}]
343
+ for advisory in advisory_items:
344
+ fix=detail.get("fixAvailable")
345
+ fixed_version=fix.get("version") if isinstance(fix,dict) else None
346
+ findings.append({"adapter":adapter,"ruleId":advisory.get("url") or advisory.get("source"),"severity":advisory.get("severity") or detail.get("severity"),"title":advisory.get("title") or f"Vulnerable dependency: {package_name}","library":package_name,"installedVersion":detail.get("range"),"fixedVersion":fixed_version})
347
+ elif adapter=="grype" and isinstance(value,dict) and isinstance(value.get("matches"),list):
348
+ for match in value["matches"][:200]:
349
+ if not isinstance(match,dict): continue
350
+ vulnerability=match.get("vulnerability") if isinstance(match.get("vulnerability"),dict) else {}
351
+ artifact=match.get("artifact") if isinstance(match.get("artifact"),dict) else {}
352
+ fix=vulnerability.get("fix") if isinstance(vulnerability.get("fix"),dict) else {}
353
+ locations=artifact.get("locations") if isinstance(artifact.get("locations"),list) else []
354
+ location=(locations[0].get("path") if locations and isinstance(locations[0],dict) else None)
355
+ findings.append({"adapter":adapter,"ruleId":vulnerability.get("id"),"severity":vulnerability.get("severity"),"title":vulnerability.get("description") or vulnerability.get("id"),"library":artifact.get("name"),"installedVersion":artifact.get("version"),"fixedVersion":", ".join(fix.get("versions",[])) if isinstance(fix.get("versions"),list) else None,"location":location})
356
+ elif isinstance(value,list):
357
+ for item in value[:200]: findings.append({"adapter":adapter,"severity":item.get("Severity") or item.get("severity") if isinstance(item,dict) else "unknown","title":str(item)[:500]})
358
+ return redact(findings)
359
+
360
+ def run_one(root:Path, ident:str, allow:bool, virtual:bool) -> dict[str,Any]:
361
+ spec,argv,cwd=command(root,ident)
362
+ result={"adapter":ident,"category":spec["category"],"workspacePath":str(root),"requiresNetwork":spec["network"],"networkRequested":allow,"networkEnforced":False,"command":{"argv":argv,"currentDir":cwd}}
363
+ if virtual: return {**result,"execution":"virtual","processStarted":False,"resultStatus":"not_executed","findings":[]}
364
+ if spec["network"] and not allow: raise ValueError(f"adapter '{ident}' requires network; set allowNetwork=true")
365
+ if not shutil.which(spec["exe"]): raise ValueError(f"scanner executable not found on PATH: {spec['exe']}")
366
+ completed=subprocess.run(argv,cwd=cwd,capture_output=True,text=True,timeout=300,check=False)
367
+ text=(completed.stdout or "")[:MAX_OUTPUT]; err=(completed.stderr or "")[:MAX_OUTPUT]
368
+ parsed=None
369
+ try: parsed=json.loads(text)
370
+ except json.JSONDecodeError:
371
+ try: parsed=[json.loads(line) for line in text.splitlines() if line.strip().startswith("{")]
372
+ except json.JSONDecodeError: pass
373
+ findings=parse_report(parsed,ident) if parsed is not None else []
374
+ secret=spec["category"]=="secrets"
375
+ if completed.returncode==0: status="complete"
376
+ elif completed.returncode==1 and findings: status="complete"
377
+ elif completed.returncode==1: status="incomplete"
378
+ else: status="failed"
379
+ return {**result,"execution":"executed","processStarted":True,"resultStatus":status,"exitCode":completed.returncode,"parsedJson":parsed is not None,"findings":findings,"counts":{"findings":len(findings)},"stdoutSnippet":"" if secret else redact(text[:4000]),"stderrSnippet":"" if secret else redact(err[:4000])}
380
+
381
+ def content(value:Any,error:bool=False)->dict[str,Any]: return {"isError":error,"content":[{"type":"text","text":json.dumps(value,ensure_ascii=False)}]}
382
+ def call(name:str,args:dict[str,Any])->dict[str,Any]:
383
+ try:
384
+ if name=="security_catalog": return content({"adapters":[{"id":k,"category":v["category"],"executable":v["exe"],"requiresNetwork":v["network"]} for k,v in ADAPTERS.items()],"webSources":["OSV","NVD","GitHub Advisory Database","vendor advisory databases"],"ai":"Use security_ai_triage_payload with the host model after explicit approval."})
385
+ if name=="security_doctor":
386
+ root=workspace(args.get("workspace")); data=plan(root); return content({**data,"missingExecutables":[r["executable"] for r in data["runs"] if not r["available"]]})
387
+ if name=="security_bootstrap":
388
+ root=workspace(args.get("workspace")); return content(bootstrap(root,args.get("createProfile",False)))
389
+ if name=="security_plan": return content(plan(workspace(args.get("workspace"))))
390
+ if name=="security_start_run":
391
+ root=workspace(args.get("workspace")); mode=args.get("mode"); consent=args.get("consent")
392
+ if mode not in ("scan","scan-ai","scan-agent") or not isinstance(consent,dict): raise ValueError("mode and consent are required")
393
+ run_id=str(time.time_ns())
394
+ RUNS[run_id]={"workspace":str(root),"mode":mode,"consent":redact(consent),"startedAt":datetime.now(timezone.utc).replace(microsecond=0).isoformat(),"scannerResults":[],"virtualCommands":[],"skippedScanners":[],"hostAiTriage":{"status":"pending" if consent.get("aiTriage") is True else "not_authorized"},"agentReview":{"status":"pending" if consent.get("agentReview") is True else "not_authorized"}}
395
+ save_run(root,run_id,RUNS[run_id]); return content({"runId":run_id,"workspace":str(root),"statePath":str(state_path(root,run_id)),"mode":mode,"consent":RUNS[run_id]["consent"],"processStarted":False,"reportWritten":False})
396
+ if name=="security_record_run":
397
+ root=workspace(args.get("workspace")); run_id=args.get("runId"); run=started_run(root,run_id); kind=args.get("kind"); entry=args.get("entry")
398
+ if kind not in ("scanner","preview","skipped","host_ai_triage","agent_review") or not isinstance(entry,dict): raise ValueError("kind and entry are required")
399
+ if kind in ("host_ai_triage","agent_review"):
400
+ if kind=="host_ai_triage": validate_host_ai_triage(run,entry)
401
+ key={"host_ai_triage":"hostAiTriage","agent_review":"agentReview"}[kind]
402
+ run[key]=redact(entry); save_run(root,str(run_id),run); return content({"runId":run_id,"recorded":kind,"entry":run[key]})
403
+ key={"scanner":"scannerResults","preview":"virtualCommands","skipped":"skippedScanners"}[kind]
404
+ run[key].append(redact(entry)); save_run(root,str(run_id),run); return content({"runId":run_id,"recorded":kind,"count":len(run[key])})
405
+ if name=="security_finalize_run":
406
+ root=workspace(args.get("workspace")); run_id=args.get("runId"); run=started_run(root,run_id)
407
+ legacy_ai=args.get("aiTriage")
408
+ host_ai=args.get("hostAiTriage",legacy_ai)
409
+ if host_ai is not None:
410
+ if not isinstance(host_ai,dict): raise ValueError("hostAiTriage must be an object")
411
+ validate_host_ai_triage(run,host_ai)
412
+ run["hostAiTriage"]=redact(host_ai)
413
+ if args.get("agentReview") is not None:
414
+ if not isinstance(args["agentReview"],dict): raise ValueError("agentReview must be an object")
415
+ run["agentReview"]=redact(args["agentReview"])
416
+ if run["mode"] in ("scan-ai","scan-agent") and run["consent"].get("aiTriage") is True and run["hostAiTriage"].get("status")=="pending": raise ValueError("record host-AI triage before finalizing an approved AI scan")
417
+ if run["mode"]=="scan-agent" and run["consent"].get("agentReview") is True and run["agentReview"].get("status")=="pending": raise ValueError("record agent review before finalizing an approved agent scan")
418
+ report={"runId":run_id,"startedAt":run["startedAt"],"consent":run["consent"],"scannerResults":run["scannerResults"],"virtualCommands":run["virtualCommands"],"skippedScanners":run["skippedScanners"]}
419
+ if run["mode"] in ("scan-ai","scan-agent"): report["hostAiTriage"]=run["hostAiTriage"]
420
+ if run["mode"]=="scan-agent": report["agentReview"]=run["agentReview"]
421
+ for key in ("initialization","doctor","plan"):
422
+ value=args.get(key)
423
+ if value is not None:
424
+ if not isinstance(value,dict): raise ValueError(f"{key} must be an object")
425
+ report[key]=value
426
+ result=write_report(root,run["mode"],report); state_path(root,run_id).unlink(); del RUNS[run_id]
427
+ return content({**result,"runId":run_id,"finalized":True})
428
+ if name=="security_virtual_run":
429
+ root=workspace(args.get("workspace")); ident=args.get("adapter")
430
+ if not isinstance(ident,str): raise ValueError("adapter must be a string")
431
+ return content(run_one(root,ident,args.get("allowNetwork") is True,True))
432
+ if name=="security_run":
433
+ root=workspace(args.get("workspace")); ident=args.get("adapter"); run=started_run(root,args.get("runId"))
434
+ if not isinstance(ident,str): raise ValueError("adapter must be a string")
435
+ preview=run_one(root,ident,run["consent"].get("network") is True,True)
436
+ if preview["requiresNetwork"] and run["consent"].get("network") is not True: raise ValueError("network consent was not recorded for this lifecycle")
437
+ if not any(item.get("adapter")==preview["adapter"] and item.get("command",{}).get("argv")==preview["command"]["argv"] for item in run["virtualCommands"]): raise ValueError("record an identical security_virtual_run preview in this lifecycle before executing")
438
+ return content(run_one(root,ident,run["consent"].get("network") is True,False))
439
+ if name=="security_ingest":
440
+ report=Path(str(args.get("report",""))).expanduser().resolve(); fmt=args.get("format")
441
+ if not report.is_file() or fmt not in ("json","sarif"): raise ValueError("report must be an existing file and format must be json or sarif")
442
+ raw=json.loads(report.read_text()); findings=parse_report(raw,args.get("adapter")); return content({"sourcePath":str(report),"format":fmt,"reportOnly":True,"findings":findings,"counts":{"findings":len(findings)}})
443
+ if name=="security_advisory_lookup":
444
+ if args.get("allowNetwork") is not True: raise ValueError("OSV lookup requires allowNetwork=true")
445
+ ecosystem,package_name,version=(args.get("ecosystem"),args.get("package"),args.get("version"))
446
+ if not all(isinstance(v,str) and v for v in (ecosystem,package_name,version)): raise ValueError("ecosystem, package, and version must be non-empty strings")
447
+ body=json.dumps({"package":{"ecosystem":ecosystem,"name":package_name},"version":version}).encode()
448
+ request=urllib.request.Request("https://api.osv.dev/v1/query",data=body,headers={"Content-Type":"application/json"},method="POST")
449
+ try:
450
+ with urllib.request.urlopen(request,timeout=15) as response: raw=json.loads(response.read().decode())
451
+ except (urllib.error.URLError,TimeoutError,json.JSONDecodeError) as exc: raise ValueError(f"OSV advisory lookup failed: {exc}")
452
+ vulns=raw.get("vulns",[]) if isinstance(raw,dict) else []
453
+ return content({"source":"OSV","networkUsed":True,"package":{"ecosystem":ecosystem,"name":package_name,"version":version},"vulnerabilities":[{"id":v.get("id"),"summary":v.get("summary"),"modified":v.get("modified"),"aliases":v.get("aliases",[]),"references":v.get("references",[])} for v in vulns[:50]]})
454
+ if name=="security_ai_triage_payload":
455
+ items=redact(args.get("findings"));
456
+ if not isinstance(items,list): raise ValueError("findings must be an array")
457
+ return content({"mode":"remediation" if args.get("remediation") else "triage","findingLimit":min(len(items),40),"findings":items[:40],"instruction":"Return a JSON object with findingNotes: one item for every supplied finding, in the same zero-based findingIndex order. Each item must include findingIndex, classification (true_positive, false_positive, or needs_review), confidence, and a detailed evidence-based note. Cite only supplied evidence. Propose patches only when remediation was explicitly requested."})
458
+ raise ValueError(f"unknown tool: {name}")
459
+ except (ValueError,OSError,subprocess.TimeoutExpired) as exc: return content({"error":str(exc)},True)
460
+
461
+ def main()->int:
462
+ for line in sys.stdin:
463
+ try:
464
+ req=json.loads(line); method=req.get("method"); rid=req.get("id")
465
+ if method=="initialize": out={"protocolVersion":"2025-03-26","capabilities":{"tools":{}},"serverInfo":{"name":"mnogovid-code-scanner","version":"0.1.0"}}
466
+ elif method=="tools/list": out={"tools":TOOLS}
467
+ elif method=="tools/call": out=call(req.get("params",{}).get("name",""),req.get("params",{}).get("arguments",{}))
468
+ elif rid is None: continue
469
+ else: raise ValueError(f"method not found: {method}")
470
+ print(json.dumps({"jsonrpc":"2.0","id":rid,"result":out},ensure_ascii=False),flush=True)
471
+ except Exception as exc: print(json.dumps({"jsonrpc":"2.0","id":None,"error":{"code":-32603,"message":str(exc)}}),flush=True)
472
+ return 0
473
+ if __name__=="__main__": raise SystemExit(main())