Files
hackingtool/tests/test_config.py
T
Hardik Zinzuvadiya 2482cc9fd0 feat: AI operator console — 215 curated tools, AI layer, /find discovery (v2.0.0 rework)
Squashed rework of hackingtool from a tool launcher into an AI-guided operator
console for authorized security testing. 93 commits collapsed into this one;
the pre-rework tree is tagged v2.0.0.

CATALOG & ENGINE
- Data-driven YAML catalog (21 categories, 215 live tools + 59 archived) with a
  registry/overlay loader and a fixed 66-tag taxonomy (63 in use). Adding a tool
  is one YAML entry, not edits across the codebase.
- Engine honesty: real exit codes, truthful install success/failure, reuse-first
  skip, EOF-safe prompts, command audit logging.
- Safe installs: sha256-required safe-fetch (killed `curl | bash` in feroxbuster,
  Caido and Sliver), list-form subprocess only, no forced sudo.

AI LAYER (bring-your-own-key or local model; degrades offline, never fabricates)
- AI1 intent -> tools; AI2 tool+goal -> command, curated-first with a grounded
  fallback; AI3 findings summary and engagement report; AI4 per-finding impact
  and remediation. Prompt-injection hardened per OWASP LLM01.
- /goal plans an objective and runs it one step at a time, showing every command
  before it runs, with a plan.json + run.log audit trail.

