@bergabruh/system-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 +6 -0
- package/README.md +185 -0
- package/opencode.js +115 -0
- package/package.json +30 -0
- package/scripts/init.py +89 -0
- package/scripts/remote_mcp_config.py +48 -0
- package/scripts/system_mcp.py +801 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Mnogovid System Scanner
|
|
2
|
+
|
|
3
|
+
Mnogovid System Scanner is a consent-gated Linux host assessment plugin. It
|
|
4
|
+
orchestrates installed local tools and records redacted evidence; it never
|
|
5
|
+
installs software, applies a fix, deletes/quarantines a file, saves PCAPs, or
|
|
6
|
+
runs shell text.
|
|
7
|
+
|
|
8
|
+
It is intentionally layered. No tool can prove a Linux host is clean: a
|
|
9
|
+
rootkit can hide from user-space checks, package CVE state can differ because
|
|
10
|
+
of distribution backports, and encrypted or unobserved traffic cannot be fully
|
|
11
|
+
inspected.
|
|
12
|
+
|
|
13
|
+
## Coverage
|
|
14
|
+
|
|
15
|
+
| Area | Allowlisted adapters |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Hardening | Lynis |
|
|
18
|
+
| Malware / rootkits | ClamAV, rkhunter, chkrootkit |
|
|
19
|
+
| File/package integrity | AIDE, `rpm -Va` |
|
|
20
|
+
| Installed-package CVEs | debsecan |
|
|
21
|
+
| Runtime inventory | osquery |
|
|
22
|
+
| Listener and firewall exposure | `ss`, nftables |
|
|
23
|
+
| Persistence | enabled systemd units and timers |
|
|
24
|
+
| Audit, kernel, and logs | audit rules, loaded kernel modules, warning journal entries |
|
|
25
|
+
| Containers | Docker and Podman inventory when their CLI is available |
|
|
26
|
+
| Docker hardening and image CVEs | Docker security options/container posture, Trivy, Grype, Dockle |
|
|
27
|
+
| Service posture | Nginx syntax; bounded local MySQL, PostgreSQL, Redis, MongoDB, and ClickHouse probes |
|
|
28
|
+
| Active ports (explicit target only) | Nmap, top 100 ports with light version detection |
|
|
29
|
+
| Live traffic metadata (bounded) | TShark, 5–300 seconds, no packet file |
|
|
30
|
+
|
|
31
|
+
Missing executables are recorded as coverage gaps. The plugin does not attempt
|
|
32
|
+
to install them or elevate privilege. Some checks need root access and will
|
|
33
|
+
report failure rather than invoke `sudo`.
|
|
34
|
+
|
|
35
|
+
## Run
|
|
36
|
+
|
|
37
|
+
Install it from the Mnogovid marketplace, then use one of the native Codex
|
|
38
|
+
commands:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
@mnogovid-system-scanner
|
|
42
|
+
/mnogovid-system-scanner:system-scan
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `@` mention invokes the plugin onboarding prompt. The unified command first
|
|
46
|
+
asks whether to assess the local host or a configured remote MCP host, checks
|
|
47
|
+
the profile and toolchain, and creates a missing profile only after consent.
|
|
48
|
+
On later runs it validates the existing profile and available adapters before
|
|
49
|
+
asking which mode to use: adapters only, adapters plus AI triage, or adapters
|
|
50
|
+
plus AI triage and independent review. It collects all scanner permissions
|
|
51
|
+
separately.
|
|
52
|
+
|
|
53
|
+
### Remote server over SSH-stdio MCP
|
|
54
|
+
|
|
55
|
+
Install the same plugin on the remote host, preferably at
|
|
56
|
+
`/opt/mnogovid-system-scanner`, and define an SSH alias for a dedicated audit
|
|
57
|
+
account. Generate a static local Codex configuration stanza:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python3 /path/to/mnogovid-system-scanner/scripts/remote_mcp_config.py prod-audit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Copy its output into local `~/.codex/config.toml`, restart Codex, then use:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
/mnogovid-system-scanner:system-scan
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The MCP protocol stays inside SSH stdio: no remote TCP listener or secret is
|
|
70
|
+
required in the generated configuration. It explicitly disables SSH agent and
|
|
71
|
+
other forwarding, enables batch mode, and requires a known host key. The utility
|
|
72
|
+
only renders TOML; it does not edit the local configuration or contact the
|
|
73
|
+
host. See [`remote-mcp.toml.example`](remote-mcp.toml.example).
|
|
74
|
+
|
|
75
|
+
The unified command asks in chat which configured remote MCP connection to use
|
|
76
|
+
when there is more than one, then asks a separate first consent before it
|
|
77
|
+
connects or starts discovery. It defaults reports to the remote MCP process's
|
|
78
|
+
current working directory and shows its resolved path before scanner planning.
|
|
79
|
+
|
|
80
|
+
### Claude Code
|
|
81
|
+
|
|
82
|
+
Install the plugin from the configured marketplace, start Claude in the
|
|
83
|
+
selected report directory, then invoke:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
/system-scan
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### OpenCode
|
|
90
|
+
|
|
91
|
+
Add the package name to the project or global `opencode.json` and restart
|
|
92
|
+
OpenCode:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"$schema": "https://opencode.ai/config.json",
|
|
97
|
+
"plugin": ["@bergabruh/system-scanner"]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
OpenCode installs the package through Bun. The package starts its bundled,
|
|
102
|
+
dependency-free Python scanner bridge itself; no `cwd`, MCP block, or copied
|
|
103
|
+
`.opencode` assets are needed. Ask OpenCode to assess the local Linux host; it
|
|
104
|
+
receives the `mnogovid_system_scanner` tool and must still request every
|
|
105
|
+
recorded consent. `python3` and individual scanner executables remain system
|
|
106
|
+
prerequisites and are never installed by the package.
|
|
107
|
+
|
|
108
|
+
First inspect the host/tool availability without starting a scanner:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python3 /path/to/mnogovid-system-scanner/scripts/init.py /safe/report-directory --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This manual initializer is optional; the unified workflow uses `system_bootstrap`
|
|
115
|
+
first. A profile records discovery only and never grants scanner permission:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python3 /path/to/mnogovid-system-scanner/scripts/init.py /safe/report-directory --json --write
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
For every adapter the agent previews its exact argv and asks for approval. Two
|
|
122
|
+
additional controls are deliberately separate:
|
|
123
|
+
|
|
124
|
+
- Image scanners with external vulnerability databases require `network`
|
|
125
|
+
consent, an image reference, and per-scanner approval.
|
|
126
|
+
- `nmap-local` needs recorded lifecycle active-network consent,
|
|
127
|
+
`authorizedTarget=true`, and one explicitly authorized literal IP.
|
|
128
|
+
- Database clients require `serviceProbe` consent and use only fixed local,
|
|
129
|
+
read-only status commands. Their raw output is withheld after normalization.
|
|
130
|
+
- `tshark-summary` needs recorded lifecycle traffic-capture consent, a named
|
|
131
|
+
interface, and a 5–300 second capture interval. It emits metadata to the
|
|
132
|
+
scanner result only, not a PCAP file; packet metadata can still be sensitive.
|
|
133
|
+
|
|
134
|
+
The database adapters use fixed local read-only status/version commands. They
|
|
135
|
+
can be unavailable when a service is not local, its socket is inaccessible, or
|
|
136
|
+
it requires credentials; such a result is a coverage gap, not a clean bill of
|
|
137
|
+
health.
|
|
138
|
+
|
|
139
|
+
Reports are written only after `system_finalize_run`:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
<report-directory>/.mnogovid/system-scanner/<timestamp>/result.md
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The report is reader-first: verdict, actionable findings, coverage gaps with
|
|
146
|
+
recovery guidance, scanner coverage, security-relevant observations, recorded
|
|
147
|
+
consent, and distinct AI/independent-review sections. A failed, missing, or
|
|
148
|
+
declined adapter is a coverage gap—not a clean result.
|
|
149
|
+
|
|
150
|
+
## AI and independent review
|
|
151
|
+
|
|
152
|
+
The AI and independent-review modes create bounded, secret-redacted payloads
|
|
153
|
+
only after their own separate approvals. Both assessments are advisory and
|
|
154
|
+
remain distinct from the scanner evidence.
|
|
155
|
+
|
|
156
|
+
Use `system_ingest` to normalize a pre-existing private local JSON or SARIF
|
|
157
|
+
report inside the selected report directory without starting a process.
|
|
158
|
+
`system_advisory_lookup` queries OSV for one
|
|
159
|
+
package version only after a separate `allowNetwork=true` approval; it is
|
|
160
|
+
advisory evidence and does not account for distribution backports by itself.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
Validate the manifest and exercise the JSON-RPC server without starting a
|
|
165
|
+
scanner:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python3 /path/to/plugin-creator/scripts/validate_plugin.py /path/to/mnogovid-system-scanner
|
|
169
|
+
python3 -m unittest discover -s /path/to/mnogovid-system-scanner/tests -v
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Licensed under the Apache License 2.0.
|
|
173
|
+
|
|
174
|
+
## Troubleshooting
|
|
175
|
+
|
|
176
|
+
- **Profile missing:** rerun the unified workflow and approve profile creation.
|
|
177
|
+
- **Profile invalid:** stop and repair it explicitly; the workflow will not
|
|
178
|
+
scan through an invalid profile.
|
|
179
|
+
- **Adapter missing or permission denied:** install or grant the required
|
|
180
|
+
read-only access through normal system administration, then rerun bootstrap.
|
|
181
|
+
- **Remote MCP unavailable:** verify the SSH alias, known host key, remote
|
|
182
|
+
Python path, and remote plugin path; the generator does not contact the
|
|
183
|
+
server or modify `~/.codex/config.toml`.
|
|
184
|
+
- **No findings:** a completed set of checks cannot prove the absence of
|
|
185
|
+
compromise, unseen traffic, or kernel-level stealth. Review coverage gaps.
|
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
|
+
"system_catalog",
|
|
7
|
+
"system_doctor",
|
|
8
|
+
"system_bootstrap",
|
|
9
|
+
"system_plan",
|
|
10
|
+
"system_virtual_run",
|
|
11
|
+
"system_run",
|
|
12
|
+
"system_ingest",
|
|
13
|
+
"system_start_run",
|
|
14
|
+
"system_record_run",
|
|
15
|
+
"system_finalize_run",
|
|
16
|
+
"system_ai_triage_payload",
|
|
17
|
+
"system_advisory_lookup",
|
|
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 MnogovidSystemScanner = async () => {
|
|
93
|
+
const script = fileURLToPath(new URL("./scripts/system_mcp.py", import.meta.url))
|
|
94
|
+
const bridge = new PythonMcpBridge(script)
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
tool: {
|
|
98
|
+
mnogovid_system_scanner: tool({
|
|
99
|
+
description: "Call one consent-gated Mnogovid System Scanner operation. Valid operations: system_catalog, system_doctor, system_bootstrap, system_plan, system_virtual_run, system_run, system_ingest, system_start_run, system_record_run, system_finalize_run, system_ai_triage_payload, system_advisory_lookup. Pass argumentsJson as a JSON object. The Python core validates report directories, 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 System 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,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bergabruh/system-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for consent-gated Linux host security assessment.",
|
|
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
|
+
"linux"
|
|
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": { "type": "git", "url": "git+https://github.com/BergaBruh/mnogovid.git" }
|
|
30
|
+
}
|
package/scripts/init.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Initialize an optional, non-destructive Mnogovid System Scanner profile."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from system_mcp import atomic_write, plan, report_directory
|
|
13
|
+
|
|
14
|
+
PROFILE_NAME = ".mnogovid-system-scanner.json"
|
|
15
|
+
PACKAGE_MANAGER_HINTS = (
|
|
16
|
+
("apt", "apt-get", "sudo apt-get install <package>"),
|
|
17
|
+
("dnf", "dnf", "sudo dnf install <package>"),
|
|
18
|
+
("yum", "yum", "sudo yum install <package>"),
|
|
19
|
+
("pacman", "pacman", "sudo pacman -S <package>"),
|
|
20
|
+
("zypper", "zypper", "sudo zypper install <package>"),
|
|
21
|
+
("apk", "apk", "sudo apk add <package>"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def installation_guide(missing: list[str]) -> dict[str, object]:
|
|
26
|
+
return {
|
|
27
|
+
"message": "Install missing scanners only through your normal system administration process; this plugin never installs tools.",
|
|
28
|
+
"platform": platform.platform(),
|
|
29
|
+
"missingExecutables": missing,
|
|
30
|
+
"packageManagers": [{"name": name, "executable": executable, "commandTemplate": command} for name, executable, command in PACKAGE_MANAGER_HINTS if shutil.which(executable)],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
parser = argparse.ArgumentParser(description="Inspect available Linux host-security scanners without running them.")
|
|
36
|
+
parser.add_argument("report_directory", nargs="?", default=".", help="existing directory where optional profile and reports live")
|
|
37
|
+
parser.add_argument("--write", action="store_true", help=f"create {PROFILE_NAME} if absent")
|
|
38
|
+
parser.add_argument("--force", action="store_true", help="replace an existing profile; requires --write")
|
|
39
|
+
parser.add_argument("--allow-active-network", action="store_true", help="record a preference for later explicitly approved Nmap probes")
|
|
40
|
+
parser.add_argument("--allow-network", action="store_true", help="record a preference for later explicitly approved vulnerability-database use")
|
|
41
|
+
parser.add_argument("--allow-service-probe", action="store_true", help="record a preference for later explicitly approved local service status probes")
|
|
42
|
+
parser.add_argument("--allow-traffic-capture", action="store_true", help="record a preference for later explicitly approved bounded packet summaries")
|
|
43
|
+
parser.add_argument("--json", action="store_true", help="print JSON")
|
|
44
|
+
args = parser.parse_args()
|
|
45
|
+
if args.force and not args.write:
|
|
46
|
+
parser.error("--force requires --write")
|
|
47
|
+
root = report_directory(args.report_directory)
|
|
48
|
+
result = plan(root)
|
|
49
|
+
result["missingExecutables"] = [item["executable"] for item in result["runs"] if not item["available"]]
|
|
50
|
+
result["installationGuide"] = installation_guide(result["missingExecutables"])
|
|
51
|
+
profile_path = root / PROFILE_NAME
|
|
52
|
+
exists = profile_path.exists() or profile_path.is_symlink()
|
|
53
|
+
if exists and profile_path.is_symlink():
|
|
54
|
+
parser.error(f"refusing to write through symlinked profile: {profile_path}")
|
|
55
|
+
if args.write and (not exists or args.force):
|
|
56
|
+
profile = {
|
|
57
|
+
"schemaVersion": 1,
|
|
58
|
+
"generatedBy": "mnogovid-system-scanner init",
|
|
59
|
+
"generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
60
|
+
"allowActiveNetwork": args.allow_active_network,
|
|
61
|
+
"allowNetwork": args.allow_network,
|
|
62
|
+
"allowTrafficCapture": args.allow_traffic_capture,
|
|
63
|
+
"allowServiceProbe": args.allow_service_probe,
|
|
64
|
+
"availableAdapters": [item["adapter"] for item in result["runs"] if item["available"]],
|
|
65
|
+
"recommendedAdapters": result["recommendedAdapters"],
|
|
66
|
+
"installationGuide": result["installationGuide"],
|
|
67
|
+
"notes": ["This file does not run scanners or grant per-command consent.", "Active probing and traffic capture still require an explicit tool call and user approval."],
|
|
68
|
+
}
|
|
69
|
+
atomic_write(profile_path, json.dumps(profile, ensure_ascii=False, indent=2) + "\n", replace=args.force)
|
|
70
|
+
result["profile"] = {"path": str(profile_path), "action": "replaced" if exists else "created"}
|
|
71
|
+
elif args.write:
|
|
72
|
+
result["profile"] = {"path": str(profile_path), "action": "unchanged", "reason": "already exists"}
|
|
73
|
+
else:
|
|
74
|
+
result["profile"] = {"path": str(profile_path), "action": "not_written"}
|
|
75
|
+
if args.json:
|
|
76
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
77
|
+
else:
|
|
78
|
+
host_os = result["host"].get("os", {})
|
|
79
|
+
print(f"Host: {host_os.get('pretty_name', host_os.get('system', 'unknown'))}")
|
|
80
|
+
for item in result["runs"]:
|
|
81
|
+
print(f"- {item['adapter']}: {item['executable']} ({'available' if item['available'] else 'missing'})")
|
|
82
|
+
for manager in result["installationGuide"]["packageManagers"]:
|
|
83
|
+
print(f"- Installation template ({manager['name']}): {manager['commandTemplate']}")
|
|
84
|
+
print(f"Profile: {result['profile']['action']} ({profile_path})")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a safe static Codex MCP stanza for one SSH alias.
|
|
3
|
+
|
|
4
|
+
The script prints TOML; it deliberately never edits ~/.codex/config.toml and
|
|
5
|
+
never contacts the remote host. The SSH alias must already exist locally.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_alias(value: str) -> str:
|
|
16
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", value):
|
|
17
|
+
raise ValueError("SSH alias must contain only letters, digits, dot, underscore, or hyphen")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_remote_script(value: str) -> str:
|
|
22
|
+
if not value.startswith("/") or ".." in value.split("/") or not re.fullmatch(r"/[A-Za-z0-9_./-]+", value):
|
|
23
|
+
raise ValueError("remote script must be an absolute path without '..' or whitespace")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render(alias: str, remote_script: str) -> str:
|
|
28
|
+
alias = validate_alias(alias)
|
|
29
|
+
remote_script = validate_remote_script(remote_script)
|
|
30
|
+
server_name = "mnogovid_system_" + re.sub(r"[^A-Za-z0-9_]", "_", alias)
|
|
31
|
+
arguments = ["-T", "-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "StrictHostKeyChecking=yes", alias, "/usr/bin/python3", remote_script]
|
|
32
|
+
return "\n".join([f"[mcp_servers.{server_name}]", 'command = "ssh"', "args = [" + ", ".join(json.dumps(item) for item in arguments) + "]", ""])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> int:
|
|
36
|
+
parser = argparse.ArgumentParser(description="Render a static SSH-stdio MCP config stanza.")
|
|
37
|
+
parser.add_argument("ssh_alias", help="existing safe SSH alias from ~/.ssh/config")
|
|
38
|
+
parser.add_argument("--remote-script", default="/opt/mnogovid-system-scanner/scripts/system_mcp.py")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
try:
|
|
41
|
+
sys.stdout.write(render(args.ssh_alias, args.remote_script))
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
parser.error(str(exc))
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|