huntkit 0.3.0__py3-none-any.whl

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 (86) hide show
  1. huntkit/__init__.py +4 -0
  2. huntkit/__main__.py +8 -0
  3. huntkit/ai/__init__.py +42 -0
  4. huntkit/ai/advisor.py +133 -0
  5. huntkit/ai/providers.py +200 -0
  6. huntkit/app/__init__.py +13 -0
  7. huntkit/app/config_cmd.py +65 -0
  8. huntkit/app/context.py +50 -0
  9. huntkit/app/main.py +1166 -0
  10. huntkit/app/plugins_cmd.py +79 -0
  11. huntkit/app/workspace_cmd.py +62 -0
  12. huntkit/core/__init__.py +6 -0
  13. huntkit/core/cache.py +67 -0
  14. huntkit/core/config.py +272 -0
  15. huntkit/core/logger.py +66 -0
  16. huntkit/core/pacing.py +71 -0
  17. huntkit/core/runner.py +117 -0
  18. huntkit/core/state.py +154 -0
  19. huntkit/core/workspace.py +145 -0
  20. huntkit/enrich/__init__.py +39 -0
  21. huntkit/enrich/asn.py +139 -0
  22. huntkit/enrich/engine.py +167 -0
  23. huntkit/enrich/favicon.py +91 -0
  24. huntkit/enrich/mmh3.py +69 -0
  25. huntkit/fingerprint/__init__.py +26 -0
  26. huntkit/fingerprint/engine.py +83 -0
  27. huntkit/fingerprint/rules.py +170 -0
  28. huntkit/history/__init__.py +38 -0
  29. huntkit/history/diff.py +95 -0
  30. huntkit/history/store.py +133 -0
  31. huntkit/intel/__init__.py +48 -0
  32. huntkit/intel/engine.py +233 -0
  33. huntkit/intel/signals.py +826 -0
  34. huntkit/knowledge/__init__.py +27 -0
  35. huntkit/knowledge/playbooks.py +452 -0
  36. huntkit/monitor/__init__.py +24 -0
  37. huntkit/monitor/runner.py +111 -0
  38. huntkit/notify/__init__.py +16 -0
  39. huntkit/notify/webhook.py +73 -0
  40. huntkit/pipeline.py +302 -0
  41. huntkit/plugins/__init__.py +36 -0
  42. huntkit/plugins/amass.py +25 -0
  43. huntkit/plugins/arjun.py +18 -0
  44. huntkit/plugins/assetfinder.py +18 -0
  45. huntkit/plugins/base.py +220 -0
  46. huntkit/plugins/chaos.py +20 -0
  47. huntkit/plugins/dalfox.py +24 -0
  48. huntkit/plugins/dnsx.py +19 -0
  49. huntkit/plugins/ffuf.py +34 -0
  50. huntkit/plugins/findomain.py +18 -0
  51. huntkit/plugins/gau.py +18 -0
  52. huntkit/plugins/hakrawler.py +19 -0
  53. huntkit/plugins/httpx.py +30 -0
  54. huntkit/plugins/katana.py +23 -0
  55. huntkit/plugins/naabu.py +22 -0
  56. huntkit/plugins/nmap.py +39 -0
  57. huntkit/plugins/nuclei.py +27 -0
  58. huntkit/plugins/registry.py +117 -0
  59. huntkit/plugins/subfinder.py +21 -0
  60. huntkit/plugins/waybackurls.py +29 -0
  61. huntkit/plugins/whatweb.py +19 -0
  62. huntkit/py.typed +0 -0
  63. huntkit/report/__init__.py +103 -0
  64. huntkit/report/builder.py +145 -0
  65. huntkit/report/render.py +323 -0
  66. huntkit/scanner/__init__.py +28 -0
  67. huntkit/scanner/runner.py +239 -0
  68. huntkit/secrets/__init__.py +34 -0
  69. huntkit/secrets/patterns.py +172 -0
  70. huntkit/secrets/scanner.py +133 -0
  71. huntkit/shots/__init__.py +29 -0
  72. huntkit/shots/runner.py +186 -0
  73. huntkit/tui/__init__.py +13 -0
  74. huntkit/tui/dashboard.py +160 -0
  75. huntkit/utils/__init__.py +6 -0
  76. huntkit/utils/filesystem.py +109 -0
  77. huntkit/utils/process.py +104 -0
  78. huntkit/utils/severity.py +49 -0
  79. huntkit/utils/terminal.py +88 -0
  80. huntkit/utils/validators.py +99 -0
  81. huntkit-0.3.0.dist-info/METADATA +340 -0
  82. huntkit-0.3.0.dist-info/RECORD +86 -0
  83. huntkit-0.3.0.dist-info/WHEEL +5 -0
  84. huntkit-0.3.0.dist-info/entry_points.txt +2 -0
  85. huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. huntkit-0.3.0.dist-info/top_level.txt +1 -0