/find TOOL DISCOVERY (this branch's headline feature)
- Suggests real GitHub projects when the catalog has no tool for a need.
  Deterministic: zero model calls, structured API fields only, suggest-only —
  it never clones, installs or runs anything.
- A charter filter refuses destructive/DoS/jamming/mass-targeting/evasion asks
  before any network I/O, while a defensive-intent guard keeps blue-team and
  DFIR phrasing ("detect a SYN flood in a pcap") from being false-refused.
- Query rewriting proved to be the dominant quality lever (the first design
  measured 29% precision with no results on 5 of 8 needs): a curated 41-row
  intent table maps plain English to canonical jargon plus a GitHub topic, and
  a two-arm search unions topic coverage with jargon precision.
- Explainable additive ranking: log-flattened stars, license/age/language,
  trusted-author bonus derived from owners we already ship, docs-repo demotion
  by name, staleness as a soft demotion rather than a filter (a hard cutoff
  would delete THC-Hydan and John the Ripper), and a relevance term weighting
  curated topics above free-text description.
- Optional no-scope GitHub token purely as a rate-limit lever (10 -> 30 req/min);
  it reaches only an Authorization header, never a cache key, log or output.
- `[a]` saves a pick to ~/.hackingtool/found.yaml as a structurally inert entry,
  and the loader strips executable keys from user catalogs at read time so a
  hand-edited file cannot become a runnable command.

CONSOLE & PACKAGING
- REPL with a / command palette, @ tool mentions, tag filters, history and
  completion; background tmux panes; settings and first-run scaffolding.
- src-layout package with catalog and pipelines as package data, console entry
  point, Docker image, signed releases with SBOM and build provenance.
- Health docs (SECURITY, CONTRIBUTING, CHANGELOG, CODE_OF_CONDUCT), a CI gate
  (ruff + pytest + catalog/taxonomy conformance) and a pre-push hook.
- README rewritten with a section index, the tool catalog split into
  docs/TOOLS.md and a step-by-step docs/HOW-TO-USE.md.

278 tests passing; scripts/check.sh green.
2026-07-27 00:18:34 +05:30

217 lines
8.2 KiB
Python

import pytest
from hackingtool import config
@pytest.fixture
def tmp_cfg(tmp_path, monkeypatch):
f = tmp_path / "config.json"
monkeypatch.setattr(config, "USER_CONFIG_FILE", f)
return f
def test_set_value_roundtrip(tmp_cfg):
ok, msg = config.set_value("background_runner", "off")
assert ok
assert config.load()["background_runner"] == "off"
def test_set_value_bool_coerce(tmp_cfg):
ok, _ = config.set_value("show_archived", "on")
assert ok and config.load()["show_archived"] is True
config.set_value("show_archived", "false")
assert config.load()["show_archived"] is False
def test_set_value_rejects_unknown(tmp_cfg):
ok, msg = config.set_value("bogus", "x")
assert not ok and "Unknown" in msg
assert not tmp_cfg.exists()
def test_set_value_rejects_readonly(tmp_cfg):
ok, msg = config.set_value("version", "9.9")
assert not ok and "read-only" in msg
assert not tmp_cfg.exists()
def test_set_value_rejects_bad_enum(tmp_cfg):
ok, msg = config.set_value("background_runner", "maybe")
assert not ok and "must be one of" in msg
assert not tmp_cfg.exists()
def test_set_value_unique_prefix(tmp_cfg):
ok, _ = config.set_value("background", "off")
assert ok and config.load()["background_runner"] == "off"
def test_describe_marks_version_readonly():
editable = {k: e for k, _, e in config.describe()}
assert editable["version"] is False
assert editable["background_runner"] is True
def test_ensure_user_files_scaffolds(tmp_cfg):
config.ensure_user_files()
assert tmp_cfg.exists() # config.json from defaults
env = tmp_cfg.parent / ".env"
assert env.exists()
body = env.read_text()
# Security: the template must NEVER ship an active secret — every AI-key
# line stays commented out.
for line in body.splitlines():
if "HACKINGTOOL_AI_KEY" in line:
assert line.lstrip().startswith("#")
def test_set_ai_key_writes_env_not_config(tmp_cfg, monkeypatch):
import stat
monkeypatch.delenv("HACKINGTOOL_AI_KEY", raising=False) # ensure clean + auto-restore
env = tmp_cfg.parent / ".env"
env.write_text("# hackingtool\nHACKINGTOOL_AI_MODEL=claude-x\n"
"# HACKINGTOOL_AI_KEY=sk-ant-your-key-here\n")
ok, _ = config.set_ai_key("sk-ant-real-123")
assert ok
body = env.read_text()
assert "HACKINGTOOL_AI_KEY=sk-ant-real-123" in body # written, uncommented
assert "HACKINGTOOL_AI_MODEL=claude-x" in body # other lines preserved
assert stat.S_IMODE(env.stat().st_mode) == 0o600 # owner-only
assert config.ai_key() == "sk-ant-real-123" # live in-process, no restart
assert not tmp_cfg.exists() or "sk-ant-real-123" not in tmp_cfg.read_text() # never in config.json
ok, _ = config.set_ai_key("") # clearing re-hides + unsets
assert ok and config.ai_key() == ""
assert "sk-ant-real-123" not in env.read_text()
def test_ensure_user_files_never_overwrites(tmp_cfg):
tmp_cfg.write_text('{"theme": "cyan"}') # pre-existing, hand-edited
env = tmp_cfg.parent / ".env"
env.write_text("# HACKINGTOOL_AI_KEY=sk-real-key\n")
config.ensure_user_files()
assert '"cyan"' in tmp_cfg.read_text() # config untouched
assert env.read_text() == "# HACKINGTOOL_AI_KEY=sk-real-key\n"
def test_allowed_values():
assert set(config.allowed_values("background_runner").split(", ")) == {"auto", "off"}
assert config.allowed_values("show_archived") == "true, false"
assert config.allowed_values("tools_dir") is None
def test_config_command_no_arg_lists(monkeypatch, capsys):
import hackingtool.cli as cli
cli.config_command("")
out = capsys.readouterr().out
assert "background_runner" in out and "version" in out
def test_config_command_sets(monkeypatch):
import hackingtool.cli as cli
from hackingtool import config
seen = {}
monkeypatch.setattr(config, "set_value",
lambda k, v: seen.setdefault("call", (k, v)) or (True, "ok"))
cli.config_command("background_runner off")
assert seen["call"] == ("background_runner", "off")
def test_config_command_show_single(monkeypatch, capsys):
import hackingtool.cli as cli
cli.config_command("background_runner")
out = capsys.readouterr().out
assert "background_runner" in out and "auto" in out
def test_config_command_show_unique_prefix(monkeypatch, capsys):
import hackingtool.cli as cli
cli.config_command("background")
out = capsys.readouterr().out
assert "background_runner" in out
# ── config_ui (modal editor) ───────────────────────────────────────────────────
def test_config_ui_rows(tmp_cfg):
from hackingtool import config_ui
rows = config_ui._rows()
by_key = {r["key"]: r for r in rows}
assert by_key["ai_provider"]["kind"] == "choice"
assert set(by_key["ai_provider"]["choices"]) == {"auto", "ollama", "openai-compat"}
assert by_key["theme"]["kind"] == "choice" # theme is now arrow-selectable
assert "magenta" in by_key["theme"]["choices"]
assert by_key["version"]["kind"] == "readonly"
assert by_key["ai_key"]["kind"] == "secret" # masked-editable → written to .env
assert rows[-1]["key"] == "ai_key"
def test_config_ui_cycle():
from hackingtool import config_ui
c = ["auto", "ollama", "openai-compat"]
assert config_ui._cycle_choice("auto", c) == "ollama" # → forward
assert config_ui._cycle_choice("openai-compat", c) == "auto" # → wraps
assert config_ui._cycle_choice("auto", c, -1) == "openai-compat" # ← wraps back
assert config_ui._cycle_choice("ollama", c, -1) == "auto" # ← backward
assert config_ui._cycle_choice("bogus", c) == "auto" # unknown → first
def test_config_command_no_arg_opens_modal_on_tty(monkeypatch):
import hackingtool.cli as cli
from hackingtool import prompt, config_ui
opened = {}
monkeypatch.setattr(prompt, "_use_pt", lambda: True)
monkeypatch.setattr(config_ui, "open_editor", lambda: opened.setdefault("hit", True))
cli.config_command("")
assert opened.get("hit") is True
# ── /config github (discover token check) ──────────────────────────────────────
def test_check_token_reports_missing(monkeypatch):
from hackingtool import discover
for name in ("HACKINGTOOL_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
monkeypatch.delenv(name, raising=False)
ok, detail = discover.check_token()
assert ok is False
assert "no token" in detail.lower()
def test_check_token_reports_limit(monkeypatch):
from hackingtool import discover
monkeypatch.setenv("HACKINGTOOL_GITHUB_TOKEN", "ghp_x")
monkeypatch.setattr(discover, "_fetch",
lambda url: {"resources": {"search": {"limit": 30}}})
ok, detail = discover.check_token()
assert ok is True and "30" in detail
def test_token_steps_never_leak_a_real_token():
from hackingtool.discover import GITHUB_TOKEN_STEPS
assert "ghp_" not in GITHUB_TOKEN_STEPS.replace("ghp_your-token-here", "")
assert "no permissions" in GITHUB_TOKEN_STEPS.lower()
def test_check_token_never_leaks_token_on_rejection(monkeypatch):
"""Token hygiene: a failed check_token() must not echo the token value
anywhere in its detail string, even indirectly via an exception message."""
from hackingtool import discover
secret = "ghp_supersecrettoken12345"
monkeypatch.setenv("HACKINGTOOL_GITHUB_TOKEN", secret)
def _boom(url):
raise ValueError(f"bad request to {url}")
monkeypatch.setattr(discover, "_fetch", _boom)
ok, detail = discover.check_token()
assert ok is False
assert secret not in detail
def test_config_command_github_no_token(monkeypatch, capsys):
import hackingtool.cli as cli
from hackingtool import discover
monkeypatch.setattr(discover, "check_token", lambda: (False, "no token configured"))
cli.config_command("github")
out = capsys.readouterr().out
assert "GitHub" in out
assert "no permissions" in out.lower()