deepparallel 0.2.0__tar.gz → 0.3.1__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.
- {deepparallel-0.2.0 → deepparallel-0.3.1}/PKG-INFO +27 -3
- {deepparallel-0.2.0 → deepparallel-0.3.1}/README.md +26 -2
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/__init__.py +1 -1
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/agent.py +43 -3
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/backend.py +18 -2
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/cli.py +43 -1
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/config.py +1 -1
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/licensing.py +24 -0
- deepparallel-0.3.1/deepparallel/supply_chain.py +130 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/PKG-INFO +27 -3
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/SOURCES.txt +3 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/pyproject.toml +1 -1
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_agent.py +118 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend_chat.py +25 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_cli.py +34 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_config.py +1 -1
- deepparallel-0.3.1/tests/test_issuer_signer.py +54 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_licensing.py +24 -0
- deepparallel-0.3.1/tests/test_supply_chain.py +71 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/branding.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/fusion.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/registry.json +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/renderer.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/system_prompt.txt +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/__init__.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/shell.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/web.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/setup.cfg +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_branding.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_fusion.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_renderer.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_files.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_search.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_shell.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepparallel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
|
|
5
5
|
Author-email: Michael Crowe <michael@crowelogic.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -22,10 +22,22 @@ Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
|
22
22
|
|
|
23
23
|
# DeepParallel
|
|
24
24
|
|
|
25
|
-
A
|
|
25
|
+
A multi-model agentic coding CLI with cross-model Guardian review. Many models
|
|
26
|
+
check the work; you ship with a second opinion. Served via Crowe Logic.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
28
29
|
|
|
30
|
+
uv tool install deepparallel # recommended
|
|
31
|
+
# or
|
|
32
|
+
pipx install deepparallel --python python3.13
|
|
33
|
+
|
|
34
|
+
Then run `deepparallel` (or the short alias `dp`).
|
|
35
|
+
|
|
36
|
+
Note: on systems with a broken Homebrew Python 3.14, bare `pipx install` may
|
|
37
|
+
fail to find a usable interpreter - use `uv tool install` or pin `--python`.
|
|
38
|
+
|
|
39
|
+
### From source
|
|
40
|
+
|
|
29
41
|
uv venv
|
|
30
42
|
uv pip install -p .venv -e .
|
|
31
43
|
|
|
@@ -53,6 +65,18 @@ Set the backend env vars (a `.env` file in the working directory is loaded autom
|
|
|
53
65
|
deepparallel doctor # diagnose config + reachability
|
|
54
66
|
deepparallel run --no-tools "..." # plain chat, no tools
|
|
55
67
|
deepparallel run --yes "..." # auto-approve tool actions
|
|
68
|
+
deepparallel review <file|--diff> # cross-model review as a CI gate (Pro)
|
|
69
|
+
deepparallel audit <file> # supply-chain gate: catch hallucinated deps (Pro)
|
|
70
|
+
|
|
71
|
+
## Supply-chain gate
|
|
72
|
+
|
|
73
|
+
LLMs invent package names that don't exist — and attackers register those names
|
|
74
|
+
("slopsquatting"). `deepparallel audit <file>` extracts the dependencies a file
|
|
75
|
+
introduces (imports, requirements.txt, package.json) and checks each against the
|
|
76
|
+
real registry (PyPI / npm), flagging any the registry has never seen. Exit 0
|
|
77
|
+
clean, 2 if a likely-hallucinated package is found — so it gates a commit or PR.
|
|
78
|
+
The Guardian also runs this automatically on every edit: a hallucinated import
|
|
79
|
+
forces a confirmation even under `--yes`.
|
|
56
80
|
|
|
57
81
|
## Fusion (stacking models for stronger output)
|
|
58
82
|
|
|
@@ -107,7 +131,7 @@ timeboxed local subprocess.
|
|
|
107
131
|
| `DEEPPARALLEL_API_VERSION` | Azure API version | `2024-08-01-preview` |
|
|
108
132
|
| `FOUNDRY_BASE_URL` / `FOUNDRY_API_KEY` | control-plane transport | (required for foundry) |
|
|
109
133
|
| `DEEPPARALLEL_TEMPERATURE` | default sampling temperature | `0.4` |
|
|
110
|
-
| `DEEPPARALLEL_MAX_TOKENS` | response cap | `
|
|
134
|
+
| `DEEPPARALLEL_MAX_TOKENS` | response cap (large enough to write whole files) | `8192` |
|
|
111
135
|
| `DEEPPARALLEL_THINK` | surface reasoning stream | `0` (answer-only) |
|
|
112
136
|
| `DEEPPARALLEL_TOOLS` | enable agent tools | `1` (on) |
|
|
113
137
|
| `DEEPPARALLEL_AUTO_APPROVE` | auto-approve mutating tools | `0` (off) |
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# DeepParallel
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A multi-model agentic coding CLI with cross-model Guardian review. Many models
|
|
4
|
+
check the work; you ship with a second opinion. Served via Crowe Logic.
|
|
4
5
|
|
|
5
6
|
## Install
|
|
6
7
|
|
|
8
|
+
uv tool install deepparallel # recommended
|
|
9
|
+
# or
|
|
10
|
+
pipx install deepparallel --python python3.13
|
|
11
|
+
|
|
12
|
+
Then run `deepparallel` (or the short alias `dp`).
|
|
13
|
+
|
|
14
|
+
Note: on systems with a broken Homebrew Python 3.14, bare `pipx install` may
|
|
15
|
+
fail to find a usable interpreter - use `uv tool install` or pin `--python`.
|
|
16
|
+
|
|
17
|
+
### From source
|
|
18
|
+
|
|
7
19
|
uv venv
|
|
8
20
|
uv pip install -p .venv -e .
|
|
9
21
|
|
|
@@ -31,6 +43,18 @@ Set the backend env vars (a `.env` file in the working directory is loaded autom
|
|
|
31
43
|
deepparallel doctor # diagnose config + reachability
|
|
32
44
|
deepparallel run --no-tools "..." # plain chat, no tools
|
|
33
45
|
deepparallel run --yes "..." # auto-approve tool actions
|
|
46
|
+
deepparallel review <file|--diff> # cross-model review as a CI gate (Pro)
|
|
47
|
+
deepparallel audit <file> # supply-chain gate: catch hallucinated deps (Pro)
|
|
48
|
+
|
|
49
|
+
## Supply-chain gate
|
|
50
|
+
|
|
51
|
+
LLMs invent package names that don't exist — and attackers register those names
|
|
52
|
+
("slopsquatting"). `deepparallel audit <file>` extracts the dependencies a file
|
|
53
|
+
introduces (imports, requirements.txt, package.json) and checks each against the
|
|
54
|
+
real registry (PyPI / npm), flagging any the registry has never seen. Exit 0
|
|
55
|
+
clean, 2 if a likely-hallucinated package is found — so it gates a commit or PR.
|
|
56
|
+
The Guardian also runs this automatically on every edit: a hallucinated import
|
|
57
|
+
forces a confirmation even under `--yes`.
|
|
34
58
|
|
|
35
59
|
## Fusion (stacking models for stronger output)
|
|
36
60
|
|
|
@@ -85,7 +109,7 @@ timeboxed local subprocess.
|
|
|
85
109
|
| `DEEPPARALLEL_API_VERSION` | Azure API version | `2024-08-01-preview` |
|
|
86
110
|
| `FOUNDRY_BASE_URL` / `FOUNDRY_API_KEY` | control-plane transport | (required for foundry) |
|
|
87
111
|
| `DEEPPARALLEL_TEMPERATURE` | default sampling temperature | `0.4` |
|
|
88
|
-
| `DEEPPARALLEL_MAX_TOKENS` | response cap | `
|
|
112
|
+
| `DEEPPARALLEL_MAX_TOKENS` | response cap (large enough to write whole files) | `8192` |
|
|
89
113
|
| `DEEPPARALLEL_THINK` | surface reasoning stream | `0` (answer-only) |
|
|
90
114
|
| `DEEPPARALLEL_TOOLS` | enable agent tools | `1` (on) |
|
|
91
115
|
| `DEEPPARALLEL_AUTO_APPROVE` | auto-approve mutating tools | `0` (off) |
|
|
@@ -18,6 +18,17 @@ _MAX_TOOL_RESULT = 50_000
|
|
|
18
18
|
_GATED_PATH_TOOLS = ("write_file", "edit_file")
|
|
19
19
|
_EDIT_TOOLS = ("write_file", "edit_file", "ast_replace_symbol")
|
|
20
20
|
|
|
21
|
+
# Shown back to the model when a tool call arrives on a response that the API
|
|
22
|
+
# cut off at the token limit (finish_reason=length). The call's arguments, and
|
|
23
|
+
# any file body inside them, are incomplete, so we never apply it.
|
|
24
|
+
_TRUNCATION_HINT = (
|
|
25
|
+
"Your previous output was cut off by the response token limit "
|
|
26
|
+
"(finish_reason=length), so this tool call is incomplete and was NOT applied. "
|
|
27
|
+
"Write large files in smaller pieces: create the file with write_file using "
|
|
28
|
+
"the first portion, then append the rest with edit_file (or split it across "
|
|
29
|
+
"files). Do not emit an entire large file in one tool call."
|
|
30
|
+
)
|
|
31
|
+
|
|
21
32
|
|
|
22
33
|
def _parse_args(raw: str) -> dict:
|
|
23
34
|
if not raw:
|
|
@@ -191,9 +202,30 @@ def _guardian_verdict(guardian, name: str, args: dict) -> str | None:
|
|
|
191
202
|
return guardian_review(guardian, _guardian_review_content(name, args))
|
|
192
203
|
|
|
193
204
|
|
|
205
|
+
def _supply_chain_note(name: str, args: dict) -> str | None:
|
|
206
|
+
"""Best-effort: flag hallucinated/slopsquatted deps an edit introduces."""
|
|
207
|
+
content = args.get("new_source") or args.get("new_string") or args.get("content") or ""
|
|
208
|
+
path = args.get("file_path", "")
|
|
209
|
+
if not content or not path:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
from deepparallel import supply_chain
|
|
213
|
+
|
|
214
|
+
result = supply_chain.audit(content, path)
|
|
215
|
+
except Exception: # noqa: BLE001 - supply-chain check is best-effort
|
|
216
|
+
return None
|
|
217
|
+
if result["hallucinated"]:
|
|
218
|
+
return "not found on the registry (possible hallucination): " + ", ".join(
|
|
219
|
+
result["hallucinated"]
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
194
224
|
def _approved(name, args, interactive, auto_approve, renderer, guardian=None) -> bool:
|
|
195
225
|
forced = name in _GATED_PATH_TOOLS and _outside_cwd(args)
|
|
196
|
-
if
|
|
226
|
+
sc_note = _supply_chain_note(name, args) if name in _EDIT_TOOLS else None
|
|
227
|
+
# A hallucinated dependency overrides auto-approve: always surface it.
|
|
228
|
+
if auto_approve and not forced and not sc_note:
|
|
197
229
|
return True
|
|
198
230
|
if not interactive:
|
|
199
231
|
return False
|
|
@@ -202,6 +234,8 @@ def _approved(name, args, interactive, auto_approve, renderer, guardian=None) ->
|
|
|
202
234
|
verdict = _guardian_verdict(guardian, name, args)
|
|
203
235
|
if verdict:
|
|
204
236
|
detail = f"{detail}\n\nGuardian: {verdict}"
|
|
237
|
+
if sc_note:
|
|
238
|
+
detail = f"{detail}\n\nSupply-chain: {sc_note}"
|
|
205
239
|
return renderer.confirm(title, detail)
|
|
206
240
|
|
|
207
241
|
|
|
@@ -250,6 +284,7 @@ def run_agent(
|
|
|
250
284
|
msg = backend.chat(messages, schemas, settings.temperature, settings.max_tokens)
|
|
251
285
|
streamed = False
|
|
252
286
|
tool_calls = msg.get("tool_calls")
|
|
287
|
+
truncated = bool(msg.get("_truncated"))
|
|
253
288
|
if not tool_calls:
|
|
254
289
|
content = msg.get("content") or ""
|
|
255
290
|
messages.append({"role": "assistant", "content": content})
|
|
@@ -260,13 +295,18 @@ def run_agent(
|
|
|
260
295
|
messages.append(
|
|
261
296
|
{"role": "assistant", "content": msg.get("content"), "tool_calls": tool_calls}
|
|
262
297
|
)
|
|
263
|
-
|
|
298
|
+
# A length-truncated response cuts off the final tool call mid-arguments,
|
|
299
|
+
# so its file body is incomplete. Refuse it and tell the model to chunk.
|
|
300
|
+
last_idx = len(tool_calls) - 1
|
|
301
|
+
for i, tc in enumerate(tool_calls):
|
|
264
302
|
name = tc["function"]["name"]
|
|
265
303
|
args = _parse_args(tc["function"].get("arguments", ""))
|
|
266
304
|
meta = registry.get(name)
|
|
267
305
|
renderer.tool_start(name, _preview(args))
|
|
268
306
|
start = time.monotonic()
|
|
269
|
-
if
|
|
307
|
+
if truncated and i == last_idx:
|
|
308
|
+
result = json.dumps({"error": _TRUNCATION_HINT})
|
|
309
|
+
elif meta is None:
|
|
270
310
|
result = json.dumps({"error": f"unknown tool: {name}"})
|
|
271
311
|
elif "__parse_error__" in args:
|
|
272
312
|
result = json.dumps({"error": "invalid JSON arguments"})
|
|
@@ -56,6 +56,7 @@ def parse_sse_stream(lines: Iterator[str]):
|
|
|
56
56
|
"""
|
|
57
57
|
content_parts: list[str] = []
|
|
58
58
|
acc: dict[int, dict] = {}
|
|
59
|
+
finish_reason: str | None = None
|
|
59
60
|
for raw in lines:
|
|
60
61
|
line = raw.strip()
|
|
61
62
|
if not line or not line.startswith("data:"):
|
|
@@ -70,6 +71,8 @@ def parse_sse_stream(lines: Iterator[str]):
|
|
|
70
71
|
choices = obj.get("choices") or []
|
|
71
72
|
if not choices:
|
|
72
73
|
continue
|
|
74
|
+
if choices[0].get("finish_reason"):
|
|
75
|
+
finish_reason = choices[0]["finish_reason"]
|
|
73
76
|
delta = choices[0].get("delta") or {}
|
|
74
77
|
reasoning = delta.get("reasoning_content")
|
|
75
78
|
if reasoning:
|
|
@@ -95,6 +98,7 @@ def parse_sse_stream(lines: Iterator[str]):
|
|
|
95
98
|
"role": "assistant",
|
|
96
99
|
"content": "".join(content_parts) or None,
|
|
97
100
|
"tool_calls": tool_calls,
|
|
101
|
+
"_truncated": finish_reason == "length",
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
|
|
@@ -103,6 +107,18 @@ def _host(url: str) -> str:
|
|
|
103
107
|
return f"{p.scheme}://{p.netloc}" if p.netloc else url
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
def _message_from_choice(choice: dict) -> dict:
|
|
111
|
+
"""Extract the assistant message and flag output-token truncation.
|
|
112
|
+
|
|
113
|
+
`finish_reason == "length"` means the model was cut off mid-output. For a
|
|
114
|
+
tool call that carries file content, that means the arguments (and any file
|
|
115
|
+
body inside them) are incomplete and must not be applied blindly.
|
|
116
|
+
"""
|
|
117
|
+
msg = dict(choice.get("message") or {})
|
|
118
|
+
msg["_truncated"] = choice.get("finish_reason") == "length"
|
|
119
|
+
return msg
|
|
120
|
+
|
|
121
|
+
|
|
106
122
|
class Backend(Protocol):
|
|
107
123
|
label: str
|
|
108
124
|
|
|
@@ -172,7 +188,7 @@ class AzureBackend:
|
|
|
172
188
|
headers = {"api-key": self._api_key, "content-type": "application/json"}
|
|
173
189
|
r = httpx.post(self._url, json=payload, headers=headers, timeout=_STREAM_TIMEOUT)
|
|
174
190
|
r.raise_for_status()
|
|
175
|
-
return r.json()["choices"][0]
|
|
191
|
+
return _message_from_choice(r.json()["choices"][0])
|
|
176
192
|
|
|
177
193
|
def stream_chat_tools(self, messages, tools, temperature, max_tokens):
|
|
178
194
|
payload = {
|
|
@@ -246,7 +262,7 @@ class FoundryBackend:
|
|
|
246
262
|
}
|
|
247
263
|
r = httpx.post(self._url, json=payload, headers=headers, timeout=_STREAM_TIMEOUT)
|
|
248
264
|
r.raise_for_status()
|
|
249
|
-
return r.json()["choices"][0]
|
|
265
|
+
return _message_from_choice(r.json()["choices"][0])
|
|
250
266
|
|
|
251
267
|
def stream_chat_tools(self, messages, tools, temperature, max_tokens):
|
|
252
268
|
payload = {
|
|
@@ -43,7 +43,7 @@ from deepparallel.config import (
|
|
|
43
43
|
missing_required,
|
|
44
44
|
resolve_settings,
|
|
45
45
|
)
|
|
46
|
-
from deepparallel import licensing
|
|
46
|
+
from deepparallel import licensing, supply_chain
|
|
47
47
|
from deepparallel.fusion import (
|
|
48
48
|
EscalationBackend,
|
|
49
49
|
ReasonAnswerBackend,
|
|
@@ -504,6 +504,48 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
|
|
|
504
504
|
sys.exit(verdict_exit_code(verdict))
|
|
505
505
|
|
|
506
506
|
|
|
507
|
+
@main.command()
|
|
508
|
+
@click.argument("path", required=True)
|
|
509
|
+
@click.pass_context
|
|
510
|
+
def audit(ctx: click.Context, path: str) -> None:
|
|
511
|
+
"""Supply-chain gate: check a file's dependencies against the real registry.
|
|
512
|
+
|
|
513
|
+
Flags hallucinated / slopsquatted packages (names PyPI or npm has never
|
|
514
|
+
seen) in source files and manifests. Exit code: 0 clean, 2 if a likely
|
|
515
|
+
hallucinated dependency is found - so it can gate a commit or PR. Paid (Pro+).
|
|
516
|
+
"""
|
|
517
|
+
ok, msg = licensing.check_feature("audit")
|
|
518
|
+
if not ok:
|
|
519
|
+
branding.error(msg)
|
|
520
|
+
sys.exit(3)
|
|
521
|
+
try:
|
|
522
|
+
content = Path(path).expanduser().read_text(encoding="utf-8")
|
|
523
|
+
except OSError as e:
|
|
524
|
+
branding.error(f"cannot read {path}: {e}")
|
|
525
|
+
sys.exit(3)
|
|
526
|
+
result = supply_chain.audit(content, path)
|
|
527
|
+
findings = result["findings"]
|
|
528
|
+
if not findings:
|
|
529
|
+
console.print(f"[{branding.DIM}]no external dependencies found in {path}[/]")
|
|
530
|
+
sys.exit(0)
|
|
531
|
+
for f in findings:
|
|
532
|
+
glyph = {"ok": branding.CHECK, "missing": branding.CROSS}.get(f["status"], "?")
|
|
533
|
+
color = {"ok": branding.GREEN_HEX, "missing": branding.RED_HEX}.get(
|
|
534
|
+
f["status"], branding.DIM
|
|
535
|
+
)
|
|
536
|
+
console.print(
|
|
537
|
+
f" [{color}]{glyph}[/] {f['name']} [{branding.DIM}]({f['ecosystem']}) · {f['status']}[/]"
|
|
538
|
+
)
|
|
539
|
+
if result["hallucinated"]:
|
|
540
|
+
console.print(
|
|
541
|
+
f"[bold {branding.RED_HEX}]{branding.CROSS} {len(result['hallucinated'])} "
|
|
542
|
+
f"possibly hallucinated:[/] {', '.join(result['hallucinated'])}"
|
|
543
|
+
)
|
|
544
|
+
sys.exit(2)
|
|
545
|
+
console.print(f"[{branding.GREEN_HEX}]{branding.CHECK} all dependencies verified[/]")
|
|
546
|
+
sys.exit(0)
|
|
547
|
+
|
|
548
|
+
|
|
507
549
|
@main.command(name="tools")
|
|
508
550
|
def tools_cmd() -> None:
|
|
509
551
|
"""List the agent tools available to DeepParallel."""
|
|
@@ -101,7 +101,7 @@ def resolve_settings() -> Settings:
|
|
|
101
101
|
foundry_api_key=os.environ.get("FOUNDRY_API_KEY"),
|
|
102
102
|
foundry_model=os.environ.get("DEEPPARALLEL_FOUNDRY_MODEL", "DeepSeek-V3-1"),
|
|
103
103
|
temperature=_float_env("DEEPPARALLEL_TEMPERATURE", 0.4),
|
|
104
|
-
max_tokens=_int_env("DEEPPARALLEL_MAX_TOKENS",
|
|
104
|
+
max_tokens=_int_env("DEEPPARALLEL_MAX_TOKENS", 8192),
|
|
105
105
|
show_thinking=_bool_env("DEEPPARALLEL_THINK", False),
|
|
106
106
|
tools_enabled=_bool_env("DEEPPARALLEL_TOOLS", True),
|
|
107
107
|
auto_approve=_bool_env("DEEPPARALLEL_AUTO_APPROVE", False),
|
|
@@ -70,6 +70,30 @@ def verify_token(token: str, pubkey_b64: str | None = None) -> dict | None:
|
|
|
70
70
|
return payload
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def sign_token(payload: dict, private_key_b64: str) -> str:
|
|
74
|
+
"""Sign a license payload into a `body.signature` token (issuer-side).
|
|
75
|
+
|
|
76
|
+
Requires the private issuance key; verify_token() checks it with the public
|
|
77
|
+
key. Used by the license-issuer (Stripe webhook) and the admin CLI.
|
|
78
|
+
"""
|
|
79
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
80
|
+
|
|
81
|
+
priv = Ed25519PrivateKey.from_private_bytes(_b64url_decode_std(private_key_b64))
|
|
82
|
+
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
|
83
|
+
sig = base64.urlsafe_b64encode(priv.sign(body.encode())).decode().rstrip("=")
|
|
84
|
+
return f"{body}.{sig}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _b64url_decode_std(s: str) -> bytes:
|
|
88
|
+
"""Decode standard OR url-safe base64 (keys are emitted as standard b64)."""
|
|
89
|
+
s = s.strip()
|
|
90
|
+
pad = "=" * (-len(s) % 4)
|
|
91
|
+
try:
|
|
92
|
+
return base64.b64decode(s + pad)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
95
|
+
|
|
96
|
+
|
|
73
97
|
def _license_file_token() -> str | None:
|
|
74
98
|
path = Path(
|
|
75
99
|
os.environ.get("DEEPPARALLEL_LICENSE_FILE", "~/.config/deepparallel/license")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Supply-chain gate: catch hallucinated / slopsquatted dependencies.
|
|
2
|
+
|
|
3
|
+
LLMs invent package names that don't exist (and attackers register those names).
|
|
4
|
+
This extracts the dependencies a change introduces and checks each against the
|
|
5
|
+
real registry (PyPI / npm). A name the registry has never seen is a likely
|
|
6
|
+
hallucination - blocking it before it lands is the Guardian's supply-chain job.
|
|
7
|
+
|
|
8
|
+
Network is best-effort: a lookup failure yields "unknown", never a false
|
|
9
|
+
"missing", so an offline run never blocks a legitimate package.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
_TIMEOUT = 6.0
|
|
21
|
+
_STDLIB = set(getattr(sys, "stdlib_module_names", set())) | {
|
|
22
|
+
"__future__",
|
|
23
|
+
"typing_extensions",
|
|
24
|
+
}
|
|
25
|
+
# Common import-name -> PyPI distribution-name mismatches, so legitimate
|
|
26
|
+
# imports are not flagged as hallucinations.
|
|
27
|
+
_PYPI_ALIASES = {
|
|
28
|
+
"yaml": "pyyaml",
|
|
29
|
+
"cv2": "opencv-python",
|
|
30
|
+
"PIL": "pillow",
|
|
31
|
+
"bs4": "beautifulsoup4",
|
|
32
|
+
"sklearn": "scikit-learn",
|
|
33
|
+
"dotenv": "python-dotenv",
|
|
34
|
+
"jwt": "pyjwt",
|
|
35
|
+
"dateutil": "python-dateutil",
|
|
36
|
+
"git": "gitpython",
|
|
37
|
+
"serial": "pyserial",
|
|
38
|
+
"OpenSSL": "pyopenssl",
|
|
39
|
+
"Crypto": "pycryptodome",
|
|
40
|
+
"google": "google-api-python-client",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_IMPORT_RE = re.compile(r"^\s*(?:import\s+([a-zA-Z0-9_.]+)|from\s+([a-zA-Z0-9_.]+)\s+import)", re.M)
|
|
44
|
+
_REQ_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_dependencies(content: str, filename: str) -> list[dict]:
|
|
48
|
+
"""Return [{name, ecosystem, raw}] introduced by this content."""
|
|
49
|
+
fn = filename.rsplit("/", 1)[-1].lower()
|
|
50
|
+
if fn == "package.json":
|
|
51
|
+
return _from_package_json(content)
|
|
52
|
+
if fn in ("requirements.txt",) or fn.startswith("requirements"):
|
|
53
|
+
return _from_requirements(content)
|
|
54
|
+
if fn.endswith(".py"):
|
|
55
|
+
return _from_python(content)
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _from_python(content: str) -> list[dict]:
|
|
60
|
+
out, seen = [], set()
|
|
61
|
+
for imp, frm in _IMPORT_RE.findall(content):
|
|
62
|
+
mod = (imp or frm).split(".")[0]
|
|
63
|
+
if not mod or mod.startswith("_") or mod in _STDLIB or mod in seen:
|
|
64
|
+
continue
|
|
65
|
+
seen.add(mod)
|
|
66
|
+
dist = _PYPI_ALIASES.get(mod, mod)
|
|
67
|
+
out.append({"name": dist, "ecosystem": "pypi", "raw": mod})
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _from_requirements(content: str) -> list[dict]:
|
|
72
|
+
out, seen = [], set()
|
|
73
|
+
for line in content.splitlines():
|
|
74
|
+
s = line.strip()
|
|
75
|
+
if not s or s.startswith(("#", "-", "git+", "http")):
|
|
76
|
+
continue
|
|
77
|
+
m = _REQ_RE.match(s)
|
|
78
|
+
if not m:
|
|
79
|
+
continue
|
|
80
|
+
name = m.group(1).lower()
|
|
81
|
+
if name not in seen:
|
|
82
|
+
seen.add(name)
|
|
83
|
+
out.append({"name": name, "ecosystem": "pypi", "raw": s})
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _from_package_json(content: str) -> list[dict]:
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(content)
|
|
90
|
+
except (ValueError, json.JSONDecodeError):
|
|
91
|
+
return []
|
|
92
|
+
out, seen = [], set()
|
|
93
|
+
for key in ("dependencies", "devDependencies", "peerDependencies"):
|
|
94
|
+
for name in data.get(key) or {}:
|
|
95
|
+
if name not in seen:
|
|
96
|
+
seen.add(name)
|
|
97
|
+
out.append({"name": name, "ecosystem": "npm", "raw": name})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_exists(name: str, ecosystem: str) -> bool | None:
|
|
102
|
+
"""True if the package exists in its registry, False if not, None on failure."""
|
|
103
|
+
if ecosystem == "pypi":
|
|
104
|
+
url = f"https://pypi.org/pypi/{name}/json"
|
|
105
|
+
elif ecosystem == "npm":
|
|
106
|
+
url = f"https://registry.npmjs.org/{name}"
|
|
107
|
+
else:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
r = httpx.get(url, timeout=_TIMEOUT, follow_redirects=True)
|
|
111
|
+
except Exception: # noqa: BLE001 - network failure is "unknown", not "missing"
|
|
112
|
+
return None
|
|
113
|
+
if r.status_code == 200:
|
|
114
|
+
return True
|
|
115
|
+
if r.status_code == 404:
|
|
116
|
+
return False
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def audit(content: str, filename: str) -> dict:
|
|
121
|
+
"""Audit a change's dependencies. Returns findings + the hallucinated list."""
|
|
122
|
+
findings = []
|
|
123
|
+
hallucinated = []
|
|
124
|
+
for dep in extract_dependencies(content, filename):
|
|
125
|
+
exists = check_exists(dep["name"], dep["ecosystem"])
|
|
126
|
+
status = "ok" if exists is True else "missing" if exists is False else "unknown"
|
|
127
|
+
findings.append({"name": dep["name"], "ecosystem": dep["ecosystem"], "status": status})
|
|
128
|
+
if status == "missing":
|
|
129
|
+
hallucinated.append(dep["name"])
|
|
130
|
+
return {"findings": findings, "hallucinated": hallucinated}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepparallel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
|
|
5
5
|
Author-email: Michael Crowe <michael@crowelogic.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -22,10 +22,22 @@ Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
|
22
22
|
|
|
23
23
|
# DeepParallel
|
|
24
24
|
|
|
25
|
-
A
|
|
25
|
+
A multi-model agentic coding CLI with cross-model Guardian review. Many models
|
|
26
|
+
check the work; you ship with a second opinion. Served via Crowe Logic.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
28
29
|
|
|
30
|
+
uv tool install deepparallel # recommended
|
|
31
|
+
# or
|
|
32
|
+
pipx install deepparallel --python python3.13
|
|
33
|
+
|
|
34
|
+
Then run `deepparallel` (or the short alias `dp`).
|
|
35
|
+
|
|
36
|
+
Note: on systems with a broken Homebrew Python 3.14, bare `pipx install` may
|
|
37
|
+
fail to find a usable interpreter - use `uv tool install` or pin `--python`.
|
|
38
|
+
|
|
39
|
+
### From source
|
|
40
|
+
|
|
29
41
|
uv venv
|
|
30
42
|
uv pip install -p .venv -e .
|
|
31
43
|
|
|
@@ -53,6 +65,18 @@ Set the backend env vars (a `.env` file in the working directory is loaded autom
|
|
|
53
65
|
deepparallel doctor # diagnose config + reachability
|
|
54
66
|
deepparallel run --no-tools "..." # plain chat, no tools
|
|
55
67
|
deepparallel run --yes "..." # auto-approve tool actions
|
|
68
|
+
deepparallel review <file|--diff> # cross-model review as a CI gate (Pro)
|
|
69
|
+
deepparallel audit <file> # supply-chain gate: catch hallucinated deps (Pro)
|
|
70
|
+
|
|
71
|
+
## Supply-chain gate
|
|
72
|
+
|
|
73
|
+
LLMs invent package names that don't exist — and attackers register those names
|
|
74
|
+
("slopsquatting"). `deepparallel audit <file>` extracts the dependencies a file
|
|
75
|
+
introduces (imports, requirements.txt, package.json) and checks each against the
|
|
76
|
+
real registry (PyPI / npm), flagging any the registry has never seen. Exit 0
|
|
77
|
+
clean, 2 if a likely-hallucinated package is found — so it gates a commit or PR.
|
|
78
|
+
The Guardian also runs this automatically on every edit: a hallucinated import
|
|
79
|
+
forces a confirmation even under `--yes`.
|
|
56
80
|
|
|
57
81
|
## Fusion (stacking models for stronger output)
|
|
58
82
|
|
|
@@ -107,7 +131,7 @@ timeboxed local subprocess.
|
|
|
107
131
|
| `DEEPPARALLEL_API_VERSION` | Azure API version | `2024-08-01-preview` |
|
|
108
132
|
| `FOUNDRY_BASE_URL` / `FOUNDRY_API_KEY` | control-plane transport | (required for foundry) |
|
|
109
133
|
| `DEEPPARALLEL_TEMPERATURE` | default sampling temperature | `0.4` |
|
|
110
|
-
| `DEEPPARALLEL_MAX_TOKENS` | response cap | `
|
|
134
|
+
| `DEEPPARALLEL_MAX_TOKENS` | response cap (large enough to write whole files) | `8192` |
|
|
111
135
|
| `DEEPPARALLEL_THINK` | surface reasoning stream | `0` (answer-only) |
|
|
112
136
|
| `DEEPPARALLEL_TOOLS` | enable agent tools | `1` (on) |
|
|
113
137
|
| `DEEPPARALLEL_AUTO_APPROVE` | auto-approve mutating tools | `0` (off) |
|
|
@@ -10,6 +10,7 @@ deepparallel/fusion.py
|
|
|
10
10
|
deepparallel/licensing.py
|
|
11
11
|
deepparallel/registry.json
|
|
12
12
|
deepparallel/renderer.py
|
|
13
|
+
deepparallel/supply_chain.py
|
|
13
14
|
deepparallel/system_prompt.txt
|
|
14
15
|
deepparallel.egg-info/PKG-INFO
|
|
15
16
|
deepparallel.egg-info/SOURCES.txt
|
|
@@ -35,8 +36,10 @@ tests/test_branding.py
|
|
|
35
36
|
tests/test_cli.py
|
|
36
37
|
tests/test_config.py
|
|
37
38
|
tests/test_fusion.py
|
|
39
|
+
tests/test_issuer_signer.py
|
|
38
40
|
tests/test_licensing.py
|
|
39
41
|
tests/test_renderer.py
|
|
42
|
+
tests/test_supply_chain.py
|
|
40
43
|
tests/test_tool_registry.py
|
|
41
44
|
tests/test_tools_codeast.py
|
|
42
45
|
tests/test_tools_edit.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deepparallel"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -367,6 +367,54 @@ def test_no_guardian_still_gates():
|
|
|
367
367
|
assert len(r.confirmed) == 1 # still prompted, just no Guardian line
|
|
368
368
|
|
|
369
369
|
|
|
370
|
+
def test_supply_chain_note_on_hallucinated_dep(monkeypatch):
|
|
371
|
+
from deepparallel import supply_chain
|
|
372
|
+
|
|
373
|
+
monkeypatch.setattr(supply_chain, "check_exists", lambda n, e: n != "fakepkg_xyz")
|
|
374
|
+
reg = _reg_with_edit_and_shell()
|
|
375
|
+
backend = FakeBackend(
|
|
376
|
+
[
|
|
377
|
+
_toolcall("c1", "write_file", '{"file_path": "a.py", "content": "import fakepkg_xyz"}'),
|
|
378
|
+
_final("done"),
|
|
379
|
+
]
|
|
380
|
+
)
|
|
381
|
+
r = FakeRenderer(confirm_value=True)
|
|
382
|
+
run_agent(
|
|
383
|
+
backend,
|
|
384
|
+
reg,
|
|
385
|
+
[{"role": "user", "content": "go"}],
|
|
386
|
+
_settings(),
|
|
387
|
+
r,
|
|
388
|
+
interactive=True,
|
|
389
|
+
auto_approve=False,
|
|
390
|
+
)
|
|
391
|
+
assert any("Supply-chain" in detail and "fakepkg_xyz" in detail for _t, detail in r.confirmed)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_supply_chain_overrides_auto_approve(monkeypatch):
|
|
395
|
+
from deepparallel import supply_chain
|
|
396
|
+
|
|
397
|
+
monkeypatch.setattr(supply_chain, "check_exists", lambda n, e: n != "fakepkg_xyz")
|
|
398
|
+
reg = _reg_with_edit_and_shell()
|
|
399
|
+
backend = FakeBackend(
|
|
400
|
+
[
|
|
401
|
+
_toolcall("c1", "write_file", '{"file_path": "a.py", "content": "import fakepkg_xyz"}'),
|
|
402
|
+
_final("done"),
|
|
403
|
+
]
|
|
404
|
+
)
|
|
405
|
+
r = FakeRenderer(confirm_value=False) # user declines once shown
|
|
406
|
+
run_agent(
|
|
407
|
+
backend,
|
|
408
|
+
reg,
|
|
409
|
+
[{"role": "user", "content": "go"}],
|
|
410
|
+
_settings(),
|
|
411
|
+
r,
|
|
412
|
+
interactive=True,
|
|
413
|
+
auto_approve=True, # auto-approve ON, but hallucination forces a prompt
|
|
414
|
+
)
|
|
415
|
+
assert len(r.confirmed) == 1
|
|
416
|
+
|
|
417
|
+
|
|
370
418
|
def test_bad_json_arguments_do_not_crash():
|
|
371
419
|
reg = _reg_with_safe_tool()
|
|
372
420
|
backend = FakeBackend([_toolcall("c1", "ping", "{not json"), _final("recovered")])
|
|
@@ -383,3 +431,73 @@ def test_bad_json_arguments_do_not_crash():
|
|
|
383
431
|
assert out == "recovered"
|
|
384
432
|
tool_msgs = [m for m in backend.calls[1] if m.get("role") == "tool"]
|
|
385
433
|
assert "error" in tool_msgs[0]["content"].lower()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_truncated_tool_call_is_not_applied():
|
|
437
|
+
"""A length-truncated write_file (incomplete file body) must be refused,
|
|
438
|
+
not written, and the model told to chunk the work."""
|
|
439
|
+
written = []
|
|
440
|
+
|
|
441
|
+
reg = ToolRegistry()
|
|
442
|
+
|
|
443
|
+
@reg.tool(dangerous=True)
|
|
444
|
+
def write_file(file_path: str, content: str = "") -> str:
|
|
445
|
+
"""Write a file."""
|
|
446
|
+
written.append((file_path, content))
|
|
447
|
+
return '{"success": true}'
|
|
448
|
+
|
|
449
|
+
truncated = _toolcall("c1", "write_file", '{"file_path": "big.py", "content": "import os')
|
|
450
|
+
truncated["_truncated"] = True
|
|
451
|
+
backend = FakeBackend([truncated, _final("split it up")])
|
|
452
|
+
r = FakeRenderer(confirm_value=True)
|
|
453
|
+
out = run_agent(
|
|
454
|
+
backend,
|
|
455
|
+
reg,
|
|
456
|
+
[{"role": "user", "content": "write a huge file"}],
|
|
457
|
+
_settings(),
|
|
458
|
+
r,
|
|
459
|
+
interactive=True,
|
|
460
|
+
auto_approve=True, # even auto-approved, a cut-off call is refused
|
|
461
|
+
)
|
|
462
|
+
assert out == "split it up"
|
|
463
|
+
assert written == [] # the half file was never written
|
|
464
|
+
assert r.confirmed == [] # not even surfaced for approval; just refused
|
|
465
|
+
tool_msgs = [m for m in backend.calls[1] if m.get("role") == "tool"]
|
|
466
|
+
assert "cut off" in tool_msgs[0]["content"].lower()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_non_final_tool_calls_still_run_when_truncated():
|
|
470
|
+
"""Only the last tool call is cut off; earlier complete calls still run."""
|
|
471
|
+
reg = _reg_with_safe_tool()
|
|
472
|
+
two_calls = {
|
|
473
|
+
"role": "assistant",
|
|
474
|
+
"content": None,
|
|
475
|
+
"_truncated": True,
|
|
476
|
+
"tool_calls": [
|
|
477
|
+
{
|
|
478
|
+
"id": "c1",
|
|
479
|
+
"type": "function",
|
|
480
|
+
"function": {"name": "ping", "arguments": '{"x": "ok"}'},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"id": "c2",
|
|
484
|
+
"type": "function",
|
|
485
|
+
"function": {"name": "ping", "arguments": '{"x": "cut'},
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
}
|
|
489
|
+
backend = FakeBackend([two_calls, _final("done")])
|
|
490
|
+
r = FakeRenderer()
|
|
491
|
+
out = run_agent(
|
|
492
|
+
backend,
|
|
493
|
+
reg,
|
|
494
|
+
[{"role": "user", "content": "go"}],
|
|
495
|
+
_settings(),
|
|
496
|
+
r,
|
|
497
|
+
interactive=True,
|
|
498
|
+
auto_approve=False,
|
|
499
|
+
)
|
|
500
|
+
assert out == "done"
|
|
501
|
+
tool_msgs = [m for m in backend.calls[1] if m.get("role") == "tool"]
|
|
502
|
+
assert tool_msgs[0]["content"] == "pong:ok" # first call ran
|
|
503
|
+
assert "cut off" in tool_msgs[1]["content"].lower() # last call refused
|
|
@@ -111,3 +111,28 @@ def test_foundry_chat_uses_bearer_and_model(monkeypatch):
|
|
|
111
111
|
assert captured["url"].endswith("/v1/chat/completions")
|
|
112
112
|
assert captured["headers"]["authorization"] == "Bearer tok"
|
|
113
113
|
assert captured["json"]["model"] == "DeepSeek-V3-1"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_TRUNCATED_MSG = {
|
|
117
|
+
"choices": [
|
|
118
|
+
{
|
|
119
|
+
"finish_reason": "length",
|
|
120
|
+
"message": {"role": "assistant", "content": "partial output that got cut"},
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_chat_flags_length_truncation(monkeypatch):
|
|
127
|
+
monkeypatch.setattr(httpx, "post", lambda url, **kw: _FakeResp(_TRUNCATED_MSG))
|
|
128
|
+
be = AzureBackend("https://x.example.com", "secret", "DeepSeek-V4-Pro", "2024-08-01-preview")
|
|
129
|
+
msg = be.chat([{"role": "user", "content": "hi"}], [], 0.4, 2048)
|
|
130
|
+
assert msg["_truncated"] is True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_chat_not_truncated_on_normal_stop(monkeypatch):
|
|
134
|
+
stop_msg = {"choices": [{"finish_reason": "stop", "message": {"content": "done"}}]}
|
|
135
|
+
monkeypatch.setattr(httpx, "post", lambda url, **kw: _FakeResp(stop_msg))
|
|
136
|
+
be = AzureBackend("https://x.example.com", "secret", "DeepSeek-V4-Pro", "2024-08-01-preview")
|
|
137
|
+
msg = be.chat([{"role": "user", "content": "hi"}], [], 0.4, 2048)
|
|
138
|
+
assert msg["_truncated"] is False
|
|
@@ -181,6 +181,40 @@ def test_review_gated_on_free(monkeypatch, tmp_path):
|
|
|
181
181
|
assert "pro" in result.output.lower() or "upgrade" in result.output.lower()
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
def test_audit_clean_exits_zero(monkeypatch, tmp_path):
|
|
185
|
+
_set_azure_env(monkeypatch)
|
|
186
|
+
from deepparallel import supply_chain
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(supply_chain, "check_exists", lambda n, e: True)
|
|
189
|
+
f = tmp_path / "app.py"
|
|
190
|
+
f.write_text("import requests\nimport flask\n")
|
|
191
|
+
result = CliRunner().invoke(main, ["audit", str(f)])
|
|
192
|
+
assert result.exit_code == 0
|
|
193
|
+
assert "verified" in result.output
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_audit_hallucinated_exits_two(monkeypatch, tmp_path):
|
|
197
|
+
_set_azure_env(monkeypatch)
|
|
198
|
+
from deepparallel import supply_chain
|
|
199
|
+
|
|
200
|
+
monkeypatch.setattr(supply_chain, "check_exists", lambda n, e: n != "totally_fake_pkg")
|
|
201
|
+
f = tmp_path / "app.py"
|
|
202
|
+
f.write_text("import requests\nimport totally_fake_pkg\n")
|
|
203
|
+
result = CliRunner().invoke(main, ["audit", str(f)])
|
|
204
|
+
assert result.exit_code == 2
|
|
205
|
+
assert "hallucinated" in result.output.lower()
|
|
206
|
+
assert "totally_fake_pkg" in result.output
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_audit_gated_on_free(monkeypatch, tmp_path):
|
|
210
|
+
_set_azure_env(monkeypatch)
|
|
211
|
+
monkeypatch.setattr(licensing, "resolve_tier", lambda: licensing.Tier.FREE)
|
|
212
|
+
f = tmp_path / "app.py"
|
|
213
|
+
f.write_text("import requests\n")
|
|
214
|
+
result = CliRunner().invoke(main, ["audit", str(f)])
|
|
215
|
+
assert result.exit_code == 3
|
|
216
|
+
|
|
217
|
+
|
|
184
218
|
def test_free_tier_gates_paid_features(monkeypatch):
|
|
185
219
|
_set_azure_env(monkeypatch)
|
|
186
220
|
monkeypatch.setattr(licensing, "resolve_tier", lambda: licensing.Tier.FREE)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The issuer function signs tokens the CLI must accept. Guard that contract."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import importlib.util
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import serialization as s
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
10
|
+
|
|
11
|
+
from deepparallel import licensing
|
|
12
|
+
|
|
13
|
+
# import functions/issuer/signer.py directly (it has no cloud deps)
|
|
14
|
+
_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"issuer_signer",
|
|
16
|
+
Path(__file__).resolve().parent.parent / "functions" / "issuer" / "signer.py",
|
|
17
|
+
)
|
|
18
|
+
signer = importlib.util.module_from_spec(_spec)
|
|
19
|
+
_spec.loader.exec_module(signer)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _keys():
|
|
23
|
+
priv = Ed25519PrivateKey.generate()
|
|
24
|
+
priv_b64 = base64.b64encode(
|
|
25
|
+
priv.private_bytes(s.Encoding.Raw, s.PrivateFormat.Raw, s.NoEncryption())
|
|
26
|
+
).decode()
|
|
27
|
+
pub_b64 = base64.b64encode(
|
|
28
|
+
priv.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw)
|
|
29
|
+
).decode()
|
|
30
|
+
return priv_b64, pub_b64
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_issuer_token_verifies_in_cli():
|
|
34
|
+
priv_b64, pub_b64 = _keys()
|
|
35
|
+
token = signer.make_license("buyer@co.com", "team", priv_b64)
|
|
36
|
+
payload = licensing.verify_token(token, pub_b64)
|
|
37
|
+
assert payload is not None
|
|
38
|
+
assert payload["tier"] == "team"
|
|
39
|
+
assert payload["email"] == "buyer@co.com"
|
|
40
|
+
assert payload["exp"] > time.time()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_price_to_tier_mapping():
|
|
44
|
+
m = {"price_PRO": "pro", "price_TEAM": "team"}
|
|
45
|
+
assert signer.price_to_tier("price_PRO", m) == "pro"
|
|
46
|
+
assert signer.price_to_tier("price_TEAM", m) == "team"
|
|
47
|
+
assert signer.price_to_tier("price_UNKNOWN", m) == "pro" # default paid -> pro
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_issuer_token_rejected_by_wrong_pubkey():
|
|
51
|
+
priv_b64, _ = _keys()
|
|
52
|
+
_, other_pub = _keys()
|
|
53
|
+
token = signer.make_license("x@y.com", "pro", priv_b64)
|
|
54
|
+
assert licensing.verify_token(token, other_pub) is None
|
|
@@ -90,6 +90,30 @@ def test_tier_ordering_and_allows():
|
|
|
90
90
|
assert not licensing.tier_allows(Tier.FREE, Tier.PRO)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_sign_token_roundtrips_with_verify():
|
|
94
|
+
priv = Ed25519PrivateKey.generate()
|
|
95
|
+
priv_b64 = base64.b64encode(
|
|
96
|
+
priv.private_bytes(s.Encoding.Raw, s.PrivateFormat.Raw, s.NoEncryption())
|
|
97
|
+
).decode()
|
|
98
|
+
pub_b64 = base64.b64encode(
|
|
99
|
+
priv.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw)
|
|
100
|
+
).decode()
|
|
101
|
+
token = licensing.sign_token({"tier": "team", "email": "x@y.com", "exp": 0}, priv_b64)
|
|
102
|
+
payload = licensing.verify_token(token, pub_b64)
|
|
103
|
+
assert payload is not None
|
|
104
|
+
assert payload["tier"] == "team" and payload["email"] == "x@y.com"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_sign_token_signature_is_unforgeable():
|
|
108
|
+
priv = Ed25519PrivateKey.generate()
|
|
109
|
+
priv_b64 = base64.b64encode(
|
|
110
|
+
priv.private_bytes(s.Encoding.Raw, s.PrivateFormat.Raw, s.NoEncryption())
|
|
111
|
+
).decode()
|
|
112
|
+
_, other_pub = _keypair()
|
|
113
|
+
token = licensing.sign_token({"tier": "pro", "exp": 0}, priv_b64)
|
|
114
|
+
assert licensing.verify_token(token, other_pub) is None # wrong key rejects
|
|
115
|
+
|
|
116
|
+
|
|
93
117
|
def test_feature_gate_messages():
|
|
94
118
|
ok, msg = licensing.check_feature("guardian", Tier.FREE)
|
|
95
119
|
assert ok is False
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from deepparallel import supply_chain as sc
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_extract_python_imports():
|
|
7
|
+
code = (
|
|
8
|
+
"import os\nimport requests\nfrom flask import Flask\nfrom . import local\nimport a.b.c\n"
|
|
9
|
+
)
|
|
10
|
+
deps = sc.extract_dependencies(code, "app.py")
|
|
11
|
+
names = {d["name"] for d in deps}
|
|
12
|
+
assert "requests" in names and "flask" in names and "a" in names
|
|
13
|
+
assert "os" not in names # stdlib excluded
|
|
14
|
+
assert "local" not in names # relative import excluded
|
|
15
|
+
assert all(d["ecosystem"] == "pypi" for d in deps)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_extract_requirements_txt():
|
|
19
|
+
deps = sc.extract_dependencies(
|
|
20
|
+
"requests==2.31\nflask>=2\n# comment\n\nNotARealPkg123\n", "requirements.txt"
|
|
21
|
+
)
|
|
22
|
+
names = {d["name"] for d in deps}
|
|
23
|
+
assert {"requests", "flask", "notarealpkg123"} <= names
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_extract_package_json():
|
|
27
|
+
pkg = '{"dependencies": {"react": "^18", "left-pad": "1.0.0"}, "devDependencies": {"vitest": "^1"}}'
|
|
28
|
+
deps = sc.extract_dependencies(pkg, "package.json")
|
|
29
|
+
names = {(d["name"], d["ecosystem"]) for d in deps}
|
|
30
|
+
assert ("react", "npm") in names and ("left-pad", "npm") in names and ("vitest", "npm") in names
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_audit_flags_missing_package(monkeypatch):
|
|
34
|
+
# requests exists (200), hallucinated-pkg does not (404)
|
|
35
|
+
def fake_get(url, **kw):
|
|
36
|
+
code = 404 if "hallucinated" in url else 200
|
|
37
|
+
return httpx.Response(code, request=httpx.Request("GET", url))
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr(httpx, "get", fake_get)
|
|
40
|
+
out = sc.audit("import requests\nimport hallucinated_pkg\n", "app.py")
|
|
41
|
+
findings = {f["name"]: f["status"] for f in out["findings"]}
|
|
42
|
+
assert findings["requests"] == "ok"
|
|
43
|
+
assert findings["hallucinated_pkg"] == "missing"
|
|
44
|
+
assert out["hallucinated"] == ["hallucinated_pkg"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_audit_degrades_on_network_error(monkeypatch):
|
|
48
|
+
def boom(url, **kw):
|
|
49
|
+
raise httpx.ConnectError("offline")
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(httpx, "get", boom)
|
|
52
|
+
out = sc.audit("import requests\n", "app.py")
|
|
53
|
+
# network failure -> unknown, never a false "missing"
|
|
54
|
+
assert out["findings"][0]["status"] == "unknown"
|
|
55
|
+
assert out["hallucinated"] == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_check_pypi_existence(monkeypatch):
|
|
59
|
+
monkeypatch.setattr(
|
|
60
|
+
httpx, "get", lambda url, **kw: httpx.Response(200, request=httpx.Request("GET", url))
|
|
61
|
+
)
|
|
62
|
+
assert sc.check_exists("requests", "pypi") is True
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
httpx, "get", lambda url, **kw: httpx.Response(404, request=httpx.Request("GET", url))
|
|
65
|
+
)
|
|
66
|
+
assert sc.check_exists("nope", "pypi") is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_audit_empty_when_no_deps():
|
|
70
|
+
out = sc.audit("x = 1\nprint(x)\n", "app.py")
|
|
71
|
+
assert out["findings"] == [] and out["hallucinated"] == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|