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.
Files changed (53) hide show
  1. {deepparallel-0.2.0 → deepparallel-0.3.1}/PKG-INFO +27 -3
  2. {deepparallel-0.2.0 → deepparallel-0.3.1}/README.md +26 -2
  3. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/__init__.py +1 -1
  4. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/agent.py +43 -3
  5. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/backend.py +18 -2
  6. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/cli.py +43 -1
  7. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/config.py +1 -1
  8. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/licensing.py +24 -0
  9. deepparallel-0.3.1/deepparallel/supply_chain.py +130 -0
  10. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/PKG-INFO +27 -3
  11. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/SOURCES.txt +3 -0
  12. {deepparallel-0.2.0 → deepparallel-0.3.1}/pyproject.toml +1 -1
  13. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_agent.py +118 -0
  14. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend_chat.py +25 -0
  15. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_cli.py +34 -0
  16. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_config.py +1 -1
  17. deepparallel-0.3.1/tests/test_issuer_signer.py +54 -0
  18. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_licensing.py +24 -0
  19. deepparallel-0.3.1/tests/test_supply_chain.py +71 -0
  20. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/branding.py +0 -0
  21. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/fusion.py +0 -0
  22. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/registry.json +0 -0
  23. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/renderer.py +0 -0
  24. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/system_prompt.txt +0 -0
  25. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/__init__.py +0 -0
  26. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/codeast.py +0 -0
  27. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/edit.py +0 -0
  28. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/files.py +0 -0
  29. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/registry.py +0 -0
  30. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/sandbox.py +0 -0
  31. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/search.py +0 -0
  32. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/shell.py +0 -0
  33. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/vision.py +0 -0
  34. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel/tools/web.py +0 -0
  35. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/dependency_links.txt +0 -0
  36. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/entry_points.txt +0 -0
  37. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/requires.txt +0 -0
  38. {deepparallel-0.2.0 → deepparallel-0.3.1}/deepparallel.egg-info/top_level.txt +0 -0
  39. {deepparallel-0.2.0 → deepparallel-0.3.1}/setup.cfg +0 -0
  40. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend.py +0 -0
  41. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_backend_stream.py +0 -0
  42. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_branding.py +0 -0
  43. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_fusion.py +0 -0
  44. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_renderer.py +0 -0
  45. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tool_registry.py +0 -0
  46. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_codeast.py +0 -0
  47. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_edit.py +0 -0
  48. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_files.py +0 -0
  49. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_sandbox.py +0 -0
  50. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_search.py +0 -0
  51. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_shell.py +0 -0
  52. {deepparallel-0.2.0 → deepparallel-0.3.1}/tests/test_tools_vision.py +0 -0
  53. {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.2.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 focused command-line interface for the DeepParallel model, served via Crowe Logic.
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 | `2048` |
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 focused command-line interface for the DeepParallel model, served via Crowe Logic.
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 | `2048` |
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) |
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -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 auto_approve and not forced:
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
- for tc in tool_calls:
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 meta is None:
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]["message"]
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]["message"]
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", 2048),
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.2.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 focused command-line interface for the DeepParallel model, served via Crowe Logic.
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 | `2048` |
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.2.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)
@@ -56,7 +56,7 @@ def test_defaults_to_azure(monkeypatch):
56
56
  assert s.backend == "azure"
57
57
  assert s.deployment == "DeepSeek-V4-Pro"
58
58
  assert s.temperature == 0.4
59
- assert s.max_tokens == 2048
59
+ assert s.max_tokens == 8192
60
60
  assert s.show_thinking is False
61
61
 
62
62
 
@@ -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