virgilhq 0.3.0__tar.gz

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.
@@ -0,0 +1,65 @@
1
+ # Environment
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.egg-info/
11
+ .eggs/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .tox/
15
+ .coverage
16
+ .coverage.*
17
+ htmlcov/
18
+ *.pid
19
+ *.log
20
+ build/
21
+ dist/
22
+ venv/
23
+ .venv/
24
+ env/
25
+
26
+ # Node / Next.js
27
+ node_modules/
28
+ .next/
29
+ out/
30
+ *.tsbuildinfo
31
+ next-env.d.ts
32
+ .npm
33
+ .eslintcache
34
+ yarn-error.log
35
+ npm-debug.log*
36
+
37
+ # Editors / OS
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+ .DS_Store
43
+ Thumbs.db
44
+
45
+ # Audit runtime artifacts
46
+ /var/
47
+ audit-*.pdf
48
+ audit-*.md
49
+ audit-*.json
50
+ audit-*.sarif
51
+ audit-*.csv
52
+ audit-*.xlsx
53
+
54
+ # Local docker / state
55
+ docker/data/
56
+ *.sqlite
57
+ *.sqlite3
58
+
59
+ # Secrets — defense in depth (we never want a real .env or token file
60
+ # committed; .env.example is the only env file that should be tracked).
61
+ *.pem
62
+ *.key
63
+ *.p12
64
+ .github-token
65
+ secrets/
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: virgilhq
3
+ Version: 0.3.0
4
+ Summary: CLI for Virgil — self-hosted security audit with the triage built in. Real scanners + clustering + LLM priority queue + code-grounded chat. Installs as `virgil` on your PATH.
5
+ Project-URL: Homepage, https://virgilhq.app
6
+ Project-URL: Repository, https://github.com/ayaanmaliksgithub/virgil
7
+ Project-URL: Issues, https://github.com/ayaanmaliksgithub/virgil/issues
8
+ Project-URL: Documentation, https://github.com/ayaanmaliksgithub/virgil#readme
9
+ Project-URL: Changelog, https://github.com/ayaanmaliksgithub/virgil/releases
10
+ Author: Virgil contributors
11
+ License: Apache-2.0
12
+ Keywords: ai,audit,cli,code-audit,gitleaks,llm,sast,sca,security,self-hosted,semgrep,trivy,vulnerability
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: Apache Software License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Security
27
+ Classifier: Topic :: Software Development :: Quality Assurance
28
+ Classifier: Topic :: System :: Monitoring
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Requires-Dist: click>=8.1
32
+ Requires-Dist: requests>=2.31
33
+ Requires-Dist: rich>=13.7
34
+ Description-Content-Type: text/markdown
35
+
36
+ # virgil
37
+
38
+ > Terminal client for [Virgil](https://github.com/ayaanmaliksgithub/virgil) —
39
+ > self-hosted security audit with the triage built in. Real scanners +
40
+ > clustering + LLM priority queue + code-grounded chat.
41
+
42
+ The CLI is a thin shell over a running Virgil API. It bundles your working
43
+ directory, submits a scan, streams progress, and prints the ranked findings
44
+ with CI-friendly exit codes.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install virgilhq # or: pipx install virgilhq
50
+ ```
51
+
52
+ The PyPI package is `virgilhq` (the bare `virgil` name was already taken).
53
+ The command on your `$PATH` is still just `virgil`.
54
+
55
+ You'll also need a running Virgil instance. The standard self-hosted setup
56
+ is `docker compose up` from the [main repo](https://github.com/ayaanmaliksgithub/virgil)
57
+ — takes about a minute the first time.
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ # Scan and land on triage (counts → ranked clusters → next-steps hint).
63
+ virgil scan .
64
+
65
+ # Or land on a different surface after the scan finishes.
66
+ virgil scan . --show report # exec narrative
67
+ virgil scan . --show surface # languages / frameworks / IaC profile
68
+ virgil scan . --show ask_virgil # drop into the chat REPL pre-flighted
69
+
70
+ # Scan a GitHub URL instead of a local path.
71
+ virgil scan --url https://github.com/OWASP/NodeGoat
72
+
73
+ # PR mode — only flag findings on lines changed between two SHAs.
74
+ virgil scan . --base-sha abc1234 --head-sha def5678
75
+
76
+ # Don't wait for the scan to finish; print the audit ID and return.
77
+ virgil scan . --no-wait
78
+
79
+ # After a scan, drill in:
80
+ virgil clusters <audit-id> # every cluster, sorted by severity
81
+ virgil clusters <audit-id> --sev high # filter
82
+ virgil cluster <audit-id> <key> # one cluster in detail (prefix match ok)
83
+ virgil findings <audit-id> # raw findings table
84
+ virgil chat <audit-id> # interactive Q&A grounded in this audit
85
+ virgil chat <audit-id> -m "what's the worst finding?" # one-shot
86
+ virgil open <audit-id> # launch the web app on the triage tab
87
+ virgil open <audit-id> --page chat # …or chat / findings / report / attack-surface
88
+ virgil status <audit-id>
89
+
90
+ # Reports in any supported format.
91
+ virgil report <audit-id> --format md
92
+ virgil report <audit-id> --format sarif -o findings.sarif
93
+ virgil report <audit-id> --format json
94
+ virgil report <audit-id> --format pdf
95
+ ```
96
+
97
+ ## Config
98
+
99
+ Persistent settings live in `~/.config/virgil/config.json`:
100
+
101
+ ```bash
102
+ virgil config show
103
+ virgil config set api_url=https://virgil.example.com/api
104
+ virgil config set web_url=https://virgil.example.com
105
+ virgil config set default_fail_on=high
106
+ virgil config set default_post_scan_view=ask_virgil # triage | report | surface | ask_virgil
107
+ virgil config unset default_fail_on
108
+ virgil config path
109
+ ```
110
+
111
+ Resolution order for each setting: **env var → config file → built-in default.**
112
+
113
+ ## CI integration
114
+
115
+ ```bash
116
+ virgil scan . --fail-on critical # exits 1 on any Critical
117
+ virgil scan . --fail-on high # exits 1 on Critical or High
118
+ virgil scan . --fail-on never # always exits 0
119
+ ```
120
+
121
+ Exit codes:
122
+
123
+ | Code | Meaning |
124
+ | ---: | --- |
125
+ | `0` | scan finished, no findings exceeded `--fail-on` |
126
+ | `1` | scan finished, findings exceed the configured threshold |
127
+ | `2` | the audit itself failed (clone error, scanner crash, etc.) |
128
+ | `3` | could not reach the Virgil API |
129
+
130
+ ## Environment
131
+
132
+ | Variable | Default | What it does |
133
+ | --- | --- | --- |
134
+ | `VIRGIL_API` | `http://localhost:8000` | API base URL. |
135
+ | `VIRGIL_WEB` | `http://localhost:3000` | Web app base URL used by `virgil open`. |
136
+ | `VIRGIL_FAIL_ON` | `critical` | Default `--fail-on` threshold for `virgil scan`. |
137
+ | `VIRGIL_SHOW` | `triage` | Default `--show` surface after `virgil scan` (`triage` / `report` / `surface` / `ask_virgil`). |
138
+ | `VIRGIL_CONFIG_DIR` | `~/.config/virgil` | Override the config directory. |
139
+
140
+ ## What the output looks like
141
+
142
+ ```
143
+ $ virgil scan .
144
+ bundle /work/myrepo → zip → submit
145
+ ┌─ [ virgil ] ───────────────────────────────────────────────────────────────┐
146
+ │ audit_id c9b1… │
147
+ │ source scan.zip │
148
+ │ state succeeded phase=completed │
149
+ └────────────────────────────────────────────────────────────────────────────┘
150
+
151
+ CRIT HIGH MED LOW INFO KEV unreach
152
+ 2 7 14 6 3 1 19
153
+
154
+ ╭─ [ fix.this_week() · ranked ] ─────────────────────────────────────────────╮
155
+ │ #01 [ CRIT ] Hard-coded AWS access key in source ×3 │
156
+ │ Critical credential exposure with CISA-KEV-adjacent risk profile… │
157
+ │ #02 [ HIGH ] SQL injection via raw query helper ×12 │
158
+ │ 12 callsites share src/db/query.py — fix the helper, not callsites. │
159
+ ╰────────────────────────────────────────────────────────────────────────────╯
160
+ ```
161
+
162
+ ## License
163
+
164
+ Apache-2.0. See [LICENSE](https://github.com/ayaanmaliksgithub/virgil/blob/main/LICENSE).
@@ -0,0 +1,129 @@
1
+ # virgil
2
+
3
+ > Terminal client for [Virgil](https://github.com/ayaanmaliksgithub/virgil) —
4
+ > self-hosted security audit with the triage built in. Real scanners +
5
+ > clustering + LLM priority queue + code-grounded chat.
6
+
7
+ The CLI is a thin shell over a running Virgil API. It bundles your working
8
+ directory, submits a scan, streams progress, and prints the ranked findings
9
+ with CI-friendly exit codes.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install virgilhq # or: pipx install virgilhq
15
+ ```
16
+
17
+ The PyPI package is `virgilhq` (the bare `virgil` name was already taken).
18
+ The command on your `$PATH` is still just `virgil`.
19
+
20
+ You'll also need a running Virgil instance. The standard self-hosted setup
21
+ is `docker compose up` from the [main repo](https://github.com/ayaanmaliksgithub/virgil)
22
+ — takes about a minute the first time.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ # Scan and land on triage (counts → ranked clusters → next-steps hint).
28
+ virgil scan .
29
+
30
+ # Or land on a different surface after the scan finishes.
31
+ virgil scan . --show report # exec narrative
32
+ virgil scan . --show surface # languages / frameworks / IaC profile
33
+ virgil scan . --show ask_virgil # drop into the chat REPL pre-flighted
34
+
35
+ # Scan a GitHub URL instead of a local path.
36
+ virgil scan --url https://github.com/OWASP/NodeGoat
37
+
38
+ # PR mode — only flag findings on lines changed between two SHAs.
39
+ virgil scan . --base-sha abc1234 --head-sha def5678
40
+
41
+ # Don't wait for the scan to finish; print the audit ID and return.
42
+ virgil scan . --no-wait
43
+
44
+ # After a scan, drill in:
45
+ virgil clusters <audit-id> # every cluster, sorted by severity
46
+ virgil clusters <audit-id> --sev high # filter
47
+ virgil cluster <audit-id> <key> # one cluster in detail (prefix match ok)
48
+ virgil findings <audit-id> # raw findings table
49
+ virgil chat <audit-id> # interactive Q&A grounded in this audit
50
+ virgil chat <audit-id> -m "what's the worst finding?" # one-shot
51
+ virgil open <audit-id> # launch the web app on the triage tab
52
+ virgil open <audit-id> --page chat # …or chat / findings / report / attack-surface
53
+ virgil status <audit-id>
54
+
55
+ # Reports in any supported format.
56
+ virgil report <audit-id> --format md
57
+ virgil report <audit-id> --format sarif -o findings.sarif
58
+ virgil report <audit-id> --format json
59
+ virgil report <audit-id> --format pdf
60
+ ```
61
+
62
+ ## Config
63
+
64
+ Persistent settings live in `~/.config/virgil/config.json`:
65
+
66
+ ```bash
67
+ virgil config show
68
+ virgil config set api_url=https://virgil.example.com/api
69
+ virgil config set web_url=https://virgil.example.com
70
+ virgil config set default_fail_on=high
71
+ virgil config set default_post_scan_view=ask_virgil # triage | report | surface | ask_virgil
72
+ virgil config unset default_fail_on
73
+ virgil config path
74
+ ```
75
+
76
+ Resolution order for each setting: **env var → config file → built-in default.**
77
+
78
+ ## CI integration
79
+
80
+ ```bash
81
+ virgil scan . --fail-on critical # exits 1 on any Critical
82
+ virgil scan . --fail-on high # exits 1 on Critical or High
83
+ virgil scan . --fail-on never # always exits 0
84
+ ```
85
+
86
+ Exit codes:
87
+
88
+ | Code | Meaning |
89
+ | ---: | --- |
90
+ | `0` | scan finished, no findings exceeded `--fail-on` |
91
+ | `1` | scan finished, findings exceed the configured threshold |
92
+ | `2` | the audit itself failed (clone error, scanner crash, etc.) |
93
+ | `3` | could not reach the Virgil API |
94
+
95
+ ## Environment
96
+
97
+ | Variable | Default | What it does |
98
+ | --- | --- | --- |
99
+ | `VIRGIL_API` | `http://localhost:8000` | API base URL. |
100
+ | `VIRGIL_WEB` | `http://localhost:3000` | Web app base URL used by `virgil open`. |
101
+ | `VIRGIL_FAIL_ON` | `critical` | Default `--fail-on` threshold for `virgil scan`. |
102
+ | `VIRGIL_SHOW` | `triage` | Default `--show` surface after `virgil scan` (`triage` / `report` / `surface` / `ask_virgil`). |
103
+ | `VIRGIL_CONFIG_DIR` | `~/.config/virgil` | Override the config directory. |
104
+
105
+ ## What the output looks like
106
+
107
+ ```
108
+ $ virgil scan .
109
+ bundle /work/myrepo → zip → submit
110
+ ┌─ [ virgil ] ───────────────────────────────────────────────────────────────┐
111
+ │ audit_id c9b1… │
112
+ │ source scan.zip │
113
+ │ state succeeded phase=completed │
114
+ └────────────────────────────────────────────────────────────────────────────┘
115
+
116
+ CRIT HIGH MED LOW INFO KEV unreach
117
+ 2 7 14 6 3 1 19
118
+
119
+ ╭─ [ fix.this_week() · ranked ] ─────────────────────────────────────────────╮
120
+ │ #01 [ CRIT ] Hard-coded AWS access key in source ×3 │
121
+ │ Critical credential exposure with CISA-KEV-adjacent risk profile… │
122
+ │ #02 [ HIGH ] SQL injection via raw query helper ×12 │
123
+ │ 12 callsites share src/db/query.py — fix the helper, not callsites. │
124
+ ╰────────────────────────────────────────────────────────────────────────────╯
125
+ ```
126
+
127
+ ## License
128
+
129
+ Apache-2.0. See [LICENSE](https://github.com/ayaanmaliksgithub/virgil/blob/main/LICENSE).
@@ -0,0 +1,10 @@
1
+ """Virgil CLI.
2
+
3
+ Thin terminal client for Virgil — the security audit platform. Submits
4
+ scans, streams audit progress, prints findings, fetches reports — talks
5
+ to a running API instance (default `http://localhost:8000`). The CLI
6
+ never runs scanners itself; that work belongs in the sandboxed worker.
7
+
8
+ Distribution: `pip install virgil` (or via `pipx`).
9
+ """
10
+ __version__ = "0.3.0"
@@ -0,0 +1,196 @@
1
+ """HTTP client for the audit API.
2
+
3
+ Thin wrapper over `requests`. Centralizes the base URL + error handling so
4
+ the command modules stay readable. Does NOT carry retry logic — a CLI
5
+ session is interactive, and silent retries on top of `requests` calls
6
+ hide signal a user wants to see (network down, server hung).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Iterator
13
+
14
+ import requests
15
+
16
+ from cli import config
17
+
18
+
19
+ HTTP_TIMEOUT = 30
20
+ CHAT_TIMEOUT = 120 # LLM round-trips can sit well past the default.
21
+
22
+
23
+ class ApiError(RuntimeError):
24
+ def __init__(self, status: int, detail: str = ""):
25
+ super().__init__(f"API {status}: {detail}".rstrip(": "))
26
+ self.status = status
27
+ self.detail = detail
28
+
29
+
30
+ class ApiUnreachable(RuntimeError):
31
+ """Network failure — distinct from a 5xx so the CLI can suggest
32
+ `docker compose up` vs. "check API logs"."""
33
+
34
+
35
+ def _base_url() -> str:
36
+ return config.api_url().rstrip("/")
37
+
38
+
39
+ def _request(method: str, path: str, **kwargs) -> requests.Response:
40
+ url = _base_url() + path
41
+ kwargs.setdefault("timeout", HTTP_TIMEOUT)
42
+ try:
43
+ res = requests.request(method, url, **kwargs)
44
+ except requests.exceptions.ConnectionError as e:
45
+ raise ApiUnreachable(str(e)) from e
46
+ except requests.exceptions.Timeout as e:
47
+ raise ApiUnreachable(f"timeout after {HTTP_TIMEOUT}s") from e
48
+ if not res.ok:
49
+ raise ApiError(res.status_code, res.text[:500])
50
+ return res
51
+
52
+
53
+ def submit_zip(zip_path: Path) -> dict:
54
+ with zip_path.open("rb") as f:
55
+ files = {"file": (zip_path.name, f, "application/zip")}
56
+ res = _request("POST", "/v1/audits", files=files)
57
+ return res.json()
58
+
59
+
60
+ def submit_url(repo_url: str, *, base_sha: str | None = None, head_sha: str | None = None) -> dict:
61
+ body: dict = {"repo_url": repo_url}
62
+ if base_sha and head_sha:
63
+ body["base_sha"] = base_sha
64
+ body["head_sha"] = head_sha
65
+ res = _request("POST", "/v1/audits/json", json=body)
66
+ return res.json()
67
+
68
+
69
+ def get_audit(audit_id: str) -> dict:
70
+ return _request("GET", f"/v1/audits/{audit_id}").json()
71
+
72
+
73
+ def list_findings(audit_id: str, *, include_suppressed: bool = False) -> list[dict]:
74
+ params = {}
75
+ if include_suppressed:
76
+ params["include_suppressed"] = "true"
77
+ return _request("GET", f"/v1/audits/{audit_id}/findings", params=params).json()["items"]
78
+
79
+
80
+ def get_clusters(audit_id: str, *, include_unreachable: bool = False) -> dict:
81
+ params = {"include_unreachable": "true"} if include_unreachable else {}
82
+ return _request("GET", f"/v1/audits/{audit_id}/findings/clusters", params=params).json()
83
+
84
+
85
+ def get_finding(finding_id: str) -> dict:
86
+ return _request("GET", f"/v1/findings/{finding_id}").json()
87
+
88
+
89
+ def get_suggested_questions(audit_id: str) -> list[str]:
90
+ return _request("GET", f"/v1/audits/{audit_id}/chat/suggested").json().get("items", [])
91
+
92
+
93
+ def post_chat(audit_id: str, message: str, *, session_id: str | None = None) -> dict:
94
+ body: dict = {"message": message}
95
+ if session_id:
96
+ body["session_id"] = session_id
97
+ res = _request("POST", f"/v1/audits/{audit_id}/chat", json=body, timeout=CHAT_TIMEOUT)
98
+ return res.json()
99
+
100
+
101
+ def post_chat_stream(audit_id: str, message: str, *, session_id: str | None = None) -> Iterator[dict]:
102
+ """Stream chat tokens as SSE.
103
+
104
+ Yields decoded events: `{"event": "session"|"token"|"done"|"error", "data": ...}`
105
+ where `data` is the already-JSON-decoded payload from the SSE frame.
106
+
107
+ On `done` the caller should replace whatever tokens were rendered with the
108
+ final `message.content`: the safety validator runs at end-of-stream and
109
+ may refuse — in which case the visible tokens are stale.
110
+ """
111
+ body: dict = {"message": message}
112
+ if session_id:
113
+ body["session_id"] = session_id
114
+ url = _base_url() + f"/v1/audits/{audit_id}/chat/stream"
115
+ try:
116
+ with requests.post(url, json=body, stream=True, timeout=CHAT_TIMEOUT) as res:
117
+ if not res.ok:
118
+ raise ApiError(res.status_code, res.text[:500])
119
+ event = "message"
120
+ data_lines: list[str] = []
121
+ for raw in res.iter_lines(decode_unicode=True):
122
+ if raw is None:
123
+ continue
124
+ if raw == "":
125
+ if data_lines:
126
+ import json as _json
127
+ joined = "\n".join(data_lines)
128
+ try:
129
+ payload = _json.loads(joined)
130
+ except _json.JSONDecodeError:
131
+ payload = {"raw": joined}
132
+ yield {"event": event, "data": payload}
133
+ event, data_lines = "message", []
134
+ continue
135
+ if raw.startswith(":"):
136
+ continue
137
+ if raw.startswith("event:"):
138
+ event = raw[6:].strip()
139
+ elif raw.startswith("data:"):
140
+ data_lines.append(raw[5:].lstrip(" "))
141
+ except requests.exceptions.ConnectionError as e:
142
+ raise ApiUnreachable(str(e)) from e
143
+
144
+
145
+ def get_chat_session(audit_id: str, session_id: str) -> dict:
146
+ return _request("GET", f"/v1/audits/{audit_id}/chat/{session_id}").json()
147
+
148
+
149
+ def get_report(audit_id: str, *, view: str = "technical", format: str = "json") -> bytes:
150
+ res = _request(
151
+ "GET",
152
+ f"/v1/audits/{audit_id}/report",
153
+ params={"view": view, "format": format},
154
+ )
155
+ return res.content
156
+
157
+
158
+ def stream_events(audit_id: str) -> Iterator[dict]:
159
+ """Yield decoded SSE event dicts until the stream ends.
160
+
161
+ Each event looks like `{"event": "log"|"done", "phase": str, "message": str}`.
162
+ """
163
+ url = _base_url() + f"/v1/audits/{audit_id}/events"
164
+ try:
165
+ with requests.get(url, stream=True, timeout=None) as res:
166
+ if not res.ok:
167
+ raise ApiError(res.status_code, res.text[:500])
168
+ event = "message"
169
+ data_lines: list[str] = []
170
+ for raw in res.iter_lines(decode_unicode=True):
171
+ if raw is None:
172
+ continue
173
+ if raw == "":
174
+ if data_lines:
175
+ yield {"event": event, "data": "\n".join(data_lines)}
176
+ event, data_lines = "message", []
177
+ continue
178
+ if raw.startswith(":"):
179
+ continue
180
+ if raw.startswith("event:"):
181
+ event = raw[6:].strip()
182
+ elif raw.startswith("data:"):
183
+ data_lines.append(raw[5:].lstrip(" "))
184
+ except requests.exceptions.ConnectionError as e:
185
+ raise ApiUnreachable(str(e)) from e
186
+
187
+
188
+ def poll_until_terminal(audit_id: str, *, interval: float = 1.5, max_seconds: float = 1800) -> dict:
189
+ """Polling fallback when SSE is unavailable. Returns the final audit dict."""
190
+ deadline = time.time() + max_seconds
191
+ while time.time() < deadline:
192
+ audit = get_audit(audit_id)
193
+ if audit["state"] in ("succeeded", "failed"):
194
+ return audit
195
+ time.sleep(interval)
196
+ raise TimeoutError(f"audit {audit_id} did not finish within {max_seconds}s")
@@ -0,0 +1,77 @@
1
+ """Persisted CLI config — `~/.config/virgil/config.json`.
2
+
3
+ Precedence for each setting: env var > config file > built-in default.
4
+ JSON over TOML to avoid a stdlib gap on Python 3.10 (`tomllib` is 3.11+).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ CONFIG_DIR = Path(os.environ.get("VIRGIL_CONFIG_DIR", str(Path.home() / ".config" / "virgil")))
15
+ CONFIG_PATH = CONFIG_DIR / "config.json"
16
+
17
+ DEFAULT_API_URL = "http://localhost:8000"
18
+ DEFAULT_WEB_URL = "http://localhost:3000"
19
+ DEFAULT_FAIL_ON = "critical"
20
+ DEFAULT_POST_SCAN_VIEW = "triage"
21
+
22
+ # Keys we know about — used by `virgil config set` to reject typos before
23
+ # they end up silently ignored on disk.
24
+ KNOWN_KEYS = {"api_url", "web_url", "default_fail_on", "default_post_scan_view"}
25
+
26
+
27
+ def load() -> dict[str, Any]:
28
+ if not CONFIG_PATH.exists():
29
+ return {}
30
+ try:
31
+ return json.loads(CONFIG_PATH.read_text())
32
+ except (OSError, json.JSONDecodeError):
33
+ return {}
34
+
35
+
36
+ def save(data: dict[str, Any]) -> None:
37
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
38
+ CONFIG_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
39
+
40
+
41
+ def get(key: str, default: Any = None) -> Any:
42
+ return load().get(key, default)
43
+
44
+
45
+ def set_(key: str, value: str) -> None:
46
+ data = load()
47
+ data[key] = value
48
+ save(data)
49
+
50
+
51
+ def unset(key: str) -> bool:
52
+ data = load()
53
+ if key not in data:
54
+ return False
55
+ del data[key]
56
+ save(data)
57
+ return True
58
+
59
+
60
+ def api_url() -> str:
61
+ return os.environ.get("VIRGIL_API") or get("api_url") or DEFAULT_API_URL
62
+
63
+
64
+ def web_url() -> str:
65
+ return os.environ.get("VIRGIL_WEB") or get("web_url") or DEFAULT_WEB_URL
66
+
67
+
68
+ def default_fail_on() -> str:
69
+ return os.environ.get("VIRGIL_FAIL_ON") or get("default_fail_on") or DEFAULT_FAIL_ON
70
+
71
+
72
+ def default_post_scan_view() -> str:
73
+ return (
74
+ os.environ.get("VIRGIL_SHOW")
75
+ or get("default_post_scan_view")
76
+ or DEFAULT_POST_SCAN_VIEW
77
+ )