huntkit/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """HuntKit - a local bug bounty recon & methodology copilot."""
2
+
3
+ __version__ = "0.3.0"
4
+ __all__ = ["__version__"]
huntkit/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m huntkit`` to launch the Typer interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import run
6
+
7
+ if __name__ == "__main__":
8
+ run()
huntkit/ai/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """AI layer — optional, multi-provider advisor over the intel report.
2
+
3
+ A feature layer (like intel/knowledge/report) that builds on ``core`` and
4
+ ``intel``. Local providers (Ollama) run fully offline; cloud providers are
5
+ gated behind explicit approval so engagement data is never sent silently.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .advisor import (
11
+ SYSTEM_PROMPT,
12
+ Advice,
13
+ Advisor,
14
+ ApprovalDenied,
15
+ build_context,
16
+ make_advisor,
17
+ )
18
+ from .providers import (
19
+ AnthropicProvider,
20
+ OllamaProvider,
21
+ OpenAIProvider,
22
+ Provider,
23
+ ProviderError,
24
+ get_provider,
25
+ provider_names,
26
+ )
27
+
28
+ __all__ = [
29
+ "SYSTEM_PROMPT",
30
+ "Advice",
31
+ "Advisor",
32
+ "ApprovalDenied",
33
+ "AnthropicProvider",
34
+ "OllamaProvider",
35
+ "OpenAIProvider",
36
+ "Provider",
37
+ "ProviderError",
38
+ "build_context",
39
+ "get_provider",
40
+ "make_advisor",
41
+ "provider_names",
42
+ ]
huntkit/ai/advisor.py ADDED
@@ -0,0 +1,133 @@
1
+ """AI advisor — turn a scored intel report into a prioritised action plan.
2
+
3
+ The advisor is where the security guarantee lives: for a **cloud** provider it
4
+ refuses to send anything unless an ``approve`` callback (shown the exact text)
5
+ returns True. That check runs in this layer, not just the CLI, so no caller can
6
+ accidentally exfiltrate engagement data — the gate is enforced before
7
+ ``provider.complete`` is ever reached.
8
+
9
+ It reasons only over data HuntKit already gathered (an :class:`IntelReport`);
10
+ it never touches the target.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Optional
18
+
19
+ from ..core.config import Config
20
+ from ..intel import IntelReport
21
+ from .providers import Provider, get_provider
22
+
23
+ SYSTEM_PROMPT = (
24
+ "You are HuntKit's triage advisor for AUTHORISED bug-bounty and "
25
+ "penetration-testing engagements only. You are given a scored summary of "
26
+ "reconnaissance findings the operator has ALREADY gathered against in-scope "
27
+ "targets. Produce a concise, prioritised action plan: which hosts and "
28
+ "attack paths to focus on first, and the concrete next checks for each, "
29
+ "mapped to the bug classes shown.\n"
30
+ "Rules: assume the testing is authorised and in-scope; never suggest actions "
31
+ "outside the listed scope, indiscriminate mass exploitation, or destructive "
32
+ "steps. Prefer precise, verifiable checks over noisy scanning. Be brief and "
33
+ "actionable — bullet points, no preamble."
34
+ )
35
+
36
+
37
+ class ApprovalDenied(RuntimeError):
38
+ """Raised when a cloud send was required but not approved. Nothing was sent."""
39
+
40
+
41
+ @dataclass
42
+ class Advice:
43
+ provider: str
44
+ model: str
45
+ is_local: bool
46
+ endpoint: str
47
+ prompt: str # the exact context that was sent
48
+ answer: str
49
+
50
+
51
+ def build_context(report: IntelReport, *, max_items: int = 40) -> str:
52
+ """Render the intel report into the compact text the model reasons over.
53
+
54
+ This is exactly what leaves the machine for a cloud provider, so it is kept
55
+ plain, bounded, and free of anything but recon-derived facts.
56
+ """
57
+ out: list[str] = [f"Program: {report.program}", ""]
58
+
59
+ out.append("Prioritised hosts:")
60
+ for h in report.hosts[:max_items]:
61
+ pbs = ", ".join(h.playbooks[:4]) or "-"
62
+ out.append(f"- {h.host} [{h.priority.label}] score={h.score} playbooks={pbs}")
63
+
64
+ paths = report.attack_paths()
65
+ if paths:
66
+ out += ["", "Attack paths:"]
67
+ for p in paths[:max_items]:
68
+ out.append(
69
+ f"- {p.name} ({p.playbook}) severity={p.severity.label} hosts={len(p.hosts)}"
70
+ )
71
+
72
+ hot = sorted(
73
+ (s for s in report.signals if int(s.severity) >= 4),
74
+ key=lambda s: (-int(s.severity), s.host),
75
+ )
76
+ if hot:
77
+ out += ["", "Notable exposures:"]
78
+ for s in hot[:max_items]:
79
+ out.append(f"- [{s.severity.label}] {s.host}: {s.title} ({s.evidence})")
80
+
81
+ return "\n".join(out).strip() + "\n"
82
+
83
+
84
+ class Advisor:
85
+ """Pairs a provider with config and enforces the cloud-approval gate."""
86
+
87
+ def __init__(self, provider: Provider, config: Config) -> None:
88
+ self.provider = provider
89
+ self.config = config
90
+
91
+ def resolve_model(self, explicit: Optional[str] = None) -> str:
92
+ if explicit:
93
+ return explicit
94
+ # only honour the configured model for the configured provider —
95
+ # e.g. don't send ollama's 'llama3.2' default to Anthropic
96
+ if self.provider.name == self.config.ai.provider and self.config.ai.model:
97
+ return self.config.ai.model
98
+ return self.provider.default_model
99
+
100
+ def advise(
101
+ self,
102
+ report: IntelReport,
103
+ *,
104
+ model: Optional[str] = None,
105
+ approve: Optional[Callable[[str], bool]] = None,
106
+ ) -> Advice:
107
+ """Ask the provider for guidance. Cloud providers require approval first."""
108
+ ctx = build_context(report, max_items=self.config.ai.max_context_items)
109
+ model_id = self.resolve_model(model)
110
+
111
+ if (
112
+ not self.provider.is_local
113
+ and self.config.ai.require_approval
114
+ and (approve is None or not approve(ctx))
115
+ ):
116
+ raise ApprovalDenied(f"sending recon data to '{self.provider.name}' was not approved")
117
+
118
+ answer = self.provider.complete(SYSTEM_PROMPT, ctx, model=model_id, config=self.config)
119
+ return Advice(
120
+ provider=self.provider.name,
121
+ model=model_id,
122
+ is_local=self.provider.is_local,
123
+ endpoint=self.provider.endpoint_host(self.config),
124
+ prompt=ctx,
125
+ answer=answer,
126
+ )
127
+
128
+
129
+ def make_advisor(config: Config, provider_name: Optional[str] = None) -> Optional[Advisor]:
130
+ """Build an advisor for the named (or configured) provider; None if unknown."""
131
+ name = (provider_name or config.ai.provider or "ollama").strip().lower()
132
+ provider = get_provider(name)
133
+ return Advisor(provider, config) if provider is not None else None
@@ -0,0 +1,200 @@
1
+ """AI providers — a thin, dependency-free abstraction over one HTTP call.
2
+
3
+ Each provider turns a (system, prompt) pair into completion text. They talk raw
4
+ JSON over stdlib ``urllib`` so the AI layer stays optional and adds no
5
+ dependencies (HuntKit is lean-dep on purpose; Ollama has no SDK anyway).
6
+
7
+ The security-relevant property is :attr:`Provider.is_local`. Local providers
8
+ (Ollama on 127.0.0.1) never send engagement data off the machine; cloud
9
+ providers do, and the advisor gates them behind explicit approval.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ from ..core.config import Config
20
+
21
+
22
+ class ProviderError(RuntimeError):
23
+ """A provider could not produce a completion (network, auth, empty reply)."""
24
+
25
+
26
+ def _post_json(url: str, headers: dict[str, str], payload: dict, timeout: int) -> dict:
27
+ """POST JSON and parse the JSON reply. Raises ProviderError on any failure.
28
+
29
+ Kept module-level so tests can stub the whole network round-trip.
30
+ """
31
+ body = json.dumps(payload).encode("utf-8")
32
+ req = urllib.request.Request(
33
+ url,
34
+ data=body,
35
+ method="POST",
36
+ headers={"content-type": "application/json", **headers},
37
+ )
38
+ try:
39
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
40
+ return json.loads(resp.read().decode("utf-8"))
41
+ except urllib.error.HTTPError as e:
42
+ detail = e.read().decode("utf-8", "replace")[:400]
43
+ raise ProviderError(f"{url}: HTTP {e.code} — {detail}") from None
44
+ except urllib.error.URLError as e:
45
+ raise ProviderError(f"cannot reach {url}: {e.reason}") from None
46
+ except (TimeoutError, OSError, ValueError) as e:
47
+ raise ProviderError(f"request to {url} failed: {e}") from None
48
+
49
+
50
+ def _api_key(config: Config, name: str, *env_names: str) -> str:
51
+ """Resolve an API key: config.api_keys first, then well-known env vars."""
52
+ key = config.api_keys.get(name)
53
+ if key:
54
+ return key
55
+ for env in env_names:
56
+ val = os.environ.get(env)
57
+ if val:
58
+ return val
59
+ return ""
60
+
61
+
62
+ class Provider:
63
+ """Base provider. Subclasses set the class attrs and implement ``complete``."""
64
+
65
+ name: str = ""
66
+ default_model: str = ""
67
+ is_local: bool = False
68
+
69
+ def available(self, config: Config) -> bool:
70
+ """Whether this provider can be used right now (key present, etc.)."""
71
+ return True
72
+
73
+ def endpoint_host(self, config: Config) -> str:
74
+ """Human-readable destination, shown at the approval gate."""
75
+ return ""
76
+
77
+ def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
78
+ raise NotImplementedError
79
+
80
+
81
+ class OllamaProvider(Provider):
82
+ """Local Ollama (offline). Data never leaves the machine — no approval gate."""
83
+
84
+ name = "ollama"
85
+ default_model = "llama3.2"
86
+ is_local = True
87
+
88
+ def endpoint_host(self, config: Config) -> str:
89
+ return config.ai.endpoint
90
+
91
+ def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
92
+ url = config.ai.endpoint.rstrip("/") + "/api/chat"
93
+ data = _post_json(
94
+ url,
95
+ {},
96
+ {
97
+ "model": model,
98
+ "messages": [
99
+ {"role": "system", "content": system},
100
+ {"role": "user", "content": prompt},
101
+ ],
102
+ "stream": False,
103
+ },
104
+ timeout=config.general.timeout,
105
+ )
106
+ text = (data.get("message") or {}).get("content", "").strip()
107
+ if not text:
108
+ raise ProviderError("Ollama returned no content (is the model pulled?)")
109
+ return text
110
+
111
+
112
+ class AnthropicProvider(Provider):
113
+ """Anthropic Claude via the Messages API (cloud → gated)."""
114
+
115
+ name = "claude"
116
+ default_model = "claude-opus-4-8"
117
+ is_local = False
118
+
119
+ def _key(self, config: Config) -> str:
120
+ return _api_key(config, "anthropic", "ANTHROPIC_API_KEY")
121
+
122
+ def available(self, config: Config) -> bool:
123
+ return bool(self._key(config))
124
+
125
+ def endpoint_host(self, config: Config) -> str:
126
+ return "api.anthropic.com"
127
+
128
+ def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
129
+ key = self._key(config)
130
+ if not key:
131
+ raise ProviderError("no Anthropic API key (set HUNTKIT_APIKEY_ANTHROPIC)")
132
+ data = _post_json(
133
+ "https://api.anthropic.com/v1/messages",
134
+ {"x-api-key": key, "anthropic-version": "2023-06-01"},
135
+ {
136
+ "model": model,
137
+ "max_tokens": 1024,
138
+ "system": system,
139
+ "messages": [{"role": "user", "content": prompt}],
140
+ },
141
+ timeout=config.general.timeout,
142
+ )
143
+ text = "".join(
144
+ b.get("text", "") for b in data.get("content", []) if b.get("type") == "text"
145
+ ).strip()
146
+ if not text:
147
+ raise ProviderError("Anthropic returned no text content")
148
+ return text
149
+
150
+
151
+ class OpenAIProvider(Provider):
152
+ """OpenAI chat completions (cloud → gated)."""
153
+
154
+ name = "openai"
155
+ default_model = "gpt-4o-mini"
156
+ is_local = False
157
+
158
+ def _key(self, config: Config) -> str:
159
+ return _api_key(config, "openai", "OPENAI_API_KEY")
160
+
161
+ def available(self, config: Config) -> bool:
162
+ return bool(self._key(config))
163
+
164
+ def endpoint_host(self, config: Config) -> str:
165
+ return "api.openai.com"
166
+
167
+ def complete(self, system: str, prompt: str, *, model: str, config: Config) -> str:
168
+ key = self._key(config)
169
+ if not key:
170
+ raise ProviderError("no OpenAI API key (set HUNTKIT_APIKEY_OPENAI)")
171
+ data = _post_json(
172
+ "https://api.openai.com/v1/chat/completions",
173
+ {"authorization": f"Bearer {key}"},
174
+ {
175
+ "model": model,
176
+ "messages": [
177
+ {"role": "system", "content": system},
178
+ {"role": "user", "content": prompt},
179
+ ],
180
+ },
181
+ timeout=config.general.timeout,
182
+ )
183
+ choices = data.get("choices") or []
184
+ text = (choices[0].get("message", {}).get("content", "") if choices else "").strip()
185
+ if not text:
186
+ raise ProviderError("OpenAI returned no content")
187
+ return text
188
+
189
+
190
+ _PROVIDERS: dict[str, Provider] = {
191
+ p.name: p for p in (OllamaProvider(), AnthropicProvider(), OpenAIProvider())
192
+ }
193
+
194
+
195
+ def get_provider(name: str) -> Provider | None:
196
+ return _PROVIDERS.get(name.strip().lower())
197
+
198
+
199
+ def provider_names() -> list[str]:
200
+ return list(_PROVIDERS)
@@ -0,0 +1,13 @@
1
+ """HuntKit's Typer command-line interface (v0.2).
2
+
3
+ The application object and its entry point live here. During the v0.2
4
+ transition the legacy argparse CLI (``huntkit.cli``) remains the default
5
+ ``huntkit`` console script; this interface is reachable via
6
+ ``python -m huntkit`` until it reaches feature parity.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .main import app, run
12
+
13
+ __all__ = ["app", "run"]
@@ -0,0 +1,65 @@
1
+ """`huntkit config` — inspect and scaffold configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+ import yaml
11
+
12
+ from ..core.config import CONFIG_FILENAME, Config, sample_yaml
13
+ from ..utils import filesystem as fs
14
+ from ..utils import terminal as term
15
+
16
+ config_app = typer.Typer(no_args_is_help=True, help="Inspect and scaffold configuration.")
17
+
18
+
19
+ def _user_config_path() -> Path:
20
+ home = os.environ.get("HUNTKIT_CONFIG_HOME")
21
+ base = Path(home).expanduser() if home else Path.home() / ".config" / "huntkit"
22
+ return base / CONFIG_FILENAME
23
+
24
+
25
+ @config_app.command("init")
26
+ def config_init(
27
+ path: Optional[Path] = typer.Option(
28
+ None, "--path", "-o", help="Where to write (default: user config dir)."
29
+ ),
30
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite if it exists."),
31
+ ) -> None:
32
+ """Write a documented default huntkit.yaml."""
33
+ target = path or _user_config_path()
34
+ if target.exists() and not force:
35
+ term.warn(f"{target} already exists — use --force to overwrite.")
36
+ raise typer.Exit(1)
37
+ fs.write_text(target, sample_yaml())
38
+ term.ok(f"wrote config: {target}")
39
+ term.info("edit it, or override any value with HUNTKIT_<SECTION>_<KEY> env vars.")
40
+
41
+
42
+ @config_app.command("show")
43
+ def config_show(ctx: typer.Context) -> None:
44
+ """Print the resolved, merged configuration (secrets redacted)."""
45
+ config: Config = ctx.obj.config
46
+ data = config.to_dict()
47
+ if data.get("api_keys"):
48
+ data["api_keys"] = dict.fromkeys(data["api_keys"], "***")
49
+ term.console.print(yaml.safe_dump(data, sort_keys=False, default_flow_style=False).rstrip())
50
+ if config._sources:
51
+ term.info("merged from: " + ", ".join(config._sources))
52
+ else:
53
+ term.info("using built-in defaults (no config file found).")
54
+
55
+
56
+ @config_app.command("path")
57
+ def config_path() -> None:
58
+ """Show where HuntKit looks for configuration."""
59
+ user = _user_config_path()
60
+ project = Path.cwd() / CONFIG_FILENAME
61
+ rows = [
62
+ ("user", str(user), "yes" if user.exists() else "no"),
63
+ ("project", str(project), "yes" if project.exists() else "no"),
64
+ ]
65
+ term.print_table("Config search path", ["scope", "path", "exists"], rows)
huntkit/app/context.py ADDED
@@ -0,0 +1,50 @@
1
+ """The object passed down every command via Typer's context.
2
+
3
+ Built once in the root callback from the global options, so each command
4
+ gets a ready configuration, a runner, a cache, and the plugin registry
5
+ without re-parsing anything.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ..core.cache import Cache
15
+ from ..core.config import Config
16
+ from ..core.logger import setup_logging
17
+ from ..core.runner import CommandRunner
18
+ from ..plugins import get_registry
19
+ from ..plugins.registry import PluginRegistry
20
+
21
+
22
+ @dataclass
23
+ class AppContext:
24
+ config: Config
25
+ runner: CommandRunner
26
+ cache: Cache
27
+ registry: PluginRegistry
28
+ base: Optional[Path] = None # workspace base override (--workspace)
29
+
30
+ @classmethod
31
+ def build(
32
+ cls,
33
+ *,
34
+ config_path: Optional[Path] = None,
35
+ workspace: Optional[Path] = None,
36
+ verbose: bool = False,
37
+ quiet: bool = False,
38
+ ) -> AppContext:
39
+ config = Config.load(config_path)
40
+ level = "DEBUG" if verbose else ("ERROR" if quiet else "INFO")
41
+ setup_logging(level=level, verbose=verbose)
42
+ cache = Cache(config.workspace_root / ".cache")
43
+ runner = CommandRunner(config, cache=cache)
44
+ return cls(
45
+ config=config,
46
+ runner=runner,
47
+ cache=cache,
48
+ registry=get_registry(),
49
+ base=workspace,
50
+ )