mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
v1.1.0: guarded pentest workflow
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Violin guard package.
|
||||
|
||||
Subcommand logic for the Violin lightweight safety/release guard. The CLI
|
||||
entrypoint (`scripts/violin_guard.py`) imports the command handlers from this
|
||||
package and only owns argument parsing.
|
||||
"""
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Bootstrap and skill-load enforcement for the Violin guard package.
|
||||
|
||||
Covers the `check-skill-loaded` and `check-bootstrap` subcommands plus the
|
||||
corrupt-artifact auto-repair helper. `validate_scope_data` lives in `scope.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from guard.core import CheckResult, ROOT
|
||||
|
||||
# Map of required file path (relative to eng_dir) -> (template path, post-create command).
|
||||
# post_create_cmd None means the template itself is the bootstrap content; otherwise we
|
||||
# initialise the file with a one-liner (e.g. history.md needs `# Command History — date`).
|
||||
_REPAIR_TARGETS = {
|
||||
Path("scope/scope.yaml"): ("skills/pentest/templates/scope-template.yaml", None),
|
||||
Path("state/ptt.md"): ("skills/pentest/templates/ptt.md", None),
|
||||
Path("hypotheses.md"): ("skills/pentest/templates/hypothesis-board.md", None),
|
||||
Path("state/history.md"): (None, "# Command History — repair placeholder\n"),
|
||||
}
|
||||
|
||||
# Host/IP extraction from an engagement directory name of the form
|
||||
# "<host>-<YYYY-MM-DD>" (e.g. "10.129.45.228-2026-07-08"). Used to pre-fill
|
||||
# the scope target so the freshly bootstrapped engagement is guard-clean.
|
||||
_HOST_RE = re.compile(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9a-fA-F:]+|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})")
|
||||
|
||||
|
||||
def _derive_host(eng_dir: Path) -> str:
|
||||
match = _HOST_RE.search(eng_dir.name)
|
||||
return match.group(1) if match else "unknown-host"
|
||||
|
||||
|
||||
def init_engagement(eng_dir: Path, host: str | None = None) -> int:
|
||||
"""Create a complete, guard-clean engagement directory from templates.
|
||||
|
||||
Auto-creates every bootstrap artifact (scope, PTT, hypothesis board,
|
||||
history) and pre-fills the scope ``targets.ip_addresses`` with the real
|
||||
host so ``validate_scope_data`` returns clean (0 errors/0 warnings) instead
|
||||
of the REVIEW that a literal copy-the-template would produce.
|
||||
|
||||
This is the one-shot "auto create" path the agent invokes at engagement
|
||||
start; ``check-bootstrap --auto-repair`` reuses the same artifact builder
|
||||
to self-heal missing files on demand.
|
||||
"""
|
||||
result = CheckResult()
|
||||
eng_dir = Path(eng_dir)
|
||||
host = (host or "").strip() or _derive_host(eng_dir)
|
||||
|
||||
eng_dir.mkdir(parents=True, exist_ok=True)
|
||||
for rel, (template_rel, placeholder) in _REPAIR_TARGETS.items():
|
||||
target = eng_dir / rel
|
||||
if target.exists():
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if placeholder is not None:
|
||||
target.write_text(placeholder, encoding="utf-8")
|
||||
else:
|
||||
src = ROOT / template_rel
|
||||
content = src.read_text(encoding="utf-8")
|
||||
if rel == Path("scope/scope.yaml"):
|
||||
# Pre-fill the in-scope target so the scope is guard-clean.
|
||||
import yaml
|
||||
data = yaml.safe_load(content)
|
||||
data["targets"]["ip_addresses"] = [host]
|
||||
data["engagement"]["date"] = _dt.date.today().isoformat()
|
||||
content = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
# A freshly created PTT is legitimately pristine (all PT-XXX [ ]);
|
||||
# stamp it touched so the bootstrap stale-PTT REVIEW doesn't fire
|
||||
# on a brand-new engagement.
|
||||
if rel == Path("state/ptt.md"):
|
||||
import re as _re
|
||||
content = _re.sub(
|
||||
r"\*Last updated:.*\*",
|
||||
f"*Last updated: {_dt.datetime.now().strftime('%Y-%m-%d %H:%M')}*",
|
||||
content,
|
||||
)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
result.add_info(f"created {rel}")
|
||||
|
||||
# Re-verify the freshly built engagement is bootstrap-complete and guard-clean.
|
||||
if result.errors or result.warnings:
|
||||
result.add_error("init-engagement produced an incomplete or non-compliant engagement")
|
||||
result.print()
|
||||
return 1
|
||||
result.add_info(f"engagement initialised and guard-clean: {eng_dir}")
|
||||
result.print()
|
||||
return 0
|
||||
|
||||
|
||||
def check_skill_loaded(args: argparse.Namespace) -> int:
|
||||
"""Mark the current session/work-block as having read the Violin skill.
|
||||
|
||||
Creates a session-scoped marker file so ``_skill_loaded_guard`` in
|
||||
``check-command`` can enforce that SKILL.md was loaded before any
|
||||
target-touching command runs.
|
||||
|
||||
Marker location:
|
||||
- explicit ``--skill-loaded-file`` if provided
|
||||
- otherwise ``$ENG_DIR/state/.skill-loaded-<session-id>``
|
||||
|
||||
The marker must be recreated after session boundaries that invalidate
|
||||
in-context knowledge: ``/new``, ``/goal set``, and context compression.
|
||||
"""
|
||||
from guard.core import ROOT
|
||||
|
||||
result = CheckResult()
|
||||
eng_dir = Path(args.eng_dir or "")
|
||||
if not eng_dir.exists():
|
||||
result.add_error(f"engagement directory not found: {eng_dir}")
|
||||
result.print()
|
||||
return 1
|
||||
session_id = (getattr(args, "session_id", "") or "").strip()
|
||||
if not session_id:
|
||||
result.add_error("--session-id is required")
|
||||
result.print()
|
||||
return 1
|
||||
explicit = (getattr(args, "skill_loaded_file", "") or "").strip()
|
||||
marker = Path(explicit) if explicit else (eng_dir / "state" / f".skill-loaded-{session_id}")
|
||||
try:
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(f"skill-loaded: skills/pentest/SKILL.md\nsession: {session_id}\n", encoding="utf-8")
|
||||
except Exception as exc: # noqa: BLE001 - filesystem write should be explicit
|
||||
result.add_error(f"failed to write skill-loaded marker: {exc}")
|
||||
result.print()
|
||||
return 1
|
||||
result.add_info(f"skill-loaded marker created: {marker}")
|
||||
result.print()
|
||||
return 0
|
||||
|
||||
|
||||
def check_bootstrap(args: argparse.Namespace) -> int:
|
||||
"""Verify engagement bootstrap is complete.
|
||||
|
||||
Required artifacts (all must exist and be non-empty):
|
||||
|
||||
- $ENG_DIR/ directory exists
|
||||
- $ENG_DIR/scope/scope.yaml scope file present and parseable
|
||||
- $ENG_DIR/state/ptt.md Pentesting Task Tree present
|
||||
- $ENG_DIR/hypotheses.md hypothesis board present
|
||||
- $ENG_DIR/state/history.md command history initialised
|
||||
|
||||
Exit codes:
|
||||
0 = bootstrap complete
|
||||
1 = bootstrap missing (one or more required artifacts absent)
|
||||
2 = bootstrap partial (artifacts present but invalid)
|
||||
"""
|
||||
from guard.record import _ptt_is_stale
|
||||
|
||||
result = CheckResult()
|
||||
eng_dir_raw = args.eng_dir or ""
|
||||
if not eng_dir_raw:
|
||||
result.add_error("BOOTSTRAP REQUIRED: --eng-dir is empty (export ENG_DIR or pass --eng-dir)")
|
||||
eng_dir = Path(eng_dir_raw)
|
||||
|
||||
required = [
|
||||
(eng_dir, "engagement directory"),
|
||||
(eng_dir / "scope" / "scope.yaml", "scope file"),
|
||||
(eng_dir / "state" / "ptt.md", "Pentesting Task Tree"),
|
||||
(eng_dir / "hypotheses.md", "hypothesis board"),
|
||||
(eng_dir / "state" / "history.md", "command history"),
|
||||
]
|
||||
for path, label in required:
|
||||
if not eng_dir_raw:
|
||||
continue
|
||||
if not path.exists():
|
||||
result.add_error(f"BOOTSTRAP REQUIRED: missing {label} at {path}")
|
||||
elif path != eng_dir and path.is_dir():
|
||||
# Common LLM bootstrap drift: a required file got created as a
|
||||
# directory (e.g. write_file with an empty $ENG_DIR, or the model
|
||||
# treating the path as a folder and writing inside it). Block the
|
||||
# bootstrap with a precise, recoverable error. Skipped for the
|
||||
# engagement root itself, which is supposed to be a directory.
|
||||
template = (
|
||||
"hypothesis-board.md" if path.name == "hypotheses.md"
|
||||
else "ptt.md" if path.name == "ptt.md"
|
||||
else "history.md" if path.name == "history.md"
|
||||
else "scope-template.yaml"
|
||||
)
|
||||
result.add_error(
|
||||
f"BOOTSTRAP CORRUPT: {label} at {path} is a DIRECTORY but must be a FILE. "
|
||||
f"Fix: rm -rf \"{path}\" && cp skills/pentest/templates/{template} \"{path}\""
|
||||
)
|
||||
elif path.is_file() and path.stat().st_size == 0:
|
||||
result.add_warning(f"bootstrap artifact is empty: {path}")
|
||||
|
||||
if eng_dir_raw and eng_dir.exists() and not (eng_dir / "scope" / "scope.yaml").exists():
|
||||
result.add_info("create the scope with: cp skills/pentest/templates/scope-template.yaml <ENG_DIR>/scope/scope.yaml")
|
||||
if eng_dir_raw and eng_dir.exists() and not (eng_dir / "state" / "ptt.md").exists():
|
||||
result.add_info("create the PTT with: cp skills/pentest/templates/ptt.md <ENG_DIR>/state/ptt.md")
|
||||
if eng_dir_raw and eng_dir.exists() and not (eng_dir / "hypotheses.md").exists():
|
||||
result.add_info("create the hypothesis board with: cp skills/pentest/templates/hypothesis-board.md <ENG_DIR>/hypotheses.md")
|
||||
if eng_dir_raw and eng_dir.exists() and not (eng_dir / "state" / "history.md").exists():
|
||||
result.add_info("initialise command history with: echo \"# Command History — $(date +%F)\" > <ENG_DIR>/state/history.md")
|
||||
|
||||
# Stale-PTT drift detection at session resume: if every PT-XXX row is still
|
||||
# in the pristine [ ] state, the engagement was not touched since bootstrap.
|
||||
if eng_dir_raw and eng_dir.exists():
|
||||
ptt_check = eng_dir / "state" / "ptt.md"
|
||||
if ptt_check.exists() and _ptt_is_stale(ptt_check):
|
||||
result.add_warning("PTT has never been updated (all PT-XXX rows are [ ]); possible drift at session resume")
|
||||
|
||||
# Auto-repair pass (only when --auto-repair is passed, so the default check
|
||||
# stays strict): heal bootstrap drift. Two classes are healed:
|
||||
# 1. A required artifact exists *as a directory* (LLM bootstrap drift) —
|
||||
# remove it and re-create from the canonical template.
|
||||
# 2. A required artifact is *missing entirely* — create it from the
|
||||
# template, pre-filling the scope target so the result is guard-clean.
|
||||
# Each repair is logged as an info note and the matching BOOTSTRAP
|
||||
# error/warning is stripped so the next pass returns clean.
|
||||
if getattr(args, "auto_repair", False):
|
||||
result = _auto_repair_corrupt_artifacts(eng_dir, result)
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info(f"bootstrap complete: {eng_dir}")
|
||||
result.print()
|
||||
if result.errors:
|
||||
return 1
|
||||
if result.warnings:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def _auto_repair_corrupt_artifacts(eng_dir: Path, result: CheckResult) -> CheckResult:
|
||||
"""Repair bootstrap drift (directory drift AND missing artifacts).
|
||||
|
||||
Each repair is logged as an info note and the matching BOOTSTRAP
|
||||
error/warning is stripped so the next pass returns clean.
|
||||
"""
|
||||
new_errors, new_warnings, new_infos = [], [], list(result.infos)
|
||||
|
||||
# Class 0: the engagement directory itself is missing — create it so the
|
||||
# artifact loop below has a place to write into.
|
||||
if not eng_dir.exists():
|
||||
try:
|
||||
eng_dir.mkdir(parents=True, exist_ok=True)
|
||||
new_infos.append(f"AUTO-REPAIR: created missing engagement directory {eng_dir}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
new_errors.append(f"AUTO-REPAIR FAILED creating {eng_dir}: {exc}")
|
||||
|
||||
for rel, (template_rel, placeholder) in _REPAIR_TARGETS.items():
|
||||
target = eng_dir / rel
|
||||
|
||||
if target.is_dir():
|
||||
# Class 1: LLM bootstrap drift — required file created as a directory.
|
||||
try:
|
||||
shutil.rmtree(target)
|
||||
_create_artifact(eng_dir, rel, template_rel, placeholder)
|
||||
new_infos.append(
|
||||
f"AUTO-REPAIR: removed dir {target} and re-created as file "
|
||||
f"from {template_rel or 'inline placeholder'}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
new_errors.append(
|
||||
f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not target.exists():
|
||||
# Class 2: artifact missing entirely — create it from the template.
|
||||
try:
|
||||
_create_artifact(eng_dir, rel, template_rel, placeholder)
|
||||
new_infos.append(f"AUTO-REPAIR: created missing {target} from template")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
new_errors.append(
|
||||
f"AUTO-REPAIR FAILED for {rel} at {target}: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Strip the BOOTSTRAP REQUIRED / CORRUPT errors we just repaired.
|
||||
for e in result.errors:
|
||||
if "missing engagement directory" in e or any(
|
||||
rel.name in e
|
||||
for rel in (Path("scope/scope.yaml"), Path("state/ptt.md"),
|
||||
Path("hypotheses.md"), Path("state/history.md"))
|
||||
):
|
||||
new_infos.append(f"resolved: {e}")
|
||||
continue
|
||||
new_errors.append(e)
|
||||
for w in result.warnings:
|
||||
new_warnings.append(w)
|
||||
|
||||
return CheckResult(errors=new_errors, warnings=new_warnings, infos=new_infos)
|
||||
|
||||
|
||||
def _create_artifact(eng_dir: Path, rel: Path, template_rel: str | None,
|
||||
placeholder: str | None) -> None:
|
||||
"""Create a single required bootstrap artifact at ``eng_dir / rel``.
|
||||
|
||||
Reuses the same logic as ``init_engagement`` so a missing scope lands
|
||||
with its in-scope target pre-filled (guard-clean) rather than empty.
|
||||
"""
|
||||
target = eng_dir / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if placeholder is not None:
|
||||
target.write_text(placeholder, encoding="utf-8")
|
||||
return
|
||||
content = (ROOT / template_rel).read_text(encoding="utf-8")
|
||||
if rel == Path("scope/scope.yaml"):
|
||||
import yaml
|
||||
data = yaml.safe_load(content)
|
||||
data["targets"]["ip_addresses"] = [_derive_host(eng_dir)]
|
||||
data["engagement"]["date"] = _dt.date.today().isoformat()
|
||||
content = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Close-out gates: enforce the mandatory REPORTING / RETROSPECTIVE artifacts.
|
||||
|
||||
These are HARD gates (ERROR → exit 1). Unlike the advisory freshness warnings
|
||||
in ``freshness.py`` (which are WARNING → exit 2 and *are* auto-approved under
|
||||
``--yolo``), close-out violations map to ``denied`` even when
|
||||
``HERMES_YOLO_MODE=1`` (see ``plugins/violin_guard/tools.py``). That is
|
||||
deliberate: skipping REPORTING/RETROSPECTIVE is a skill-level violation — the
|
||||
compliance audit found a prior engagement stopped at flag capture with no
|
||||
report, no retrospective, no phase-summary, no CVSS vectors, and an empty
|
||||
Research Log, because those artifacts were only ever emitted as yolo-approved
|
||||
warnings.
|
||||
|
||||
Artifacts enforced (skill-defined paths):
|
||||
- REPORTING : $ENG_DIR/reporting/report.md (non-trivial),
|
||||
$ENG_DIR/state/phase-summary.md (transition summary),
|
||||
CVSS:3.1 vector for any Critical/High finding,
|
||||
non-empty hypotheses.md "Research Log" (RES- entries).
|
||||
- RETROSPECTIVE: $ENG_DIR/retrospective.md (or evidence/retrospective/...),
|
||||
phase-summary.md still present, CVSS still required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from guard.core import CheckResult
|
||||
|
||||
# Candidate locations for the mandated artifacts (skill-defined).
|
||||
REPORT_CANDIDATES = [
|
||||
"reporting/report.md",
|
||||
"evidence/reporting/report.md",
|
||||
"report.md",
|
||||
]
|
||||
RETRO_CANDIDATES = [
|
||||
"retrospective.md",
|
||||
"evidence/retrospective/retrospective.md",
|
||||
"reporting/retrospective.md",
|
||||
]
|
||||
PHASE_SUMMARY = "state/phase-summary.md"
|
||||
|
||||
CVSS_RE = re.compile(r"CVSS:3\.1", re.IGNORECASE)
|
||||
SEVERITY_RE = re.compile(r"(?i)severity\s*[:=]?\s*(critical|high)\b")
|
||||
TIER_RE = re.compile(r"\b(L3|L4)\b")
|
||||
|
||||
# Commands that legitimately PRODUCE close-out artifacts, or are guard
|
||||
# housekeeping — never blocked by the close-out gate, so the agent can create
|
||||
# the very file the gate requires without deadlocking.
|
||||
_REPORT_TOKENS = (
|
||||
"report.md", "retrospective.md", "phase-summary.md",
|
||||
"report-template", "coverage-matrix", "retrospective",
|
||||
)
|
||||
_WRITE_OPS = (
|
||||
"write_file", "record-ptt", "record-hypothesis", "record-history",
|
||||
"tee ", "cat >", "cat >>", "echo >", "echo >>", "printf >",
|
||||
"sed -i", "vim ", "nano ",
|
||||
)
|
||||
_SAFE_META = (
|
||||
"violin_guard.py", "hypothesis_guard.py", "sync-done", "heartbeat-done",
|
||||
"message-tick", "check-command", "check-closeout", "check-bootstrap",
|
||||
)
|
||||
|
||||
|
||||
def _exists_nonempty(eng_dir: Path, rel: str, min_bytes: int) -> bool:
|
||||
p = eng_dir / rel
|
||||
if not p.exists() or not p.is_file():
|
||||
return False
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
return text.strip() != "" and p.stat().st_size >= min_bytes
|
||||
|
||||
|
||||
def _report_substantive(path: Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8", errors="replace").lower()
|
||||
# Accept if it carries a CVSS vector (the key mandate) or a standard
|
||||
# report section. Rejects empty/stub files.
|
||||
if "cvss" in text:
|
||||
return True
|
||||
return any(tok in text for tok in ("severity", "finding", "summary", "methodology"))
|
||||
|
||||
|
||||
def _report_ok(eng_dir: Path) -> bool:
|
||||
for rel in REPORT_CANDIDATES:
|
||||
if _exists_nonempty(eng_dir, rel, 50) and _report_substantive(eng_dir / rel):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _retro_ok(eng_dir: Path) -> bool:
|
||||
for rel in RETRO_CANDIDATES:
|
||||
if _exists_nonempty(eng_dir, rel, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _phase_summary_ok(eng_dir: Path) -> bool:
|
||||
return _exists_nonempty(eng_dir, PHASE_SUMMARY, 30)
|
||||
|
||||
|
||||
def _cvss_ok(eng_dir: Path) -> bool:
|
||||
"""True if no Critical/High/L3/L4 finding lacks a CVSS:3.1 vector."""
|
||||
candidates = [
|
||||
eng_dir / "evidence" / "vuln-research" / "findings.md",
|
||||
eng_dir / "evidence" / "findings.md",
|
||||
eng_dir / "state" / "findings.md",
|
||||
eng_dir / "reporting" / "report.md",
|
||||
eng_dir / "evidence" / "reporting" / "report.md",
|
||||
]
|
||||
found = [p for p in candidates if _exists_nonempty(p.parent, p.name, 1)]
|
||||
if not found:
|
||||
return True # no findings recorded -> nothing to score
|
||||
combined = "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in found)
|
||||
if CVSS_RE.search(combined):
|
||||
return True
|
||||
# No CVSS vector present: only a problem if a high-severity finding exists.
|
||||
return not (SEVERITY_RE.search(combined) or TIER_RE.search(combined))
|
||||
|
||||
|
||||
def _research_log_ok(hyp_path: Path) -> bool:
|
||||
if not hyp_path.exists() or not hyp_path.is_file():
|
||||
return False
|
||||
text = hyp_path.read_text(encoding="utf-8", errors="replace")
|
||||
m = re.search(r"##\s+Research Log", text)
|
||||
if not m:
|
||||
return False
|
||||
tail = text[m.end():]
|
||||
nxt = re.search(r"\n##\s+", tail)
|
||||
section = tail[: nxt.start()] if nxt else tail
|
||||
return bool(re.search(r"RES-\d+", section))
|
||||
|
||||
|
||||
def _is_permitted(command: str) -> bool:
|
||||
"""True when the command is producing a close-out artifact or is guard
|
||||
housekeeping — so it must NOT be blocked by the close-out gate."""
|
||||
c = command.lower()
|
||||
if any(tok in c for tok in _REPORT_TOKENS):
|
||||
return True
|
||||
if any(op in c for op in _WRITE_OPS):
|
||||
return True
|
||||
if re.search(r">\s*\S*\.md(\b|$)", c):
|
||||
return True
|
||||
if any(meta in c for meta in _SAFE_META):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_closeout(eng_dir: str | Path, phase: str, command: str = "") -> CheckResult:
|
||||
"""Hard gate for the mandatory close-out artifacts.
|
||||
|
||||
Returns a ``CheckResult`` whose ``.errors`` are non-empty (→ exit 1 →
|
||||
``denied`` even under yolo) when a mandated artifact is missing *and* the
|
||||
command is not the one producing it.
|
||||
"""
|
||||
result = CheckResult()
|
||||
eng = Path(eng_dir)
|
||||
phase = phase.upper().replace("-", "_")
|
||||
permitted = _is_permitted(command or "")
|
||||
|
||||
if phase == "REPORTING":
|
||||
# REPORTING is documentation-only. When the agent is running an
|
||||
# artifact-producing command, accept it and re-check after save (no
|
||||
# deadlock). Otherwise every missing artifact is a hard BLOCK.
|
||||
if permitted:
|
||||
result.add_info(
|
||||
"close-out (REPORTING) command accepted — produce "
|
||||
"state/phase-summary.md, reporting/report.md (with CVSS:3.1 "
|
||||
"vectors for Critical/High findings), and a Research Log entry, "
|
||||
"then re-run check-command."
|
||||
)
|
||||
else:
|
||||
if not _phase_summary_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: state/phase-summary.md missing/empty — write the "
|
||||
"RECON→VULN-RESEARCH→EXPLOITATION→REPORTING transition summary before proceeding"
|
||||
)
|
||||
if not _report_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: reporting/report.md not produced — REPORTING is "
|
||||
"documentation-only; create the report before any other command"
|
||||
)
|
||||
if not _cvss_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: a Critical/High (L3/L4) finding is present but no "
|
||||
"CVSS:3.1 vector is recorded — add CVSS vectors before reporting"
|
||||
)
|
||||
if not _research_log_ok(eng / "hypotheses.md"):
|
||||
result.add_error(
|
||||
"close-out gate: hypotheses.md 'Research Log' has no RES- entries — "
|
||||
"record the research loop (NVD/ExploitDB/…) before reporting"
|
||||
)
|
||||
|
||||
elif phase == "RETROSPECTIVE":
|
||||
if not _phase_summary_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: state/phase-summary.md missing/empty for retrospective"
|
||||
)
|
||||
if not _retro_ok(eng):
|
||||
if permitted:
|
||||
result.add_info("retrospective-production command accepted; re-run check-command after saving")
|
||||
else:
|
||||
result.add_error(
|
||||
"close-out gate: retrospective.md not produced — RETROSPECTIVE is "
|
||||
"mandatory after every engagement"
|
||||
)
|
||||
if not _report_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: reporting/report.md missing — complete REPORTING before RETROSPECTIVE"
|
||||
)
|
||||
if not _cvss_ok(eng):
|
||||
result.add_error(
|
||||
"close-out gate: a Critical/High (L3/L4) finding lacks a CVSS:3.1 vector"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,257 @@
|
||||
"""check-command and its sub-guards for the Violin guard package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from guard.core import (
|
||||
PHASES,
|
||||
TARGET_TOOLS,
|
||||
DANGEROUS_PATTERNS,
|
||||
TIER3_PATTERNS,
|
||||
METADATA_TARGETS,
|
||||
as_list,
|
||||
is_scoped_host,
|
||||
is_excluded_host,
|
||||
load_yaml,
|
||||
normalize_host,
|
||||
host_from_url,
|
||||
validate_scope_data,
|
||||
CheckResult,
|
||||
)
|
||||
from guard.record import _ptt_staleness_guard, _history_staleness_guard
|
||||
from guard.freshness import (
|
||||
check_skill_load_gate,
|
||||
check_ptt_freshness,
|
||||
check_hypotheses_freshness,
|
||||
check_findings_freshness,
|
||||
)
|
||||
from guard.closeout import check_closeout
|
||||
from hypothesis_guard import _parse_hypotheses
|
||||
|
||||
|
||||
def check_command(args: argparse.Namespace) -> int:
|
||||
scope_path = Path(args.scope)
|
||||
if not scope_path.exists():
|
||||
result = CheckResult()
|
||||
result.add_error(f"scope file not found: {scope_path}")
|
||||
# Check if the scope argument looks like an IP/host instead of a file path
|
||||
scope_arg = args.scope.strip()
|
||||
if re.match(r"^(\d{1,3}\.){3}\d{1,3}$", scope_arg) or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", scope_arg):
|
||||
result.add_error(f" → The value '{scope_arg}' looks like an IP address or hostname, not a file path.")
|
||||
result.add_error(" → The --scope flag requires the PATH to your scope.yaml file (e.g. $ENG_DIR/scope/scope.yaml)")
|
||||
result.add_error("BOOTSTRAP REQUIRED: run playbooks/scoping.md §0 (Bootstrap) to create scope.yaml, PTT, hypothesis board, and command history before any target interaction")
|
||||
result.add_info("quickstart: ENG_DIR=engagements/<target>-$(date +%F); mkdir -p \"$ENG_DIR\"/{scope,evidence/{recon/{passive,tech,active},vuln-research,exploitation,reporting,retrospective},state}; cp skills/pentest/templates/{ptt.md,scope-template.yaml,hypothesis-board.md} \"$ENG_DIR\"/{state/ptt.md,scope/scope.yaml,hypotheses.md}")
|
||||
result.print()
|
||||
return 1
|
||||
scope = load_yaml(scope_path)
|
||||
result = CheckResult()
|
||||
phase = args.phase.upper().replace("-", "_")
|
||||
command = args.command.strip()
|
||||
lowered = command.lower()
|
||||
|
||||
if phase not in PHASES:
|
||||
result.add_error(f"unknown phase: {args.phase}")
|
||||
|
||||
scope_result = validate_scope_data(scope)
|
||||
if scope_result.errors:
|
||||
result.errors.extend(f"scope invalid: {error}" for error in scope_result.errors)
|
||||
|
||||
for pattern, reason in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, lowered, flags=re.IGNORECASE):
|
||||
result.add_error(reason)
|
||||
|
||||
for pattern, reason in TIER3_PATTERNS:
|
||||
if re.search(pattern, lowered, flags=re.IGNORECASE):
|
||||
if "credential" in reason and has_allowed_carveout(scope, "credential", "brute", "password"):
|
||||
result.add_warning(f"{reason}; RoE carve-out found, require explicit per-command approval")
|
||||
else:
|
||||
result.add_warning(reason)
|
||||
|
||||
tokens = command_tokens(command)
|
||||
tool = Path(tokens[0]).name.lower() if tokens else ""
|
||||
hosts = extract_hosts(command)
|
||||
|
||||
if tool and tool not in TARGET_TOOLS and hosts:
|
||||
result.add_warning(f"command uses unclassified tool '{tool}' against detected target(s)")
|
||||
|
||||
if tool in TARGET_TOOLS and not hosts:
|
||||
result.add_warning("target-touching tool used but no target was detected; ask for review")
|
||||
|
||||
for host in sorted(hosts):
|
||||
if host in METADATA_TARGETS and not has_allowed_carveout(scope, "metadata", "ssrf"):
|
||||
result.add_error(f"cloud metadata target is not scoped by default: {host}")
|
||||
elif is_excluded_host(host, scope):
|
||||
result.add_error(f"target is explicitly excluded: {host}")
|
||||
elif not is_scoped_host(host, scope):
|
||||
result.add_error(f"target is outside approved scope: {host}")
|
||||
|
||||
if phase in {"SCOPING", "REPORTING", "RETROSPECTIVE"} and (hosts or tool in TARGET_TOOLS):
|
||||
result.add_error(f"target interaction is not allowed during {phase}")
|
||||
|
||||
if phase in {"RECON", "VULN_RESEARCH"} and any(term in lowered for term in ("--os-pwn", "--risk=3", "reverse shell")):
|
||||
result.add_error(f"exploit-style command is not allowed during {phase}")
|
||||
|
||||
eng_dir = (args.eng_dir or "").strip()
|
||||
if eng_dir:
|
||||
eng_dir_path = Path(eng_dir)
|
||||
skill_loaded_file = getattr(args, "skill_loaded_file", "") or ""
|
||||
session_id = (getattr(args, "session_id", "") or "").strip()
|
||||
if session_id:
|
||||
canonical = eng_dir_path / "state" / f".skill-loaded-{session_id}"
|
||||
skill_loaded_file = str(canonical)
|
||||
|
||||
# Skill-load gate is mandatory for any target-touching command when an
|
||||
# engagement dir is supplied (was discipline-only).
|
||||
target_touching = bool(hosts or tool in TARGET_TOOLS) and phase in {
|
||||
"RECON", "VULN_RESEARCH", "EXPLOITATION",
|
||||
}
|
||||
if target_touching:
|
||||
skill_result = check_skill_load_gate(skill_loaded_file, mandatory=True)
|
||||
if skill_result.errors or skill_result.warnings:
|
||||
result.errors.extend(f"skill guard: {message}" for message in skill_result.errors)
|
||||
result.warnings.extend(f"skill guard: {message}" for message in skill_result.warnings)
|
||||
for message in skill_result.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
elif skill_loaded_file:
|
||||
# Non-target command with an explicit marker: verify but don't block.
|
||||
skill_result = check_skill_load_gate(skill_loaded_file, mandatory=False)
|
||||
if skill_result.warnings:
|
||||
result.warnings.extend(f"skill guard: {message}" for message in skill_result.warnings)
|
||||
for message in skill_result.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
ptt_result = _ptt_staleness_guard(eng_dir_path / "state" / "ptt.md")
|
||||
if ptt_result.errors or ptt_result.warnings:
|
||||
result.errors.extend(f"ptt guard: {message}" for message in ptt_result.errors)
|
||||
result.warnings.extend(f"ptt guard: {message}" for message in ptt_result.warnings)
|
||||
for message in ptt_result.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
# Freshness guard: PTT "Last updated" + phase desync
|
||||
ptt_fresh = check_ptt_freshness(eng_dir_path / "state" / "ptt.md", phase)
|
||||
if ptt_fresh.warnings:
|
||||
result.warnings.extend(f"ptt guard: {message}" for message in ptt_fresh.warnings)
|
||||
for message in ptt_fresh.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
history_result = _history_staleness_guard(eng_dir_path, lowered)
|
||||
if history_result.errors or history_result.warnings:
|
||||
result.errors.extend(f"history guard: {message}" for message in history_result.errors)
|
||||
result.warnings.extend(f"history guard: {message}" for message in history_result.warnings)
|
||||
for message in history_result.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
if target_touching and not result.errors:
|
||||
hyp_result = _hypothesis_guard(eng_dir_path, hosts, phase)
|
||||
if hyp_result.errors or hyp_result.warnings:
|
||||
result.errors.extend(f"hypothesis guard: {message}" for message in hyp_result.errors)
|
||||
result.warnings.extend(f"hypothesis guard: {message}" for message in hyp_result.warnings)
|
||||
for message in hyp_result.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
# Freshness guard: hypotheses + findings drift
|
||||
hyp_fresh = check_hypotheses_freshness(eng_dir_path / "hypotheses.md", phase)
|
||||
if hyp_fresh.errors or hyp_fresh.warnings:
|
||||
result.errors.extend(f"hypothesis guard: {message}" for message in hyp_fresh.errors)
|
||||
result.warnings.extend(f"hypothesis guard: {message}" for message in hyp_fresh.warnings)
|
||||
findings_fresh = check_findings_freshness(eng_dir_path, phase)
|
||||
if findings_fresh.warnings:
|
||||
result.warnings.extend(f"findings guard: {message}" for message in findings_fresh.warnings)
|
||||
|
||||
# Close-out gate: mandatory REPORTING / RETROSPECTIVE artifacts. These are
|
||||
# HARD errors (exit 1) so --yolo cannot auto-approve them (only warnings
|
||||
# are auto-approved). Permitted artifact-producing commands are exempt so
|
||||
# the agent can create the very file the gate requires (no deadlock).
|
||||
if phase in {"REPORTING", "RETROSPECTIVE"}:
|
||||
closeout = check_closeout(eng_dir_path, phase, command)
|
||||
if closeout.errors:
|
||||
result.errors.extend(f"close-out gate: {message}" for message in closeout.errors)
|
||||
for message in closeout.infos:
|
||||
if message not in result.infos:
|
||||
result.infos.append(message)
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info("command is allowed by current lightweight guard")
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
|
||||
|
||||
def _hypothesis_guard(eng_dir: Path, hosts: set[str], phase: str) -> CheckResult:
|
||||
result = CheckResult()
|
||||
hypothesis_path = eng_dir / "hypotheses.md"
|
||||
if not hypothesis_path.exists() or not hypothesis_path.is_file():
|
||||
result.add_error(f"hypotheses.md missing: {hypothesis_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"")
|
||||
return result
|
||||
try:
|
||||
hypotheses = _parse_hypotheses(hypothesis_path)
|
||||
except Exception as exc:
|
||||
result.add_error(f"hypotheses.md parse error: {exc}")
|
||||
return result
|
||||
active_hypotheses = [h for h in hypotheses if h.status in {"candidate", "researching", "verified"}]
|
||||
if not active_hypotheses:
|
||||
result.add_error("no active hypotheses found; create one before continuing")
|
||||
result.add_info("run: python scripts/hypothesis_guard.py record-hypothesis --eng-dir \"$ENG_DIR\" --service <service> --port <port> --status researching --rationale \"<why>\"")
|
||||
return result
|
||||
for host in hosts:
|
||||
matched = [h for h in active_hypotheses if h.target and host.lower() in h.target.lower()]
|
||||
if not matched:
|
||||
result.add_warning(f"no hypothesis covers host {host}; add a hypothesis or verify scope before continuing")
|
||||
if phase in {"RECON", "VULN_RESEARCH"} and all(h.status != "verified" for h in active_hypotheses):
|
||||
result.add_warning("active hypotheses exist but none are verified; research step required before exploitation")
|
||||
return result
|
||||
|
||||
|
||||
def _skill_loaded_guard(skill_loaded_file: str) -> CheckResult:
|
||||
result = CheckResult()
|
||||
marker = Path(skill_loaded_file)
|
||||
if not marker.exists() or not marker.is_file():
|
||||
result.add_error("skill load gate: SKILL.md has not been marked as loaded for this session")
|
||||
result.add_info("load with: read_file path=skills/pentest/SKILL.md")
|
||||
result.add_info("then run: python scripts/violin_guard.py check-skill-loaded --eng-dir \"$ENG_DIR\" --session-id \"<session label>\"")
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
def command_tokens(command: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(command, posix=False)
|
||||
except ValueError:
|
||||
return command.split()
|
||||
|
||||
|
||||
def extract_hosts(command: str) -> set[str]:
|
||||
hosts: set[str] = set()
|
||||
for url in re.findall(r"https?://[^\s'\"<>]+", command, flags=re.IGNORECASE):
|
||||
host = host_from_url(url)
|
||||
if host:
|
||||
hosts.add(host)
|
||||
hosts.update(normalize_host(item) for item in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", command))
|
||||
# Suffixes that denote a file/path segment rather than a hostname, so they
|
||||
# are not misclassified as out-of-scope target hosts (e.g. shell.php in a URL).
|
||||
_FILE_SUFFIXES = (
|
||||
".txt", ".md", ".yaml", ".yml", ".json", ".py", ".sh", ".ps1",
|
||||
".php", ".html", ".htm", ".asp", ".aspx", ".js", ".css",
|
||||
".jsp", ".cgi", ".do", ".xml", ".csv",
|
||||
)
|
||||
for host in re.findall(r"\b[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}\b", command):
|
||||
normalized = normalize_host(host)
|
||||
if not normalized.endswith(_FILE_SUFFIXES):
|
||||
hosts.add(normalized)
|
||||
return hosts
|
||||
|
||||
|
||||
def has_allowed_carveout(scope: dict[str, Any], *needles: str) -> bool:
|
||||
allowed = " ".join(str(item).lower() for item in as_list((scope.get("rules_of_engagement") or {}).get("allowed_actions")))
|
||||
return any(needle in allowed for needle in needles)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Shared types, constants, and scope host helpers for the Violin guard package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover - exercised only on missing dependency
|
||||
yaml = None
|
||||
|
||||
# scripts/guard/core.py -> parents[2] == repo root
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Single source of truth for the engagement root. The skill (SKILL.md /
|
||||
# scoping.md) and the violin-guard plugin MUST resolve every engagement
|
||||
# directory against the SAME absolute base, otherwise the two trees diverge
|
||||
# and a stale lock in one tree wedges the other (see root-cause report).
|
||||
#
|
||||
# Resolution order (first match wins):
|
||||
# 1. $VIOLIN_ENG_ROOT - explicit override (absolute or relative-to-cwd)
|
||||
# 2. <repo>/engagements - default canonical location
|
||||
# Engagements are ALWAYS "<host>-<YYYY-MM-DD>" subdirs of ENG_ROOT.
|
||||
ENG_ROOT = Path(os.environ.get("VIOLIN_ENG_ROOT", ROOT / "engagements")).resolve()
|
||||
|
||||
# Backwards-compat alias used by older call sites (bootstrap.py auto-repair
|
||||
# messages etc.). Equal to ENG_ROOT.
|
||||
_REPO_ENGAGEMENTS = ENG_ROOT
|
||||
|
||||
|
||||
def resolve_eng_dir(eng_dir: str | Path | None) -> str:
|
||||
"""Resolve an engagement directory to an ABSOLUTE path under ENG_ROOT.
|
||||
|
||||
The skill builds trees with a relative ``engagements/<host>-<date>`` string
|
||||
while the plugin historically resolved that relative path against Hermes
|
||||
home (CWD), producing two divergent trees. This function makes both entry
|
||||
points converge on the same canonical absolute root no matter what the
|
||||
caller passes:
|
||||
|
||||
- None / "" -> ENG_ROOT itself (useful for listing engagements)
|
||||
- "<host>-<date>" -> ENG_ROOT / "<host>-<date>"
|
||||
- "engagements/..." -> ENG_ROOT / "..." (the relative form the skill
|
||||
used; the ``engagements/``
|
||||
prefix is stripped, not
|
||||
nested, so both trees merge)
|
||||
- absolute path -> passed through unchanged (already explicit)
|
||||
- a path already under ENG_ROOT -> returned as-is
|
||||
|
||||
Returns an absolute ``str`` path.
|
||||
"""
|
||||
eng_dir = "" if eng_dir is None else str(eng_dir).strip()
|
||||
if not eng_dir:
|
||||
return str(ENG_ROOT)
|
||||
p = Path(eng_dir)
|
||||
if p.is_absolute():
|
||||
return str(p.resolve())
|
||||
# Normalise "engagements/foo" and "engagements/foo/bar" -> just the tail,
|
||||
# so the skill's relative form lands in ENG_ROOT, not ENG_ROOT/engagements.
|
||||
parts = p.parts
|
||||
if parts and parts[0] == "engagements":
|
||||
parts = parts[1:]
|
||||
if not parts:
|
||||
return str(ENG_ROOT)
|
||||
return str(ENG_ROOT.joinpath(*parts))
|
||||
|
||||
|
||||
PHASES = {"SCOPING", "RECON", "VULN_RESEARCH", "EXPLOITATION", "REPORTING", "RETROSPECTIVE"}
|
||||
|
||||
TARGET_TOOLS = {
|
||||
"amass",
|
||||
"arjun",
|
||||
"commix",
|
||||
"curl",
|
||||
"dalfox",
|
||||
"dig",
|
||||
"dirb",
|
||||
"dirsearch",
|
||||
"ffuf",
|
||||
"feroxbuster",
|
||||
"gobuster",
|
||||
"host",
|
||||
"httpx",
|
||||
"hydra",
|
||||
"masscan",
|
||||
"nmap",
|
||||
"nikto",
|
||||
"nslookup",
|
||||
"nuclei",
|
||||
"sqlmap",
|
||||
"subfinder",
|
||||
"testssl",
|
||||
"whatweb",
|
||||
"wpscan",
|
||||
"xsstrike",
|
||||
"zap-baseline.py",
|
||||
}
|
||||
|
||||
DANGEROUS_PATTERNS = [
|
||||
(r"\bsqlmap\b.*\s--dump\b", "sqlmap data dumping is blocked by default"),
|
||||
(r"\bsqlmap\b.*\s--os-shell\b", "sqlmap OS shell is blocked"),
|
||||
(r"\bsqlmap\b.*\s--file-(read|write)\b", "sqlmap file read/write is blocked by default"),
|
||||
(r"\bDROP\s+(TABLE|DATABASE)\b", "destructive SQL payload is blocked"),
|
||||
(r"\brm\s+-rf\s+(/|\*)", "destructive filesystem deletion is blocked"),
|
||||
(r"\bmkfs(\.|\s|$)", "filesystem formatting is blocked"),
|
||||
(r"\bdd\s+if=.*\s+of=/dev/", "raw device writes are blocked"),
|
||||
(r"\b(meterpreter|msfvenom)\b", "payload generation or meterpreter requires explicit review"),
|
||||
]
|
||||
|
||||
TIER3_PATTERNS = [
|
||||
(r"\b(hydra|medusa|patator|hashcat|john)\b", "credential attack or cracking tool requires RoE carve-out"),
|
||||
(r"\b(masscan|zmap)\b", "high-volume scanning requires phase approval and rate limits"),
|
||||
(r"\b--rate\s+[1-9]\d{2,}\b", "high request rate requires approval"),
|
||||
(r"\b--threads\s+[5-9]\d*\b", "high concurrency requires approval"),
|
||||
(r"\b--forms\b|\b--crawl\b", "broad authenticated crawling requires approval"),
|
||||
]
|
||||
|
||||
METADATA_TARGETS = {
|
||||
"169.254.169.254",
|
||||
"100.100.100.200",
|
||||
"metadata.google.internal",
|
||||
"fd00:ec2::254",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
infos: list[str] = field(default_factory=list)
|
||||
|
||||
def add_error(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def add_warning(self, message: str) -> None:
|
||||
self.warnings.append(message)
|
||||
|
||||
def add_info(self, message: str) -> None:
|
||||
self.infos.append(message)
|
||||
|
||||
def exit_code(self) -> int:
|
||||
if self.errors:
|
||||
return 1
|
||||
if self.warnings:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
def print(self) -> None:
|
||||
for message in self.errors:
|
||||
print(f"BLOCK: {message}")
|
||||
for message in self.warnings:
|
||||
print(f"REVIEW: {message}")
|
||||
for message in self.infos:
|
||||
print(f"OK: {message}")
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
if yaml is None:
|
||||
raise RuntimeError("PyYAML is required. Install with: python -m pip install pyyaml")
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
|
||||
|
||||
def as_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def normalize_host(value: str) -> str:
|
||||
return value.strip().strip("[]").strip(".").lower()
|
||||
|
||||
|
||||
def validate_scope_data(scope: dict[str, Any]) -> CheckResult:
|
||||
"""Validate an engagement scope dictionary.
|
||||
|
||||
Checks for at least one target, valid CIDRs, and (as warnings) the
|
||||
presence of authorized parties, rules of engagement, and date bounds.
|
||||
"""
|
||||
result = CheckResult()
|
||||
targets = scope.get("targets", {}) or {}
|
||||
exclusions = scope.get("exclusions", {}) or {}
|
||||
|
||||
domains = [normalize_host(d) for d in as_list(targets.get("domains"))]
|
||||
ip_addresses = [normalize_host(i) for i in as_list(targets.get("ip_addresses"))]
|
||||
cidrs = as_list(targets.get("cidrs", []))
|
||||
urls = as_list(targets.get("urls"))
|
||||
|
||||
# normalise and de-duplicate the host set
|
||||
hosts: set[str] = set()
|
||||
hosts.update(domains)
|
||||
hosts.update(ip_addresses)
|
||||
for url in urls:
|
||||
host = host_from_url(str(url))
|
||||
if host:
|
||||
hosts.add(host)
|
||||
for item in cidrs:
|
||||
try:
|
||||
ipaddress.ip_network(item, strict=False)
|
||||
except ValueError:
|
||||
result.add_error(f"scope invalid: cidr is not a valid network: {item}")
|
||||
|
||||
if not hosts and not cidrs:
|
||||
result.add_error("scope invalid: no targets defined in targets.domains / ip_addresses / urls")
|
||||
|
||||
if not as_list(scope.get("authorized_parties")):
|
||||
result.add_warning("scope warning: no authorized_parties listed; confirm authorization before testing")
|
||||
|
||||
if not (scope.get("rules_of_engagement") or {}).get("allowed_actions"):
|
||||
result.add_warning("scope warning: no rules_of_engagement.allowed_actions defined")
|
||||
|
||||
if scope.get("start_date") and scope.get("end_date"):
|
||||
result.add_warning("scope warning: dates present but not range-checked")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def host_from_url(value: str) -> str | None:
|
||||
parsed = urlparse(value if "://" in value else f"//{value}")
|
||||
return normalize_host(parsed.hostname or "")
|
||||
|
||||
|
||||
def get_targets(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]:
|
||||
targets = scope.get("targets", {}) or {}
|
||||
domains = {normalize_host(str(item)) for item in as_list(targets.get("domains")) if str(item).strip()}
|
||||
ip_addresses = {normalize_host(str(item)) for item in as_list(targets.get("ip_addresses")) if str(item).strip()}
|
||||
networks: list[ipaddress._BaseNetwork] = []
|
||||
for item in as_list(targets.get("cidrs")):
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(str(item), strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
url_hosts = {host_from_url(str(item)) for item in as_list(targets.get("urls")) if str(item).strip()}
|
||||
return domains, ip_addresses, networks, {host for host in url_hosts if host}
|
||||
|
||||
|
||||
def get_exclusions(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]:
|
||||
exclusions = scope.get("exclusions", {}) or {}
|
||||
domains = {normalize_host(str(item)) for item in as_list(exclusions.get("domains")) if str(item).strip()}
|
||||
ip_addresses = {normalize_host(str(item)) for item in as_list(exclusions.get("ip_addresses")) if str(item).strip()}
|
||||
networks: list[ipaddress._BaseNetwork] = []
|
||||
for item in as_list(exclusions.get("cidrs")):
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(str(item), strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
url_hosts = {host_from_url(str(item)) for item in as_list(exclusions.get("urls")) if str(item).strip()}
|
||||
return domains, ip_addresses, networks, {host for host in url_hosts if host}
|
||||
|
||||
|
||||
def domain_matches(host: str, domains: set[str]) -> bool:
|
||||
host = normalize_host(host)
|
||||
return any(host == domain or host.endswith(f".{domain}") for domain in domains)
|
||||
|
||||
|
||||
def ip_matches(host: str, addresses: set[str], networks: list[ipaddress._BaseNetwork]) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return host in addresses or any(ip in network for network in networks)
|
||||
|
||||
|
||||
def is_scoped_host(host: str, scope: dict[str, Any]) -> bool:
|
||||
domains, ips, networks, url_hosts = get_targets(scope)
|
||||
return domain_matches(host, domains | url_hosts) or ip_matches(host, ips, networks)
|
||||
|
||||
|
||||
def is_excluded_host(host: str, scope: dict[str, Any]) -> bool:
|
||||
domains, ips, networks, url_hosts = get_exclusions(scope)
|
||||
return domain_matches(host, domains | url_hosts) or ip_matches(host, ips, networks)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Freshness / drift guards for engagement artifacts.
|
||||
|
||||
Closes artifact-discipline gaps in the engagement guards:
|
||||
|
||||
All freshness signals are WARNING-level (exit 2) so they surface drift without
|
||||
hard-blocking a legitimate command; the skill-load *presence* gate is the only
|
||||
ERROR (exit 1) because running target commands without the skill loaded is the
|
||||
Nimbus-class failure the guard exists to prevent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from guard.core import CheckResult
|
||||
|
||||
# Engagement artifacts older than this are flagged as stale (warning, not block).
|
||||
MAX_PTT_AGE_HOURS = 24
|
||||
MAX_HYPOTHESIS_AGE_HOURS = 24
|
||||
MAX_SKILL_MARKER_AGE_HOURS = 24
|
||||
|
||||
_DONE_MARKERS = {"[x]", "[~]", "[!]", "[-]"}
|
||||
_TS_PATTERN = r"(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2})"
|
||||
|
||||
|
||||
def _parse_ts(value: str) -> datetime | None:
|
||||
value = value.strip().replace("T", " ")
|
||||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _age_hours(ts: datetime) -> float:
|
||||
return (datetime.now() - ts).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def check_skill_load_gate(
|
||||
skill_loaded_file: str,
|
||||
mandatory: bool,
|
||||
max_age_hours: int = MAX_SKILL_MARKER_AGE_HOURS,
|
||||
) -> CheckResult:
|
||||
result = CheckResult()
|
||||
marker = Path(skill_loaded_file) if skill_loaded_file else None
|
||||
if not marker or not marker.exists() or not marker.is_file():
|
||||
if mandatory:
|
||||
result.add_error("skill load gate: SKILL.md not marked loaded for this session — load it before any target command")
|
||||
result.add_info("load with: read_file path=skills/pentest/SKILL.md")
|
||||
result.add_info("then run: python scripts/violin_guard.py check-skill-loaded --eng-dir \"$ENG_DIR\" --session-id \"<session label>\"")
|
||||
else:
|
||||
result.add_warning("skill load gate: no --skill-loaded-file/--session-id passed; SKILL.md load not verified")
|
||||
return result
|
||||
ts = _parse_ts_from_mtime(marker)
|
||||
if ts is not None and _age_hours(ts) > max_age_hours:
|
||||
result.add_warning(
|
||||
f"skill load marker is {_age_hours(ts):.0f}h old (>{max_age_hours}h); "
|
||||
f"reload skills/pentest/SKILL.md after context compression / resume"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_ts_from_mtime(path: Path) -> datetime | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def check_ptt_freshness(
|
||||
ptt_path: Path,
|
||||
phase: str,
|
||||
max_age_hours: int = MAX_PTT_AGE_HOURS,
|
||||
) -> CheckResult:
|
||||
result = CheckResult()
|
||||
if not ptt_path.exists() or not ptt_path.is_file():
|
||||
result.add_error(f"PTT missing: {ptt_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"")
|
||||
return result
|
||||
|
||||
text = ptt_path.read_text(encoding="utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
|
||||
# 1) "Last updated" freshness
|
||||
last_updated = None
|
||||
for line in lines:
|
||||
if "last updated" in line.lower():
|
||||
m = __import__("re").search(_TS_PATTERN, line)
|
||||
if m:
|
||||
last_updated = _parse_ts(m.group(1))
|
||||
break
|
||||
if last_updated is None:
|
||||
result.add_warning("PTT has no 'Last updated' timestamp — set it after every tool batch")
|
||||
elif _age_hours(last_updated) > max_age_hours:
|
||||
result.add_warning(
|
||||
f"PTT last updated {_age_hours(last_updated):.0f}h ago (>{max_age_hours}h); "
|
||||
f"update it after every batch (rule: never start a new batch without reading this file)"
|
||||
)
|
||||
|
||||
# 2) Desync: earlier phases all [ ] while later phases have recorded progress.
|
||||
any_done = any(f" {marker} " in text for marker in ("[x]", "[~]", "[!]", "[-]"))
|
||||
if any_done and phase in {"EXPLOITATION", "REPORTING", "RETROSPECTIVE"}:
|
||||
import re
|
||||
phase_sections: list[tuple[str, list[str]]] = []
|
||||
current = None
|
||||
rows: list[str] = []
|
||||
for line in lines:
|
||||
pm = re.match(r"^##\s+Phase:\s*(\w+)", line)
|
||||
if pm:
|
||||
if current is not None:
|
||||
phase_sections.append((current, rows))
|
||||
current = pm.group(1).upper()
|
||||
rows = []
|
||||
elif current is not None and line.strip().startswith("|") and "PT-" in line:
|
||||
rows.append(line)
|
||||
if current is not None:
|
||||
phase_sections.append((current, rows))
|
||||
order = ["SCOPING", "RECON", "VULN_RESEARCH", "EXPLOITATION", "REPORTING", "RETROSPECTIVE"]
|
||||
reached = {p for p, _ in phase_sections if p in order}
|
||||
for p, prows in phase_sections:
|
||||
if p in order and p != "RETROSPECTIVE" and prows:
|
||||
has_progress = any(any(f" {m} " in r for m in _DONE_MARKERS) for r in prows)
|
||||
if not has_progress and order.index(p) < order.index(phase) and p in reached:
|
||||
result.add_warning(
|
||||
f"PTT phase {p} shows all [ ] but later-phase work is recorded as done — "
|
||||
f"mark {p} rows to reflect actual progress"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def check_hypotheses_freshness(
|
||||
hyp_path: Path,
|
||||
phase: str,
|
||||
max_age_hours: int = MAX_HYPOTHESIS_AGE_HOURS,
|
||||
) -> CheckResult:
|
||||
result = CheckResult()
|
||||
if not hyp_path.exists() or not hyp_path.is_file():
|
||||
result.add_error(f"hypotheses.md missing: {hyp_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"")
|
||||
return result
|
||||
|
||||
import re
|
||||
text = hyp_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Split into H-XXX blocks (Active Theories + Resolved Theories)
|
||||
blocks = re.split(r"^###\s+(H-\d+):", text, flags=re.MULTILINE)
|
||||
# blocks: [pre, id1, body1, id2, body2, ...]
|
||||
entries: list[tuple[str, str]] = []
|
||||
for i in range(1, len(blocks), 2):
|
||||
entries.append((blocks[i], blocks[i + 1] if i + 1 < len(blocks) else ""))
|
||||
|
||||
def field(body: str, name: str) -> str:
|
||||
m = re.search(rf"\*\*{name}:\*\*\s*(.+)", body)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
stale_entries = 0
|
||||
contradiction = False
|
||||
for hid, body in entries:
|
||||
status = field(body, "Status")
|
||||
updated = field(body, "Updated")
|
||||
ts = _parse_ts(updated) if updated else None
|
||||
if status in {"Validated", "Rejected"}:
|
||||
if ts is None:
|
||||
result.add_warning(f"hypothesis {hid} is {status} but has no 'Updated' timestamp")
|
||||
stale_entries += 1
|
||||
elif _age_hours(ts) > max_age_hours:
|
||||
result.add_warning(f"hypothesis {hid} ({status}) last updated {_age_hours(ts):.0f}h ago (>{max_age_hours}h)")
|
||||
stale_entries += 1
|
||||
if status == "Candidate":
|
||||
linked = field(body, "Linked findings")
|
||||
if linked and linked.upper().startswith("FIND-"):
|
||||
result.add_warning(f"hypothesis {hid} is Candidate but already links {linked} — promote to Validated/Rejected")
|
||||
contradiction = True
|
||||
|
||||
if phase in {"REPORTING", "RETROSPECTIVE"}:
|
||||
resolved = re.search(r"##\s+Resolved Theories", text)
|
||||
if resolved:
|
||||
tail = text[resolved.start():]
|
||||
if not re.search(r"H-\d+:", tail):
|
||||
result.add_warning("hypotheses.md 'Resolved Theories' is empty at reporting time — record validated/rejected theories")
|
||||
return result
|
||||
|
||||
|
||||
def check_findings_freshness(eng_dir: Path, phase: str) -> CheckResult:
|
||||
result = CheckResult()
|
||||
if phase not in {"VULN_RESEARCH", "EXPLOITATION", "REPORTING", "RETROSPECTIVE"}:
|
||||
return result
|
||||
candidates = [
|
||||
eng_dir / "evidence" / "vuln-research" / "findings.md",
|
||||
eng_dir / "evidence" / "findings.md",
|
||||
eng_dir / "state" / "findings.md",
|
||||
]
|
||||
found = [p for p in candidates if p.exists() and p.is_file()]
|
||||
if not found:
|
||||
result.add_warning("no findings file found (e.g. evidence/vuln-research/findings.md) — record findings as they emerge")
|
||||
return result
|
||||
if all(p.read_text(encoding="utf-8", errors="replace").strip() == "" for p in found):
|
||||
result.add_warning("findings file exists but is empty — populate it as findings are validated")
|
||||
return result
|
||||
@@ -0,0 +1,214 @@
|
||||
"""PTT/history record and staleness guards for the Violin guard package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from guard.core import CheckResult
|
||||
|
||||
# Valid status markers in the PTT (must match templates/ptt.md §Task State Legend)
|
||||
VALID_STATUSES = {"[ ]", "[~]", "[x]", "[!]", "[-]"}
|
||||
# Regex for a PTT row: | PT-016 | [ ] | task text | evidence |
|
||||
_PTT_ROW_RE = re.compile(r"^(\|\s*)(PT-\d+)(\s*\|\s*)\[( |~|x|!|-)\](\s*\|)", re.MULTILINE)
|
||||
|
||||
|
||||
def _find_ptt_row(lines: list[str], pt_id: str) -> tuple[int, re.Match] | None:
|
||||
"""Locate the PTT row for the given PT-XXX id. Returns (line_index, match)."""
|
||||
for idx, line in enumerate(lines):
|
||||
m = _PTT_ROW_RE.match(line)
|
||||
if m and m.group(2) == pt_id:
|
||||
return idx, m
|
||||
return None
|
||||
|
||||
|
||||
def _ptt_is_stale(ptt_path: Path) -> bool:
|
||||
"""A PTT is 'stale' if every PT-XXX row is still in the pristine [ ] state
|
||||
(i.e. no task has ever been touched). Used by check-bootstrap at session
|
||||
resume to surface drift."""
|
||||
if not ptt_path.exists() or not ptt_path.is_file():
|
||||
return False
|
||||
text = ptt_path.read_text(encoding="utf-8")
|
||||
rows = _PTT_ROW_RE.findall(text)
|
||||
if not rows:
|
||||
return False
|
||||
# rows is a list of tuples; group(4) is the status char (space, ~, x, !, -)
|
||||
return all(m[3] == " " for m in rows)
|
||||
|
||||
|
||||
def record_ptt(args: argparse.Namespace) -> int:
|
||||
"""Update a PT-XXX row in the PTT: change its status marker and append a
|
||||
note to the Evidence / Notes column. Also bumps the 'Last updated' footer.
|
||||
|
||||
Required: --eng-dir, --id (PT-XXX), --status (one of [ ] [~] [x] [!] [-])
|
||||
Optional: --note (one-line result; appended to the Evidence column)
|
||||
|
||||
Exit codes:
|
||||
0 = row updated
|
||||
1 = PTT missing, PT-XXX not found, or invalid status marker
|
||||
"""
|
||||
result = CheckResult()
|
||||
eng_dir = Path(args.eng_dir or "")
|
||||
ptt_path = eng_dir / "state" / "ptt.md"
|
||||
pt_id = (args.id or "").strip().upper()
|
||||
new_status_raw = (args.status or "").strip()
|
||||
note = (args.note or "").strip()
|
||||
|
||||
if not eng_dir.exists():
|
||||
result.add_error(f"engagement directory not found: {eng_dir}")
|
||||
result.print()
|
||||
return 1
|
||||
if not ptt_path.exists() or not ptt_path.is_file():
|
||||
result.add_error(f"PTT not found (or is a directory): {ptt_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"")
|
||||
result.print()
|
||||
return 1
|
||||
if not re.fullmatch(r"PT-\d+", pt_id):
|
||||
result.add_error(f"--id must be a PT-XXX identifier (e.g. PT-016), got: {args.id!r}")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
# Normalize status: accept with or without brackets, convert to bracketed form
|
||||
status_char_map = {" ": "[ ]", "~": "[~]", "x": "[x]", "!": "[!]", "-": "[-]"}
|
||||
if new_status_raw in VALID_STATUSES:
|
||||
new_status = new_status_raw
|
||||
elif new_status_raw in status_char_map:
|
||||
new_status = status_char_map[new_status_raw]
|
||||
elif len(new_status_raw) == 1 and new_status_raw in " ~x!-":
|
||||
# Single char like 'x', '~', ' ', '!', '-'
|
||||
new_status = status_char_map.get(new_status_raw, new_status_raw)
|
||||
else:
|
||||
result.add_error(f"--status must be one of {sorted(VALID_STATUSES)} (e.g. '[x]', 'x', '[~]', '~'), got: {new_status_raw!r}")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
lines = ptt_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
located = _find_ptt_row(lines, pt_id)
|
||||
if located is None:
|
||||
result.add_error(f"PT-XXX id {pt_id} not found in {ptt_path}")
|
||||
result.add_info("open the PTT and verify the id exists in the current phase table")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
idx, m = located
|
||||
old_marker = f"[{m.group(4)}]"
|
||||
# Replace the status marker in place
|
||||
new_char = new_status[1] # strip brackets, keep the inner char
|
||||
rebuilt = (
|
||||
m.group(1) + pt_id + m.group(3) + f"[{new_char}]" + m.group(5)
|
||||
)
|
||||
# Preserve the rest of the line (task text + evidence columns)
|
||||
rest_of_line = lines[idx][m.end():]
|
||||
lines[idx] = rebuilt + rest_of_line
|
||||
|
||||
# If a note was provided, append it to the Evidence / Notes column.
|
||||
# We keep everything up to the last "|", then append " — <note>"
|
||||
# before the closing pipe so successive updates chain.
|
||||
if note:
|
||||
raw = lines[idx]
|
||||
eol = "\r\n" if raw.endswith("\r\n") else "\n" if raw.endswith("\n") else ""
|
||||
body = raw.rstrip("\r\n")
|
||||
|
||||
last_pipe = body.rfind("|")
|
||||
if last_pipe > 0:
|
||||
existing = body[:last_pipe].rstrip()
|
||||
sep = " — " if existing and not existing.endswith(" — ") else ""
|
||||
lines[idx] = existing + sep + note + body[last_pipe:] + eol
|
||||
|
||||
# Bump the "Last updated:" footer (last non-empty line starting with *Last updated)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().startswith("*Last updated"):
|
||||
lines[i] = f"*Last updated: {now}*\n"
|
||||
break
|
||||
|
||||
ptt_path.write_text("".join(lines), encoding="utf-8")
|
||||
result.add_info(f"PTT {pt_id} status: {old_marker} → {new_status}" + (f" — {note}" if note else ""))
|
||||
result.print()
|
||||
return 0
|
||||
|
||||
|
||||
def record_history(args: argparse.Namespace) -> int:
|
||||
"""Append a timestamped entry to $ENG_DIR/state/history.md.
|
||||
|
||||
Required: --eng-dir, --command (the shell command that ran), --exit-code (int)
|
||||
Optional: --phase (defaults to UNKNOWN), --evidence (path under $ENG_DIR/evidence/)
|
||||
|
||||
Exit codes:
|
||||
0 = entry appended
|
||||
1 = history.md missing or --command empty
|
||||
"""
|
||||
result = CheckResult()
|
||||
eng_dir = Path(args.eng_dir or "")
|
||||
history_path = eng_dir / "state" / "history.md"
|
||||
command = (args.command or "").strip()
|
||||
phase = (args.phase or "UNKNOWN").strip().upper()
|
||||
evidence = (args.evidence or "").strip()
|
||||
exit_code = args.exit_code
|
||||
|
||||
if not eng_dir.exists():
|
||||
result.add_error(f"engagement directory not found: {eng_dir}")
|
||||
result.print()
|
||||
return 1
|
||||
if not history_path.exists() or not history_path.is_file():
|
||||
result.add_error(f"history.md not found (or is a directory): {history_path}")
|
||||
result.add_info('initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"')
|
||||
result.print()
|
||||
return 1
|
||||
if not command:
|
||||
result.add_error("--command is required (the shell command that was just run)")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
entry = f"- [{ts}] [{phase}] exit={exit_code}"
|
||||
if evidence:
|
||||
entry += f" evidence={evidence}"
|
||||
# Escape any embedded newlines in the command so the table stays one-line-per-entry
|
||||
safe_cmd = command.replace("\n", " ⏎ ")
|
||||
entry += f" `{safe_cmd}`\n"
|
||||
|
||||
with history_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(entry)
|
||||
|
||||
result.add_info(f"history appended: [{phase}] exit={exit_code} `{safe_cmd[:60]}{'…' if len(safe_cmd) > 60 else ''}`")
|
||||
result.print()
|
||||
return 0
|
||||
|
||||
|
||||
def _ptt_staleness_guard(ptt_path: Path) -> CheckResult:
|
||||
result = CheckResult()
|
||||
if not ptt_path.exists() or not ptt_path.is_file():
|
||||
result.add_error(f"PTT missing: {ptt_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/ptt.md \"$ENG_DIR/state/ptt.md\"")
|
||||
return result
|
||||
if _ptt_is_stale(ptt_path):
|
||||
result.add_error("PTT is stale: no PT-XXX row has moved past [ ]; update before advancing")
|
||||
result.add_info("run: python scripts/violin_guard.py record-ptt --eng-dir \"$ENG_DIR\" --id <PT-XXX> --status [~] --note \"<batch result>\"")
|
||||
return result
|
||||
|
||||
|
||||
def _history_staleness_guard(eng_dir: Path, lowered_command: str) -> CheckResult:
|
||||
result = CheckResult()
|
||||
history_path = eng_dir / "state" / "history.md"
|
||||
if not history_path.exists() or not history_path.is_file():
|
||||
result.add_error(f"history.md missing: {history_path}")
|
||||
result.add_info('initialise with: echo "# Command History — $(date +%F)" > "$ENG_DIR/state/history.md"')
|
||||
return result
|
||||
text = history_path.read_text(encoding="utf-8")
|
||||
backtick_commands = re.findall(r"`([^`]+)`", text)
|
||||
if not backtick_commands:
|
||||
# No commands recorded yet (fresh bootstrap). Soft warning, not a block,
|
||||
# so the first target command after bootstrap is not hard-stopped.
|
||||
result.add_warning("history.md has no recorded commands yet; record this command after it runs")
|
||||
return result
|
||||
# NOTE (root-cause fix, issue 2): the single exact-repeat "duplicate
|
||||
# command" warning was removed. It fired on *every* re-issue of a command
|
||||
# the agent had legitimately just run + synced, forcing a
|
||||
# REVIEW -> sync -> REVIEW loop that blocked non-interactive / yolo sessions.
|
||||
# Genuine retry loops are still caught by the hard anti-stuck block in
|
||||
# sync.py (repeat_count >= RETRY_LIMIT) which BLOCKs after 3+ identical
|
||||
# re-issues without progress.
|
||||
return result
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Release-readiness checks for the Violin guard package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from guard.core import ROOT, as_list, load_yaml, CheckResult
|
||||
|
||||
|
||||
def local_markdown_links(path: Path, text: str) -> list[str]:
|
||||
refs: set[str] = set()
|
||||
# Inline backtick references: `path/to/file.md` or `file.md`
|
||||
# Skip anything that looks like a shell command (starts with `cp `, `mkdir `, `cat `, `echo `, `ls `, etc.)
|
||||
shell_command_prefixes = ("cp ", "mkdir ", "cat ", "echo ", "ls ", "cd ", "mv ", "rm ", "touch ", "chmod ", "python", "bash ", "sh ", "tar ", "grep ", "sed ", "awk ", "command ", "export ", "read_file", "write_file", "search_files", "terminal(", "clarify(", "session_search", "skill_view", "delegate_task")
|
||||
for match in re.findall(r"`([^`]+\.md)`", text):
|
||||
candidate = match.strip()
|
||||
# Skip shell command examples
|
||||
if any(candidate.startswith(prefix) for prefix in shell_command_prefixes):
|
||||
continue
|
||||
# Skip runtime paths under $ENG_DIR/ — they only exist per-engagement, not in the repo
|
||||
if "$ENG_DIR" in candidate or "engagements/" in candidate or candidate.startswith("state/") or candidate.startswith("evidence/"):
|
||||
continue
|
||||
# Skip paths that are part of a longer shell command (e.g., "foo.md $ENG_DIR/")
|
||||
if " " in candidate and not candidate.startswith(("./", "/", "skills/", "references/", "playbooks/", "templates/")):
|
||||
continue
|
||||
# Skip bare filenames that look like runtime artifacts
|
||||
if candidate in {"hypotheses.md", "hypothesis-board.md", "ptt.md", "history.md", "phase-summary.md", "scope.yaml"}:
|
||||
continue
|
||||
refs.add(candidate)
|
||||
# Markdown link references: [text](path/to/file.md) — only relative, no scheme
|
||||
for match in re.findall(r"\]\(([^)]+\.md)\)", text):
|
||||
if "://" in match:
|
||||
continue
|
||||
refs.add(match)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def resolve_reference(base: Path, ref: str) -> Path:
|
||||
cleaned = ref.strip().split("#", 1)[0]
|
||||
if "$" in cleaned or "<" in cleaned:
|
||||
return Path()
|
||||
if cleaned.startswith("/"):
|
||||
return ROOT / cleaned.lstrip("/")
|
||||
if cleaned.startswith("skills/") or cleaned in {"README.md", "SOUL.md", "PLAN.md", ".hermes.md"}:
|
||||
return ROOT / cleaned
|
||||
if cleaned.startswith(("playbooks/", "references/")):
|
||||
skill_root = ROOT / "skills/pentest"
|
||||
if base.is_relative_to(skill_root):
|
||||
return base.parent / cleaned
|
||||
return skill_root / cleaned
|
||||
if cleaned.startswith("templates/"):
|
||||
return ROOT / "skills/pentest" / cleaned
|
||||
return base.parent / cleaned
|
||||
|
||||
|
||||
def check_release(_: argparse.Namespace) -> int:
|
||||
result = CheckResult()
|
||||
for yaml_path in ("distribution.yaml", "config.yaml", "skills/pentest/templates/scope-template.yaml"):
|
||||
try:
|
||||
load_yaml(ROOT / yaml_path)
|
||||
result.add_info(f"YAML valid: {yaml_path}")
|
||||
except Exception as exc: # noqa: BLE001 - report any validation failure
|
||||
result.add_error(f"YAML invalid: {yaml_path}: {exc}")
|
||||
|
||||
distribution = load_yaml(ROOT / "distribution.yaml")
|
||||
for item in as_list(distribution.get("distribution_owned")):
|
||||
if not (ROOT / str(item)).exists():
|
||||
result.add_error(f"distribution_owned path missing: {item}")
|
||||
|
||||
playbooks = sorted((ROOT / "skills/pentest/playbooks").glob("*.md"))
|
||||
if len(playbooks) != 31:
|
||||
result.add_error(f"expected 31 playbooks, found {len(playbooks)}")
|
||||
else:
|
||||
result.add_info("31 playbooks present")
|
||||
|
||||
phase_playbooks = {"scoping", "recon", "vuln-research", "exploitation", "reporting", "tools", "post-exploitation"}
|
||||
for playbook in playbooks:
|
||||
text = playbook.read_text(encoding="utf-8")
|
||||
if playbook.stem not in phase_playbooks:
|
||||
for section in ("## Evidence", "## Stop", "## Blocked"):
|
||||
if section not in text:
|
||||
result.add_error(f"{playbook.relative_to(ROOT)} missing {section}")
|
||||
if re.search(r"\./evidence\b|\./report\b", text):
|
||||
result.add_error(f"{playbook.relative_to(ROOT)} contains stale ./evidence or ./report path")
|
||||
|
||||
for md_path in [ROOT / "README.md", ROOT / "SOUL.md", ROOT / ".hermes.md", ROOT / "skills/pentest/SKILL.md", *playbooks]:
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
for ref in local_markdown_links(md_path, text):
|
||||
resolved = resolve_reference(md_path, ref)
|
||||
if str(resolved) == ".":
|
||||
continue
|
||||
if not resolved.exists():
|
||||
result.add_error(f"{md_path.relative_to(ROOT)} references missing markdown file: {ref}")
|
||||
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
if "fully autonomous" in readme.lower():
|
||||
result.add_error("README still claims fully autonomous operation")
|
||||
if "supervised agentic" not in readme.lower():
|
||||
result.add_warning("README does not use supervised agentic positioning")
|
||||
|
||||
if not (ROOT / "scripts/smoke-test.ps1").exists():
|
||||
result.add_error("Windows smoke test missing: scripts/smoke-test.ps1")
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info("release check passed")
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Scope validation for the Violin guard package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from guard.core import as_list, load_yaml, CheckResult, validate_scope_data
|
||||
|
||||
|
||||
def validate_scope(args: argparse.Namespace) -> int:
|
||||
scope_path = Path(args.scope)
|
||||
if not scope_path.exists():
|
||||
result = CheckResult()
|
||||
result.add_error(f"scope file not found: {scope_path}")
|
||||
result.add_info("BOOTSTRAP REQUIRED: run the engagement bootstrap from playbooks/scoping.md §0 before any target interaction")
|
||||
result.print()
|
||||
return 1
|
||||
result = validate_scope_data(load_yaml(scope_path))
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Doc-sync + heartbeat state machine for the Violin guard.
|
||||
|
||||
Single source of truth for the "update your tracking artifacts after every
|
||||
command" enforcement and the periodic coarse review. Used by both the core
|
||||
``violin_guard.py check-command`` path and the violin_guard plugin, so the
|
||||
enforcement is identical no matter which entry point the LLM uses.
|
||||
|
||||
State files live under ``<eng_dir>/state/``:
|
||||
.violin_last_check.json - last approved command (continuity)
|
||||
.violin_pending_sync.json - a command was approved but its artifacts
|
||||
(ptt.md / history.md / hypothesis-board.md)
|
||||
have not yet been verified fresh
|
||||
.violin_heartbeat.json - command + message counters
|
||||
.violin_heartbeat_pending.json - a periodic coarse review is due
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cadence. A doc-sync gate fires after *every* approved target command; a
|
||||
# heartbeat (full engagement-file review) fires every COMMAND_INTERVAL commands
|
||||
# or every MESSAGE_INTERVAL messages.
|
||||
COMMAND_INTERVAL = 5
|
||||
MESSAGE_INTERVAL = 10
|
||||
|
||||
# How many times the exact same command may be re-issued before check-command
|
||||
# hard-blocks it and forces the LLM to stop retrying and do research instead.
|
||||
RETRY_LIMIT = 3
|
||||
|
||||
# A pending-sync lock older than this many hours is treated as stale — almost
|
||||
# certainly a leftover from a *prior* session that approved a command, ran it,
|
||||
# recorded history, but died before calling sync-done. Auto-expire it so a
|
||||
# brand-new session is never wedged by a stale lock (root-cause fix, issue 3).
|
||||
# 12h comfortably spans an active session while expiring next-day leftovers.
|
||||
PENDING_SYNC_TTL_HOURS = 12
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# state dir / paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
def state_dir(eng_dir: str) -> Path:
|
||||
p = Path(eng_dir) / "state"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _last_check_path(eng_dir: str) -> Path:
|
||||
return state_dir(eng_dir) / ".violin_last_check.json"
|
||||
|
||||
|
||||
def _pending_sync_path(eng_dir: str) -> Path:
|
||||
return state_dir(eng_dir) / ".violin_pending_sync.json"
|
||||
|
||||
|
||||
def _heartbeat_count_path(eng_dir: str) -> Path:
|
||||
return state_dir(eng_dir) / ".violin_heartbeat.json"
|
||||
|
||||
|
||||
def _heartbeat_pending_path(eng_dir: str) -> Path:
|
||||
return state_dir(eng_dir) / ".violin_heartbeat_pending.json"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# last approved command (continuity)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def record_ok_check(eng_dir: str, command: str, phase: str) -> None:
|
||||
_last_check_path(eng_dir).write_text(json.dumps({
|
||||
"command": command,
|
||||
"phase": phase,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
|
||||
def last_ok_check(eng_dir: str) -> dict | None:
|
||||
p = _last_check_path(eng_dir)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DOC-SYNC GATE
|
||||
# --------------------------------------------------------------------------- #
|
||||
def mark_pending_sync(eng_dir: str, command: str, phase: str) -> None:
|
||||
"""Called after a command is approved & returned to the operator."""
|
||||
_pending_sync_path(eng_dir).write_text(json.dumps({
|
||||
"command": command,
|
||||
"phase": phase,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
|
||||
def clear_pending_sync(eng_dir: str) -> None:
|
||||
p = _pending_sync_path(eng_dir)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
def _pending_ts(rec: dict) -> float:
|
||||
"""Parse a pending record's ISO-8601 ``ts`` to a UTC epoch, or -1 if unparseable."""
|
||||
s = (rec or {}).get("ts", "")
|
||||
if not s:
|
||||
return -1.0
|
||||
try:
|
||||
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except Exception:
|
||||
return -1.0
|
||||
|
||||
|
||||
def force_clear_pending_sync(eng_dir: str) -> bool:
|
||||
"""Unconditionally clear a pending-sync lock.
|
||||
|
||||
Used for manual reconciliation and at session start (scoping bootstrap calls
|
||||
``sync-clear``) to drop a leftover lock from a previous session that would
|
||||
otherwise wedge the new session (root-cause fix, issue 3).
|
||||
"""
|
||||
p = _pending_sync_path(eng_dir)
|
||||
if p.exists():
|
||||
try:
|
||||
p.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def has_pending_sync(eng_dir: str) -> dict | None:
|
||||
"""Return the pending record if a prior command's artifacts are un-synced.
|
||||
|
||||
ROOT-CAUSE FIX (issue 2): self-heals truly orphaned locks WITHOUT breaking
|
||||
the normal pending flow.
|
||||
|
||||
Normal flow: a command is approved, ``mark_pending_sync`` arms the lock, THEN
|
||||
the LLM runs it and calls ``record-history`` — so for a brief, correct window
|
||||
the pending command is NOT yet in history.md. Clearing the lock in that window
|
||||
would destroy the doc-sync enforcement, so a missing-from-history command is
|
||||
treated as *genuinely pending* (the artifacts_are_fresh gate then decides).
|
||||
|
||||
The lock is only auto-healed (cleared -> None) when it is unambiguously
|
||||
orphaned/stale:
|
||||
- the lock file is corrupt/unreadable (can never gate correctly), OR
|
||||
- state/history.md does not exist at all, meaning NOTHING was ever run in
|
||||
this engagement tree — exactly the incident case (a prior session's lock
|
||||
survived into a tree that was never executed). With no history, the lock
|
||||
can only be a leftover and would otherwise wedge every later session.
|
||||
"""
|
||||
p = _pending_sync_path(eng_dir)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
rec = json.loads(p.read_text())
|
||||
except Exception:
|
||||
# Unreadable lock is treated as stale -> clear and unblock.
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
hist = Path(eng_dir) / "state" / "history.md"
|
||||
if not hist.exists():
|
||||
# No history artifact at all -> nothing was ever run for this
|
||||
# engagement tree -> the pending command was released but never
|
||||
# executed. The lock is a leftover (the incident case) and would
|
||||
# otherwise wedge every later session. Clear it.
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
# TTL auto-expire: a lock older than PENDING_SYNC_TTL_HOURS is a leftover
|
||||
# from a prior session (command recorded in history but sync-done never
|
||||
# called). Expire it so a fresh session is not wedged.
|
||||
age = datetime.now(timezone.utc).timestamp() - _pending_ts(rec)
|
||||
if _pending_ts(rec) > 0 and age > PENDING_SYNC_TTL_HOURS * 3600:
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool:
|
||||
"""Verify the tracking artifacts were updated AFTER the pending command ts.
|
||||
|
||||
Rules:
|
||||
- state/history.md MUST contain the command string (continuity proven).
|
||||
- ptt.md MUST have a 'Last updated:' timestamp >= pending ts.
|
||||
- if phase in {vuln-research, exploitation}: hypothesis-board.md MUST have
|
||||
an 'Updated:' timestamp >= pending ts.
|
||||
Returns True only if all applicable checks pass.
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
def _ts(s: str) -> float:
|
||||
# Normalise every timestamp to an explicit-UTC, tz-aware value so the
|
||||
# comparison is consistent regardless of how it was written:
|
||||
# - pending ts: "2026-07-08T19:23:49.691262+00:00" (ISO, UTC)
|
||||
# - ptt footer: "*Last updated: 2026-07-08 19:29 UTC*"
|
||||
# - history: "- [2026-07-08T19:29:15Z] ..."
|
||||
# - LLM manual: "2026-07-08 19:25" (local wall-clock)
|
||||
# We convert " UTC"/"Z" to "+00:00" and, for bare local wall-clock
|
||||
# stamps, assume UTC (the operator's clock) so the pending/artifact
|
||||
# clocks are compared on the same basis.
|
||||
s = (s or "").strip()
|
||||
if not s:
|
||||
return -1.0
|
||||
# 1) ISO 8601 with optional offset / Z / fractional seconds
|
||||
# e.g. "2026-07-08T19:40:15.760831+00:00", "2026-07-08T19:29:15Z".
|
||||
try:
|
||||
parsed = _dt.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Plain wall-clock with a " UTC" marker, e.g. "2026-07-08 19:32 UTC".
|
||||
s2 = re.sub(r"\bUTC\b", "", s).strip()
|
||||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
parsed = _dt.strptime(s2, fmt)
|
||||
return parsed.replace(tzinfo=timezone.utc).timestamp()
|
||||
except Exception:
|
||||
continue
|
||||
# 3) Unparseable / placeholder stamp (e.g. "<YYYY-MM-DD HH:MM>") is
|
||||
# treated as STALE, never "fresh".
|
||||
return -1.0
|
||||
|
||||
# Strip markdown wrapping (*, **, - ) from a "*Last updated: ...*" style line
|
||||
# and return the bare label (lower) + value, or (None, None) if not a
|
||||
# "last updated"/"updated" field.
|
||||
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?\**\s*(last updated|updated)\s*[:*]\s*\**\s*(.*?)\s*\**\s*$",
|
||||
re.IGNORECASE)
|
||||
|
||||
d = Path(eng_dir)
|
||||
pending_ts = _ts(pending.get("ts", ""))
|
||||
# Artifacts are stamped at minute/second resolution (e.g. record-ptt writes
|
||||
# "%Y-%m-%d %H:%M UTC"), while the pending ts carries microsecond
|
||||
# resolution. Comparing directly would make a same-minute update look
|
||||
# stale, so we floor the pending ts to the minute for the freshness check.
|
||||
pending_min = pending_ts - (pending_ts % 60)
|
||||
# 1) history continuity
|
||||
hist = d / "state" / "history.md"
|
||||
if not (hist.exists() and pending.get("command", "") in hist.read_text(encoding="utf-8", errors="ignore")):
|
||||
return False
|
||||
# 2) ptt freshness (deployed at state/ptt.md)
|
||||
ptt = d / "state" / "ptt.md"
|
||||
if ptt.exists():
|
||||
freshest = 0.0
|
||||
matched = False
|
||||
for line in ptt.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
m = _FIELD_RE.match(line)
|
||||
if m and m.group(1).lower() == "last updated":
|
||||
matched = True
|
||||
freshest = max(freshest, _ts(m.group(2)))
|
||||
if not matched or freshest < pending_min:
|
||||
return False
|
||||
# 3) hypothesis board freshness (research/exploitation phases)
|
||||
# deployed at hypotheses.md (top-level)
|
||||
if pending.get("phase") in ("vuln-research", "exploitation"):
|
||||
hb = d / "hypotheses.md"
|
||||
if hb.exists():
|
||||
freshest = 0.0
|
||||
matched = False
|
||||
for line in hb.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
m = _FIELD_RE.match(line)
|
||||
if m and m.group(1).lower() == "updated":
|
||||
matched = True
|
||||
freshest = max(freshest, _ts(m.group(2)))
|
||||
if not matched or freshest < pending_min:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HEARTBEAT GATE
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _read_counts(eng_dir: str) -> dict:
|
||||
p = _heartbeat_count_path(eng_dir)
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"command_count": 0, "message_count": 0}
|
||||
|
||||
|
||||
def tick_command(eng_dir: str) -> int:
|
||||
"""Increment the approved-command counter; return the new count."""
|
||||
c = _read_counts(eng_dir)
|
||||
c["command_count"] = c.get("command_count", 0) + 1
|
||||
_heartbeat_count_path(eng_dir).write_text(json.dumps(c))
|
||||
return c["command_count"]
|
||||
|
||||
|
||||
def tick_message(eng_dir: str) -> int:
|
||||
"""Increment the message counter (LLM calls this per message); return new count."""
|
||||
c = _read_counts(eng_dir)
|
||||
c["message_count"] = c.get("message_count", 0) + 1
|
||||
_heartbeat_count_path(eng_dir).write_text(json.dumps(c))
|
||||
return c["message_count"]
|
||||
|
||||
|
||||
def set_heartbeat_pending(eng_dir: str, reason: str) -> None:
|
||||
_heartbeat_pending_path(eng_dir).write_text(json.dumps({
|
||||
"reason": reason,
|
||||
"skill_review_required": True,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
|
||||
def has_heartbeat_pending(eng_dir: str) -> dict | None:
|
||||
p = _heartbeat_pending_path(eng_dir)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def clear_heartbeat_pending(eng_dir: str) -> None:
|
||||
p = _heartbeat_pending_path(eng_dir)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# STUCK / RETRY DETECTION
|
||||
# --------------------------------------------------------------------------- #
|
||||
def repeat_count(eng_dir: str, command: str) -> int:
|
||||
"""Count exact occurrences of ``command`` in state/history.md.
|
||||
|
||||
Used by check-command to block retry loops: re-issuing the same command
|
||||
over and over is the classic "stuck" anti-pattern. Returns 0 if history is
|
||||
absent.
|
||||
"""
|
||||
hist = Path(eng_dir) / "state" / "history.md"
|
||||
if not hist.exists():
|
||||
return 0
|
||||
needle = command.strip()
|
||||
if not needle:
|
||||
return 0
|
||||
text = hist.read_text(encoding="utf-8", errors="ignore")
|
||||
return text.count(needle)
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hypothesis-driven recon evidence guard.
|
||||
|
||||
Subcommands:
|
||||
- record-hypothesis Append or update a service-level hypothesis entry.
|
||||
- check-hypothesis Verify at least one researched or verified hypothesis
|
||||
exists for a given service/port combination.
|
||||
|
||||
This guard is intentionally lightweight and file-based:
|
||||
state lives in `$ENG_DIR/hypotheses.md`, with one H-XXX block per theory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Share CheckResult with the guard package (single source of truth).
|
||||
if str(ROOT / "scripts") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from guard.core import CheckResult # noqa: E402
|
||||
|
||||
_HYPOTHESIS_HEADING_RE = re.compile(r"^### (H-\d+):", re.MULTILINE)
|
||||
_FIELD_RE = re.compile(r"^- \*\*(.+?):\*\*\s*(.*)$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hypothesis:
|
||||
id: str
|
||||
status: str = "candidate"
|
||||
phase: str = ""
|
||||
service: str = ""
|
||||
target: str = ""
|
||||
vuln_class: str = ""
|
||||
rationale: str = ""
|
||||
evidence: str = ""
|
||||
updated: str = ""
|
||||
|
||||
|
||||
def _parse_hypotheses(path: Path) -> list[Hypothesis]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
hypotheses: list[Hypothesis] = []
|
||||
sections = list(_HYPOTHESIS_HEADING_RE.split(text))
|
||||
for idx in range(1, len(sections), 2):
|
||||
hyp_id = sections[idx].strip()
|
||||
body = sections[idx + 1]
|
||||
fields: dict[str, str] = {}
|
||||
for name, value in _FIELD_RE.findall(body):
|
||||
fields[name.strip().lower()] = value.strip()
|
||||
hypotheses.append(
|
||||
Hypothesis(
|
||||
id=hyp_id,
|
||||
status=fields.get("status", "candidate").lower(),
|
||||
phase=fields.get("phase", "").lower(),
|
||||
service=fields.get("service", "").lower(),
|
||||
target=fields.get("target", "").lower(),
|
||||
vuln_class=fields.get("vuln class", "").lower(),
|
||||
rationale=fields.get("rationale", "").lower(),
|
||||
evidence=fields.get("evidence", ""),
|
||||
updated=fields.get("updated", ""),
|
||||
)
|
||||
)
|
||||
return hypotheses
|
||||
|
||||
|
||||
# CheckResult is imported from guard.core (single source of truth) — see above.
|
||||
|
||||
|
||||
def _next_id(hypotheses: list[Hypothesis]) -> str:
|
||||
max_id = 0
|
||||
for hypothesis in hypotheses:
|
||||
match = re.fullmatch(r"H-(\d+)", hypothesis.id.upper())
|
||||
if match:
|
||||
max_id = max(max_id, int(match.group(1)))
|
||||
return f"H-{max_id + 1:03d}"
|
||||
|
||||
|
||||
def _status_value(status: str) -> str:
|
||||
normalized = status.strip().lower()
|
||||
allowed = {"candidate", "researching", "verified", "rejected"}
|
||||
if normalized not in allowed:
|
||||
raise ValueError(f"status must be one of {sorted(allowed)}, got: {status!r}")
|
||||
return normalized.capitalize()
|
||||
|
||||
|
||||
def record_hypothesis(args: argparse.Namespace) -> int:
|
||||
result = CheckResult()
|
||||
eng_dir = Path(args.eng_dir or "")
|
||||
hypotheses_path = eng_dir / "hypotheses.md"
|
||||
|
||||
if not eng_dir.exists():
|
||||
result.add_error(f"engagement directory not found: {eng_dir}")
|
||||
result.print()
|
||||
return 1
|
||||
if not hypotheses_path.exists():
|
||||
result.add_error(f"hypotheses.md not found: {hypotheses_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
service = (args.service or "").strip()
|
||||
port = (args.port or "").strip()
|
||||
if not service or not port:
|
||||
result.add_error("--service and --port are required")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
title = (args.title or "").strip() or f"Unnamed hypothesis for {service}:{port}"
|
||||
try:
|
||||
status_value = _status_value(args.status or "candidate")
|
||||
except ValueError as exc:
|
||||
result.add_error(str(exc))
|
||||
result.print()
|
||||
return 1
|
||||
status_value = status_value.capitalize()
|
||||
|
||||
# Resolve the real host string. `--target` wins; otherwise derive the host
|
||||
# from the engagement directory name (expected "<host>-<YYYY-MM-DD>") so we
|
||||
# never record the literal placeholder "<target>", which would never match
|
||||
# a real command's host in `_hypothesis_guard`.
|
||||
target_host = (args.target or "").strip()
|
||||
if not target_host:
|
||||
import re as _re
|
||||
_m = _re.search(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9a-fA-F:]+|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", eng_dir.name)
|
||||
target_host = _m.group(1) if _m else "unknown-host"
|
||||
|
||||
hypotheses = _parse_hypotheses(hypotheses_path)
|
||||
update_id = (args.id or "").strip().upper()
|
||||
target_hyp = None
|
||||
target_index = None
|
||||
for idx, hypothesis in enumerate(hypotheses):
|
||||
if hypothesis.id.upper() == update_id:
|
||||
target_hyp = hypothesis
|
||||
target_index = idx
|
||||
break
|
||||
|
||||
fields = {
|
||||
"status": status_value,
|
||||
"phase": args.phase or target_hyp.phase if target_hyp else (args.phase or "RECON"),
|
||||
"service": service,
|
||||
"target": f"{target_host}:{port}",
|
||||
"vuln class": args.vuln_class or target_hyp.vuln_class if target_hyp else "",
|
||||
"rationale": args.rationale or target_hyp.rationale if target_hyp else "",
|
||||
"evidence": args.evidence or target_hyp.evidence if target_hyp else "",
|
||||
"updated": args.updated or "",
|
||||
}
|
||||
|
||||
if target_hyp is None:
|
||||
new_hyp_id = _next_id(hypotheses)
|
||||
block = (
|
||||
f"\n### {new_hyp_id}: {title}\n"
|
||||
f"- **Status:** {fields['status']}\n"
|
||||
f"- **Phase:** {fields['phase']}\n"
|
||||
f"- **Service:** {service}\n"
|
||||
f"- **Target:** {fields['target']}\n"
|
||||
f"- **Vuln class:** {fields['vuln class']}\n"
|
||||
f"- **Rationale:** {fields['rationale']}\n"
|
||||
f"- **Evidence:** `{fields['evidence']}`\n"
|
||||
f"- **Next step:** <research or validate>\n"
|
||||
f"- **Linked findings:** <none yet>\n"
|
||||
f"- **Updated:** {fields['updated'] or '<YYYY-MM-DD HH:MM>'}\n"
|
||||
)
|
||||
with hypotheses_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(block)
|
||||
result.add_info(f"created hypothesis {new_hyp_id}: {service}:{port} — {title}")
|
||||
else:
|
||||
text = hypotheses_path.read_text(encoding="utf-8")
|
||||
pattern = re.compile(
|
||||
r"### " + re.escape(target_hyp.id) + r":.+?(?=\n### |\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
replacement = (
|
||||
f"### {target_hyp.id}: {title}\n"
|
||||
f"- **Status:** {fields['status']}\n"
|
||||
f"- **Phase:** {fields['phase']}\n"
|
||||
f"- **Service:** {fields['service']}\n"
|
||||
f"- **Target:** {fields['target']}\n"
|
||||
f"- **Vuln class:** {fields['vuln class']}\n"
|
||||
f"- **Rationale:** {fields['rationale']}\n"
|
||||
f"- **Evidence:** `{fields['evidence']}`\n"
|
||||
f"- **Next step:** <update after research>\n"
|
||||
f"- **Linked findings:** <none yet>\n"
|
||||
f"- **Updated:** {fields['updated'] or '<YYYY-MM-DD HH:MM>'}\n"
|
||||
)
|
||||
new_text, subs = pattern.subn(replacement, text)
|
||||
if subs != 1:
|
||||
result.add_error(f"failed to update hypothesis block {target_hyp.id}; match count={subs}")
|
||||
result.print()
|
||||
return 1
|
||||
hypotheses_path.write_text(new_text, encoding="utf-8")
|
||||
result.add_info(f"updated hypothesis {target_hyp.id}: {service}:{port} → {status_value}")
|
||||
|
||||
result.print()
|
||||
return 0
|
||||
|
||||
|
||||
def check_hypothesis(args: argparse.Namespace) -> int:
|
||||
result = CheckResult()
|
||||
eng_dir = Path(args.eng_dir or "")
|
||||
hypotheses_path = eng_dir / "hypotheses.md"
|
||||
|
||||
if not eng_dir.exists():
|
||||
result.add_error(f"engagement directory not found: {eng_dir}")
|
||||
result.print()
|
||||
return 1
|
||||
if not hypotheses_path.exists():
|
||||
result.add_error(f"hypotheses.md not found: {hypotheses_path}")
|
||||
result.add_info("bootstrap with: cp skills/pentest/templates/hypothesis-board.md \"$ENG_DIR/hypotheses.md\"")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
service = (args.service or "").strip().lower()
|
||||
port = (args.port or "").strip()
|
||||
if not service or not port:
|
||||
result.add_error("--service and --port are required")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
try:
|
||||
hypotheses = _parse_hypotheses(hypotheses_path)
|
||||
except Exception as exc: # noqa: BLE001 - file read/parse failure should be explicit
|
||||
result.add_error(f"failed to parse hypotheses.md: {exc}")
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
requires_research = getattr(args, "require_research", False)
|
||||
verified = [
|
||||
hypothesis
|
||||
for hypothesis in hypotheses
|
||||
if hypothesis.service == service
|
||||
and hypothesis.target.endswith(f":{port}")
|
||||
and hypothesis.status in {"researching", "verified"}
|
||||
]
|
||||
|
||||
if not verified:
|
||||
result.add_error(
|
||||
f"HYPOTHESIS REQUIRED: no researching/verified hypothesis for {service}:{port} in {hypotheses_path}"
|
||||
)
|
||||
result.add_info(
|
||||
"run: python scripts/hypothesis_guard.py record-hypothesis "
|
||||
f"--eng-dir \"$ENG_DIR\" --service {service} --port {port} "
|
||||
"--status researching --title \"<short title>\" --rationale \"<why>\""
|
||||
)
|
||||
result.print()
|
||||
return 1
|
||||
|
||||
if requires_research and all(hypothesis.status != "verified" for hypothesis in verified):
|
||||
result.add_warning(f"hypothesis for {service}:{port} is only researching; verified entry required before exploitation")
|
||||
|
||||
result.add_info(
|
||||
f"hypothesis ok: {service}:{port} -> "
|
||||
+ ", ".join(f"{hypothesis.id} ({hypothesis.status})" for hypothesis in verified)
|
||||
)
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Hypothesis evidence guard")
|
||||
subparsers = parser.add_subparsers(dest="command_name", required=True)
|
||||
|
||||
record_parser = subparsers.add_parser("record-hypothesis", help="append or update a hypothesis entry in hypotheses.md")
|
||||
record_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
record_parser.add_argument("--service", required=True, help="service name (e.g. SMB)")
|
||||
record_parser.add_argument("--port", required=True, help="port number (e.g. 445)")
|
||||
record_parser.add_argument("--id", default="", help="existing H-XXX id to update in place")
|
||||
record_parser.add_argument("--title", default="", help="short hypothesis title")
|
||||
record_parser.add_argument("--status", default="candidate", help="candidate|researching|verified|rejected")
|
||||
record_parser.add_argument("--phase", default="RECON", help="phase tag for this hypothesis")
|
||||
record_parser.add_argument("--target", default="", help="host/IP this hypothesis targets (e.g. 10.1.2.3). If omitted, derived from the engagement dir name. NEVER the literal '<target>'.")
|
||||
record_parser.add_argument("--vuln-class", default="", help="vulnerability class (e.g. CVE-2021-44142)")
|
||||
record_parser.add_argument("--rationale", default="", help="why this service is interesting")
|
||||
record_parser.add_argument("--evidence", default="", help="path to supporting evidence")
|
||||
record_parser.add_argument("--updated", default="", help="override updated timestamp")
|
||||
record_parser.set_defaults(func=record_hypothesis)
|
||||
|
||||
check_parser = subparsers.add_parser("check-hypothesis", help="verify researched/verified hypotheses exist for a service:port")
|
||||
check_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
check_parser.add_argument("--service", required=True, help="service name")
|
||||
check_parser.add_argument("--port", required=True, help="port number")
|
||||
check_parser.add_argument("--require-research", action="store_true", help="warn when only researching is present")
|
||||
check_parser.set_defaults(func=check_hypothesis)
|
||||
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return args.func(args)
|
||||
except Exception as exc: # noqa: BLE001 - CLI should fail clearly
|
||||
print(f"BLOCK: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# The container mounts /engagements -> ./engagements/ in the Violin repo,
|
||||
# so evidence and output files written to /engagements/ are accessible
|
||||
# from the host at C:\Users\Hello\repos\violin\engagements\.
|
||||
# from the host at <violin-repo-root>/engagements/.
|
||||
#
|
||||
# If the container isn't running, starts it automatically.
|
||||
|
||||
|
||||
+560
-1
@@ -122,6 +122,557 @@ if [ -n "$stale_in_templates" ]; then
|
||||
pass "Template paths are intentional references, not stale"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3.5 Bootstrap Enforcement — guard hard-blocks unbooted target interaction
|
||||
# =============================================================================
|
||||
header "3.5 Bootstrap Enforcement"
|
||||
|
||||
SMOKE_ENG="engagements/_smoke-bootstrap-$$"
|
||||
rm -rf "$SMOKE_ENG"
|
||||
|
||||
# (a) Unbooted: check-bootstrap must exit 1 with BOOTSTRAP REQUIRED
|
||||
set +e
|
||||
unbooted_output=$(python3 scripts/violin_guard.py check-bootstrap --eng-dir "$SMOKE_ENG" 2>&1)
|
||||
unbooted_exit=$?
|
||||
set -e
|
||||
if [ "$unbooted_exit" -eq 1 ] && echo "$unbooted_output" | grep -q "BOOTSTRAP REQUIRED"; then
|
||||
pass "Unbooted check-bootstrap exits 1 with 'BOOTSTRAP REQUIRED'"
|
||||
else
|
||||
fail "Unbooted check-bootstrap did not behave as expected (exit=$unbooted_exit)"
|
||||
fi
|
||||
|
||||
# (b) Unbooted: check-command with missing scope must exit 1
|
||||
set +e
|
||||
unbooted_cmd=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_ENG/no-scope.yaml" --phase recon --command "curl http://10.129.245.218" 2>&1)
|
||||
unbooted_cmd_exit=$?
|
||||
set -e
|
||||
if [ "$unbooted_cmd_exit" -eq 1 ] && echo "$unbooted_cmd" | grep -q "BOOTSTRAP REQUIRED"; then
|
||||
pass "check-command with missing scope exits 1 with 'BOOTSTRAP REQUIRED'"
|
||||
else
|
||||
fail "check-command with missing scope did not block (exit=$unbooted_cmd_exit)"
|
||||
fi
|
||||
|
||||
# (c) Bootstrapped: check-bootstrap must exit 0
|
||||
mkdir -p "$SMOKE_ENG"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$SMOKE_ENG/state/ptt.md"
|
||||
# Update a PTT row so stale-PTT detection doesn't fire
|
||||
python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_ENG" --id PT-001 --status "[x]" --note "smoke bootstrap" >/dev/null 2>&1
|
||||
cp skills/pentest/templates/hypothesis-board.md "$SMOKE_ENG/hypotheses.md"
|
||||
echo "# Command History — smoke" > "$SMOKE_ENG/state/history.md"
|
||||
cat > "$SMOKE_ENG/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: Smoke
|
||||
tester: smoke
|
||||
date: '2026-07-07'
|
||||
duration: '1h'
|
||||
targets:
|
||||
domains: [nimbus.htb]
|
||||
ip_addresses: [10.129.245.218]
|
||||
app_type: webapp
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing, social-engineering, persistence, stealth-evasion, malware-delivery, destructive-payloads]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-07T00:00:00Z'
|
||||
YAML
|
||||
|
||||
set +e
|
||||
booted_output=$(python3 scripts/violin_guard.py check-bootstrap --eng-dir "$SMOKE_ENG" 2>&1)
|
||||
booted_exit=$?
|
||||
set -e
|
||||
if [ "$booted_exit" -eq 0 ] && echo "$booted_output" | grep -q "bootstrap complete"; then
|
||||
pass "Bootstrapped check-bootstrap exits 0"
|
||||
else
|
||||
fail "Bootstrapped check-bootstrap did not exit 0 (exit=$booted_exit)"
|
||||
fi
|
||||
|
||||
# (d) Bootstrapped in-scope: check-command must exit 0
|
||||
set +e
|
||||
inscope_output=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_ENG/scope/scope.yaml" --phase recon --command "curl http://10.129.245.218" 2>&1)
|
||||
inscope_exit=$?
|
||||
set -e
|
||||
if [ "$inscope_exit" -eq 0 ]; then
|
||||
pass "Bootstrapped in-scope command allowed"
|
||||
else
|
||||
fail "Bootstrapped in-scope command blocked (exit=$inscope_exit)"
|
||||
fi
|
||||
|
||||
# (e) Bootstrapped out-of-scope: check-command must exit 1
|
||||
set +e
|
||||
outofscope_output=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_ENG/scope/scope.yaml" --phase recon --command "curl http://other.example.com" 2>&1)
|
||||
outofscope_exit=$?
|
||||
set -e
|
||||
if [ "$outofscope_exit" -eq 1 ] && echo "$outofscope_output" | grep -q "outside approved scope"; then
|
||||
pass "Bootstrapped out-of-scope command blocked"
|
||||
else
|
||||
fail "Bootstrapped out-of-scope command not blocked (exit=$outofscope_exit)"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$SMOKE_ENG"
|
||||
|
||||
# =============================================================================
|
||||
# 3.6 PTT / History Guard Enforcement
|
||||
# =============================================================================
|
||||
header "3.6 PTT & History Guard Enforcement"
|
||||
|
||||
SMOKE_GUARD="engagements/_smoke-guard-$$"
|
||||
mkdir -p "$SMOKE_GUARD"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$SMOKE_GUARD/state/ptt.md"
|
||||
echo "# Command History — smoke" > "$SMOKE_GUARD/state/history.md"
|
||||
cat > "$SMOKE_GUARD/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: Guard-Smoke
|
||||
tester: smoke
|
||||
date: '2026-07-07'
|
||||
duration: '1h'
|
||||
targets:
|
||||
domains: [nimbus.htb]
|
||||
ip_addresses: [10.129.245.218]
|
||||
app_type: webapp
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-07T00:00:00Z'
|
||||
YAML
|
||||
|
||||
# (a) record-ptt updates a PTT row and exits 0
|
||||
set +e
|
||||
ptt_out=$(python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_GUARD" --id PT-001 --status "[x]" --note "smoke test passed" 2>&1)
|
||||
ptt_exit=$?
|
||||
set -e
|
||||
if [ "$ptt_exit" -eq 0 ] && echo "$ptt_out" | grep -q "PT-001"; then
|
||||
pass "record-ptt updates PT-001 row and exits 0"
|
||||
else
|
||||
fail "record-ptt failed (exit=$ptt_exit): $ptt_out"
|
||||
fi
|
||||
|
||||
# (b) Verify the PTT row actually changed on disk
|
||||
if grep -q 'PT-001.*\[x\]' "$SMOKE_GUARD/state/ptt.md" 2>/dev/null; then
|
||||
pass "PT-001 row shows [x] on disk"
|
||||
else
|
||||
fail "PT-001 row did not update on disk"
|
||||
fi
|
||||
|
||||
# (c) record-ptt with invalid status exits 1
|
||||
set +e
|
||||
bad_ptt=$(python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_GUARD" --id PT-001 --status INVALID 2>&1 || true)
|
||||
bad_ptt_exit=$?
|
||||
set -e
|
||||
if [ "$bad_ptt_exit" -eq 1 ] || echo "$bad_ptt" | grep -qi "invalid"; then
|
||||
pass "record-ptt rejects invalid status marker"
|
||||
else
|
||||
fail "record-ptt did not reject invalid status (exit=$bad_ptt_exit)"
|
||||
fi
|
||||
|
||||
# (d) record-ptt with non-existent PT id exits 1
|
||||
set +e
|
||||
bad_id=$(python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_GUARD" --id PT-999 --status "[~]" 2>&1)
|
||||
bad_id_exit=$?
|
||||
set -e
|
||||
if [ "$bad_id_exit" -eq 1 ] && echo "$bad_id" | grep -qi "not found"; then
|
||||
pass "record-ptt rejects non-existent PT id"
|
||||
else
|
||||
fail "record-ptt did not reject bad PT id (exit=$bad_id_exit)"
|
||||
fi
|
||||
|
||||
# (e) record-history appends a line and exits 0
|
||||
set +e
|
||||
hist_out=$(python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_GUARD" --command "nmap -sV 10.129.245.218" --exit-code 0 --phase RECON 2>&1)
|
||||
hist_exit=$?
|
||||
set -e
|
||||
if [ "$hist_exit" -eq 0 ]; then
|
||||
pass "record-history appends entry and exits 0"
|
||||
else
|
||||
fail "record-history failed (exit=$hist_exit): $hist_out"
|
||||
fi
|
||||
|
||||
# (f) history.md has the new entry on disk
|
||||
if grep -q 'RECON' "$SMOKE_GUARD/state/history.md" 2>/dev/null; then
|
||||
pass "history.md contains the RECON entry"
|
||||
else
|
||||
fail "history.md did not receive the recorded entry"
|
||||
fi
|
||||
|
||||
# (g) record-history with empty command exits 1
|
||||
set +e
|
||||
empty_hist=$(python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_GUARD" --command "" --exit-code 0 2>&1)
|
||||
empty_hist_exit=$?
|
||||
set -e
|
||||
if [ "$empty_hist_exit" -eq 1 ] && echo "$empty_hist" | grep -qi "required"; then
|
||||
pass "record-history rejects empty --command"
|
||||
else
|
||||
fail "record-history did not reject empty command (exit=$empty_hist_exit)"
|
||||
fi
|
||||
|
||||
# (h) Stale-PTT detection: check-bootstrap warns when all rows are pristine
|
||||
# Use a SEPARATE fresh engagement (test (a) already updated PT-001 in $SMOKE_GUARD)
|
||||
STALE_ENG="engagements/_smoke-stale-$$"
|
||||
mkdir -p "$STALE_ENG"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$STALE_ENG/state/ptt.md"
|
||||
cp skills/pentest/templates/hypothesis-board.md "$STALE_ENG/hypotheses.md"
|
||||
echo "# Command History — stale" > "$STALE_ENG/state/history.md"
|
||||
echo "test: ok" > "$STALE_ENG/scope/scope.yaml"
|
||||
set +e
|
||||
stale_out=$(python3 scripts/violin_guard.py check-bootstrap --eng-dir "$STALE_ENG" 2>&1)
|
||||
stale_exit=$?
|
||||
set -e
|
||||
rm -rf "$STALE_ENG"
|
||||
if [ "$stale_exit" -eq 2 ] && echo "$stale_out" | grep -qi "never been updated"; then
|
||||
pass "Stale PTT detection warns on pristine PTT (exit 2)"
|
||||
else
|
||||
fail "Stale-PTT detection unexpected (exit=$stale_exit): $stale_out"
|
||||
fi
|
||||
|
||||
# (i) record-history without a pre-existing history.md does not create one and exits 1
|
||||
rm -f "$SMOKE_GUARD/state/history.md"
|
||||
set +e
|
||||
nohist_out=$(python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_GUARD" --command "test" --exit-code 0 2>&1)
|
||||
nohist_exit=$?
|
||||
set -e
|
||||
if [ "$nohist_exit" -eq 1 ] && echo "$nohist_out" | grep -qi "not found"; then
|
||||
pass "record-history errors when history.md is missing"
|
||||
else
|
||||
fail "record-history did not error on missing history.md (exit=$nohist_exit)"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$SMOKE_GUARD"
|
||||
|
||||
# =============================================================================
|
||||
# 3.7 Freshness & Mandatory Skill-Load Gates
|
||||
# =============================================================================
|
||||
header "3.7 Freshness & Mandatory Skill-Load Gates"
|
||||
|
||||
SMOKE_FRESH="engagements/_smoke-fresh-$$"
|
||||
mkdir -p "$SMOKE_FRESH"/{scope,state,evidence/vuln-research}
|
||||
cp skills/pentest/templates/ptt.md "$SMOKE_FRESH/state/ptt.md"
|
||||
cp skills/pentest/templates/hypothesis-board.md "$SMOKE_FRESH/hypotheses.md"
|
||||
# Replace placeholder hypothesis with a valid one so the hypothesis guard passes
|
||||
cat > "$SMOKE_FRESH/hypotheses.md" <<'MD'
|
||||
# Hypothesis Board
|
||||
|
||||
## Active Theories
|
||||
|
||||
### H-001: web RCE
|
||||
- **Status:** researching
|
||||
- **Phase:** RECON
|
||||
- **Target:** 10.129.45.113
|
||||
- **Vuln class:** RCE
|
||||
- **Rationale:** testing
|
||||
- **Evidence:** evidence/recon/active/
|
||||
- **Next step:** confirm
|
||||
- **Linked findings:** none
|
||||
- **Updated:** $(date '+%Y-%m-%d %H:%M')
|
||||
MD
|
||||
echo "# Command History — fresh" > "$SMOKE_FRESH/state/history.md"
|
||||
python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_FRESH" --command "nmap 10.129.45.113" --exit-code 0 --phase RECON >/dev/null 2>&1
|
||||
# PTT "Last updated" set to now
|
||||
sed -i "s|<YYYY-MM-DD HH:MM>|$(date '+%Y-%m-%d %H:%M')|" "$SMOKE_FRESH/state/ptt.md"
|
||||
# Mark one RECON row done so desync detection has a baseline
|
||||
python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_FRESH" --id PT-016 --status "[x]" --note "nmap done" >/dev/null 2>&1
|
||||
# Findings file present (non-empty) once vuln-research underway
|
||||
echo "## Findings" > "$SMOKE_FRESH/evidence/vuln-research/findings.md"
|
||||
# Skill-load marker for session 'fresh'
|
||||
touch "$SMOKE_FRESH/state/.skill-loaded-fresh"
|
||||
cat > "$SMOKE_FRESH/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: Fresh-Smoke
|
||||
tester: smoke
|
||||
date: '2026-07-08'
|
||||
duration: '1h'
|
||||
targets:
|
||||
domains: [fresh.htb]
|
||||
ip_addresses: [10.129.45.113]
|
||||
app_type: webapp
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-08T00:00:00Z'
|
||||
YAML
|
||||
|
||||
# (a) Target-touching command WITH skill marker + --session-id passes (exit 0)
|
||||
set +e
|
||||
fresh_ok=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_FRESH/scope/scope.yaml" --eng-dir "$SMOKE_FRESH" --session-id fresh --phase recon --command "nmap 10.129.45.113" 2>&1)
|
||||
fresh_ok_exit=$?
|
||||
set -e
|
||||
if [ "$fresh_ok_exit" -ne 1 ]; then
|
||||
pass "Fresh engagement: target command passes skill gate with marker + --session-id (REVIEW warnings allowed)"
|
||||
else
|
||||
fail "Fresh engagement: target command unexpectedly blocked (exit=$fresh_ok_exit): $fresh_ok"
|
||||
fi
|
||||
|
||||
# Clear the doc-sync gate so the next test hits the skill-load gate, not the sync gate
|
||||
python3 scripts/violin_guard.py sync-done --eng-dir "$SMOKE_FRESH" >/dev/null 2>&1
|
||||
|
||||
# (b) Target-touching command WITHOUT --session-id/--skill-loaded-file is BLOCKED (Gap #1 fix)
|
||||
set +e
|
||||
fresh_noskill=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_FRESH/scope/scope.yaml" --eng-dir "$SMOKE_FRESH" --phase recon --command "nmap 10.129.45.113" 2>&1)
|
||||
fresh_noskill_exit=$?
|
||||
set -e
|
||||
if [ "$fresh_noskill_exit" -eq 1 ] && echo "$fresh_noskill" | grep -q "skill load gate"; then
|
||||
pass "Gap #1 fix: target command blocked when skill-load marker missing/omitted"
|
||||
else
|
||||
fail "Gap #1 fix: missing skill-load gate did not block (exit=$fresh_noskill_exit): $fresh_noskill"
|
||||
fi
|
||||
|
||||
# (c) Stale PTT (no 'Last updated') raises REVIEW (exit 2, Gap #2)
|
||||
SMOKE_STALEPTT="engagements/_smoke-staleptt-$$"
|
||||
mkdir -p "$SMOKE_STALEPTT"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$SMOKE_STALEPTT/state/ptt.md"
|
||||
# remove the Last updated line entirely
|
||||
sed -i '/Last updated/d' "$SMOKE_STALEPTT/state/ptt.md"
|
||||
cp skills/pentest/templates/hypothesis-board.md "$SMOKE_STALEPTT/hypotheses.md"
|
||||
# Overwrite with an active (researching) hypothesis so the hypothesis guard passes;
|
||||
# the stale signal we test is the PTT missing 'Last updated', not the hypothesis guard.
|
||||
cat > "$SMOKE_STALEPTT/hypotheses.md" <<'MD'
|
||||
# Hypothesis Board
|
||||
|
||||
## Active Theories
|
||||
|
||||
### H-001: stale ptt test
|
||||
- **Status:** researching
|
||||
- **Phase:** EXPLOITATION
|
||||
- **Target:** 10.129.45.113
|
||||
- **Vuln class:** RCE
|
||||
- **Rationale:** test
|
||||
- **Evidence:** x
|
||||
- **Next step:** confirm
|
||||
- **Linked findings:** none
|
||||
- **Updated:** 2026-07-08 00:00
|
||||
MD
|
||||
echo "# Command History" > "$SMOKE_STALEPTT/state/history.md"
|
||||
python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_STALEPTT" --command "curl 10.129.45.113" --exit-code 0 --phase EXPLOITATION >/dev/null 2>&1
|
||||
python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_STALEPTT" --id PT-040 --status "[~]" --note "exploiting" >/dev/null 2>&1
|
||||
touch "$SMOKE_STALEPTT/state/.skill-loaded-stale"
|
||||
cat > "$SMOKE_STALEPTT/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: StalePTT
|
||||
tester: smoke
|
||||
date: '2026-07-08'
|
||||
duration: '1h'
|
||||
targets:
|
||||
ip_addresses: [10.129.45.113]
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-08T00:00:00Z'
|
||||
YAML
|
||||
set +e
|
||||
stale_ptt_out=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_STALEPTT/scope/scope.yaml" --eng-dir "$SMOKE_STALEPTT" --session-id stale --phase exploitation --command "curl 10.129.45.113" 2>&1)
|
||||
stale_ptt_exit=$?
|
||||
set -e
|
||||
rm -rf "$SMOKE_STALEPTT"
|
||||
if [ "$stale_ptt_exit" -eq 2 ] && echo "$stale_ptt_out" | grep -q "Last updated"; then
|
||||
pass "Gap #2 fix: stale/missing PTT 'Last updated' raises REVIEW"
|
||||
else
|
||||
fail "Gap #2 fix: stale PTT not flagged (exit=$stale_ptt_exit): $stale_ptt_out"
|
||||
fi
|
||||
|
||||
# (d) Stale hypotheses (Candidate linking FIND-) raises REVIEW (exit 2, Gap #3)
|
||||
SMOKE_STALEHYP="engagements/_smoke-stalehyp-$$"
|
||||
mkdir -p "$SMOKE_STALEHYP"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$SMOKE_STALEHYP/state/ptt.md"
|
||||
sed -i "s|<YYYY-MM-DD HH:MM>|$(date '+%Y-%m-%d %H:%M')|" "$SMOKE_STALEHYP/state/ptt.md"
|
||||
# Inject a Candidate hypothesis that already links a finding (contradiction)
|
||||
cat > "$SMOKE_STALEHYP/hypotheses.md" <<'MD'
|
||||
# Hypothesis Board
|
||||
|
||||
## Active Theories
|
||||
|
||||
### H-001: stale candidate
|
||||
- **Status:** Candidate
|
||||
- **Phase:** EXPLOITATION
|
||||
- **Target:** 10.129.45.113
|
||||
- **Vuln class:** RCE
|
||||
- **Rationale:** test
|
||||
- **Evidence:** x
|
||||
- **Next step:** promote
|
||||
- **Linked findings:** FIND-001
|
||||
- **Updated:** 2026-07-08 00:00
|
||||
MD
|
||||
echo "# Command History" > "$SMOKE_STALEHYP/state/history.md"
|
||||
python3 scripts/violin_guard.py record-history --eng-dir "$SMOKE_STALEHYP" --command "curl 10.129.45.113" --exit-code 0 --phase EXPLOITATION >/dev/null 2>&1
|
||||
python3 scripts/violin_guard.py record-ptt --eng-dir "$SMOKE_STALEHYP" --id PT-040 --status "[~]" --note "exploiting" >/dev/null 2>&1
|
||||
touch "$SMOKE_STALEHYP/state/.skill-loaded-sh"
|
||||
cat > "$SMOKE_STALEHYP/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: StaleHyp
|
||||
tester: smoke
|
||||
date: '2026-07-08'
|
||||
duration: '1h'
|
||||
targets:
|
||||
ip_addresses: [10.129.45.113]
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-08T00:00:00Z'
|
||||
YAML
|
||||
set +e
|
||||
stale_hyp_out=$(python3 scripts/violin_guard.py check-command --scope "$SMOKE_STALEHYP/scope/scope.yaml" --eng-dir "$SMOKE_STALEHYP" --session-id sh --phase exploitation --command "curl 10.129.45.113" 2>&1)
|
||||
stale_hyp_exit=$?
|
||||
set -e
|
||||
rm -rf "$SMOKE_STALEHYP"
|
||||
if [ "$stale_hyp_exit" -eq 2 ] && echo "$stale_hyp_out" | grep -q "Candidate but already links"; then
|
||||
pass "Gap #3 fix: Candidate hypothesis linking a finding raises REVIEW"
|
||||
else
|
||||
fail "Gap #3 fix: stale hypothesis not flagged (exit=$stale_hyp_exit): $stale_hyp_out"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$SMOKE_FRESH"
|
||||
|
||||
# =============================================================================
|
||||
# 3.8 Plugin Gate Lifecycle — violin_exec (check-command + doc-sync) & violin_sync_done
|
||||
# =============================================================================
|
||||
header "3.8 Plugin Gate Lifecycle (violin_exec + violin_sync_done)"
|
||||
|
||||
GATES_DIR="engagements/_smoke-gates-$$"
|
||||
mkdir -p "$GATES_DIR"/{scope,state,evidence}
|
||||
cp skills/pentest/templates/ptt.md "$GATES_DIR/state/ptt.md"
|
||||
sed -i "s|<YYYY-MM-DD HH:MM>|$(date '+%Y-%m-%d %H:%M')|" "$GATES_DIR/state/ptt.md"
|
||||
cp skills/pentest/templates/hypothesis-board.md "$GATES_DIR/hypotheses.md"
|
||||
# Seed an active hypothesis so the hypothesis guard passes on fresh engagement.
|
||||
cat > "$GATES_DIR/hypotheses.md" <<'MD'
|
||||
# Hypothesis Board
|
||||
|
||||
## Active Theories
|
||||
|
||||
### H-001: initial recon
|
||||
- **Status:** verified
|
||||
- **Phase:** RECON
|
||||
- **Target:** 10.129.45.113
|
||||
- **Vuln class:** recon
|
||||
- **Rationale:** establishing baseline
|
||||
- **Evidence:** evidence/recon/active/
|
||||
- **Next step:** confirm
|
||||
- **Linked findings:** none
|
||||
- **Updated:** $(date '+%Y-%m-%d %H:%M')
|
||||
MD
|
||||
echo "# Command History" > "$GATES_DIR/state/history.md"
|
||||
touch "$GATES_DIR/state/.skill-loaded-gate"
|
||||
cat > "$GATES_DIR/scope/scope.yaml" <<'YAML'
|
||||
engagement:
|
||||
client: GateSmoke
|
||||
tester: smoke
|
||||
date: '2026-07-08'
|
||||
duration: '1h'
|
||||
targets:
|
||||
ip_addresses: [10.129.45.113]
|
||||
mode: active-recon
|
||||
depth: black-box
|
||||
rules_of_engagement:
|
||||
max_requests_per_second: 5
|
||||
forbidden_actions: [credential-stuffing]
|
||||
authorisation:
|
||||
confirmed: true
|
||||
confirmed_by: smoke
|
||||
confirmed_at: '2026-07-08T00:00:00Z'
|
||||
YAML
|
||||
|
||||
# Seed a valid PTT row (moved past [ ]) — do NOT pre-record the history command,
|
||||
# because the first exec should be a fresh command (no duplicate warning).
|
||||
# The record-history happens AFTER the first exec in the real workflow.
|
||||
python3 scripts/violin_guard.py record-ptt --eng-dir "$GATES_DIR" --id PT-001 --status "[x]" --note "bootstrap" >/dev/null 2>&1
|
||||
# Pre-seed a DIFFERENT command in history so the history guard doesn't fire "no recorded commands" REVIEW
|
||||
python3 scripts/violin_guard.py record-history --eng-dir "$GATES_DIR" --command "curl http://10.129.45.113" --exit-code 0 --phase RECON >/dev/null 2>&1
|
||||
# Note: first exec will be a different command (nmap -sV) so no duplicate warning
|
||||
|
||||
# Drive the plugin handlers directly (the layer that wraps the guard scripts).
|
||||
# This asserts the full lifecycle: fresh exec -> approved -> pending sync locks
|
||||
# the next exec -> sync_done clears -> next exec approved.
|
||||
python3 - "$GATES_DIR" <<'PY'
|
||||
import sys, json, os
|
||||
from datetime import datetime
|
||||
eng_dir = sys.argv[1]
|
||||
repo = os.getcwd()
|
||||
import importlib.util
|
||||
pkg_spec = importlib.util.spec_from_file_location("vgpkg", os.path.join(repo, "plugins/violin_guard/__init__.py"))
|
||||
pkg = importlib.util.module_from_spec(pkg_spec)
|
||||
pkg.__path__ = [os.path.join(repo, "plugins/violin_guard")]
|
||||
pkg.__package__ = "vgpkg"
|
||||
sys.modules["vgpkg"] = pkg
|
||||
def load_sub(name, path):
|
||||
spec = importlib.util.spec_from_file_location(f"vgpkg.{name}", path)
|
||||
m = importlib.util.module_from_spec(spec); m.__package__ = "vgpkg"
|
||||
sys.modules[f"vgpkg.{name}"] = m; spec.loader.exec_module(m); return m
|
||||
tools = load_sub("tools", os.path.join(repo, "plugins/violin_guard/tools.py"))
|
||||
|
||||
def st(handler, **kw):
|
||||
return json.loads(handler(kw))["status"]
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
base = dict(eng_dir=eng_dir, scope=f"{eng_dir}/scope/scope.yaml",
|
||||
phase="recon", command="nmap -sV 10.129.45.113", # Different from any pre-recorded
|
||||
skill_loaded_file=f"{eng_dir}/state/.skill-loaded-gate")
|
||||
|
||||
# Step 1: fresh engagement -> exec must be APPROVED (no pending sync)
|
||||
# Uses a DISTINCT command from any pre-recorded history
|
||||
s1 = st(tools.handle_exec, **base)
|
||||
assert s1 == "approved", f"step1 expected approved, got {s1}"
|
||||
print(" ok: fresh violin_exec -> approved")
|
||||
|
||||
# Step 2: next exec without violin_sync_done -> SYNC_REQUIRED (doc-sync gate)
|
||||
s2 = st(tools.handle_exec, **base)
|
||||
assert s2 == "sync_required", f"step2 expected sync_required, got {s2}"
|
||||
print(" ok: second violin_exec before sync -> sync_required")
|
||||
|
||||
# Step 3: LLM updates artifacts, then violin_sync_done -> OK (freshness verified)
|
||||
with open(f"{eng_dir}/state/history.md", "a") as f:
|
||||
f.write(f"\n- nmap -sV 10.129.45.113 (exit 0) [{now}]\n")
|
||||
pt = f"{eng_dir}/state/ptt.md"
|
||||
t = open(pt).read()
|
||||
open(pt, "w").write(t.replace("Last updated:", f"Last updated: {now}") if "Last updated:" in t else t)
|
||||
s3 = st(tools.handle_sync_done, eng_dir=eng_dir)
|
||||
assert s3 == "ok", f"step3 expected ok, got {s3}"
|
||||
print(" ok: violin_sync_done after artifact update -> ok")
|
||||
|
||||
# Step 4: exec after sync -> APPROVED again (use a DIFFERENT command from step 1/2)
|
||||
base2 = dict(base, command="gobuster dir -u http://10.129.45.113 -w wordlist.txt")
|
||||
s4 = st(tools.handle_exec, **base2)
|
||||
assert s4 == "approved", f"step4 expected approved, got {s4}"
|
||||
print(" ok: violin_exec after sync -> approved")
|
||||
|
||||
# Step 5: hypothesis recording via plugin routes to hypothesis_guard.py
|
||||
s5 = st(tools.handle_record_hypothesis, eng_dir=eng_dir, service="SMB",
|
||||
port="445", title="anon access", status="researching")
|
||||
assert s5 == "ok", f"step5 expected ok, got {s5}"
|
||||
print(" ok: violin_record_hypothesis -> ok (routed to hypothesis_guard.py)")
|
||||
print("GATES_OK")
|
||||
PY
|
||||
gates_exit=$?
|
||||
if [ "$gates_exit" -eq 0 ]; then
|
||||
pass "3.8 Plugin gate lifecycle: exec->sync_required->sync_done->exec + hypothesis routing all correct"
|
||||
else
|
||||
fail "3.8 Plugin gate lifecycle failed (see python output above)"
|
||||
fi
|
||||
|
||||
rm -rf "$GATES_DIR"
|
||||
|
||||
# =============================================================================
|
||||
# 4. Playbook Section Coverage
|
||||
# =============================================================================
|
||||
@@ -225,7 +776,15 @@ else
|
||||
|
||||
# ── Install ──
|
||||
echo " Installing profile as '$SMOKE_PROFILE'..."
|
||||
if hermes profile install "$REPO_ROOT" --name "$SMOKE_PROFILE" -y 2>&1; then
|
||||
# Under Windows git-bash, REPO_ROOT is a /c/... path that Python's pathlib
|
||||
# mangles into \c\... — convert to a native Windows path so `hermes` (Python)
|
||||
# resolves distribution.yaml at the repo root. No-op on native Linux/Kali.
|
||||
if [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* ]]; then
|
||||
INSTALL_SRC="$(cygpath -w "$REPO_ROOT" 2>/dev/null || echo "$REPO_ROOT")"
|
||||
else
|
||||
INSTALL_SRC="$REPO_ROOT"
|
||||
fi
|
||||
if hermes profile install "$INSTALL_SRC" --name "$SMOKE_PROFILE" -y 2>&1; then
|
||||
pass "Profile installed: $SMOKE_PROFILE"
|
||||
else
|
||||
fail "Profile install failed"
|
||||
|
||||
+284
-385
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lightweight Violin scope, command, and release guard.
|
||||
"""Violin lightweight safety and release guard — CLI entrypoint.
|
||||
|
||||
The actual command implementations live in the `guard` package
|
||||
(`scripts/guard/`). This module only owns argument parsing and dispatch, so
|
||||
the guard logic stays in focused, individually-testable modules.
|
||||
|
||||
Exit codes:
|
||||
0 = allowed / valid
|
||||
@@ -10,408 +14,216 @@ Exit codes:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover - exercised only on missing dependency
|
||||
yaml = None
|
||||
# Make the scripts/ directory importable so `guard` and `hypothesis_guard` resolve,
|
||||
# regardless of the caller's working directory.
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from guard.bootstrap import check_bootstrap, check_skill_loaded, init_engagement # noqa: E402
|
||||
from guard.command import check_command # noqa: E402
|
||||
from guard.closeout import check_closeout # noqa: E402
|
||||
from guard.record import record_ptt, record_history, VALID_STATUSES # noqa: E402
|
||||
from guard.release import check_release # noqa: E402
|
||||
from guard.scope import validate_scope # noqa: E402
|
||||
# Single source of truth for the doc-sync + heartbeat + stuck-loop state machine.
|
||||
from guard import sync as sync_state # noqa: E402
|
||||
# Single source of truth for the canonical engagement root + resolver. Every
|
||||
# subcommand that takes --eng-dir now resolves through here so the skill and
|
||||
# the plugin converge on the same absolute tree (root-cause fix).
|
||||
from guard.core import ENG_ROOT, resolve_eng_dir # noqa: E402
|
||||
|
||||
# Importable marker so the plugin and the CLI share identical enforcement logic.
|
||||
__all__ = ["main", "check_command_enforced"]
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PHASES = {"SCOPING", "RECON", "VULN_RESEARCH", "EXPLOITATION", "REPORTING", "RETROSPECTIVE"}
|
||||
def check_command_enforced(args: argparse.Namespace) -> int:
|
||||
"""check-command WITH doc-sync / heartbeat / stuck-loop enforcement.
|
||||
|
||||
TARGET_TOOLS = {
|
||||
"amass",
|
||||
"arjun",
|
||||
"commix",
|
||||
"curl",
|
||||
"dalfox",
|
||||
"dig",
|
||||
"dirb",
|
||||
"dirsearch",
|
||||
"ffuf",
|
||||
"feroxbuster",
|
||||
"gobuster",
|
||||
"host",
|
||||
"httpx",
|
||||
"hydra",
|
||||
"masscan",
|
||||
"nmap",
|
||||
"nikto",
|
||||
"nslookup",
|
||||
"nuclei",
|
||||
"sqlmap",
|
||||
"subfinder",
|
||||
"testssl",
|
||||
"whatweb",
|
||||
"wpscan",
|
||||
"xsstrike",
|
||||
"zap-baseline.py",
|
||||
}
|
||||
This is the path the LLM must use for every target-touching command. It:
|
||||
|
||||
DANGEROUS_PATTERNS = [
|
||||
(r"\bsqlmap\b.*\s--dump\b", "sqlmap data dumping is blocked by default"),
|
||||
(r"\bsqlmap\b.*\s--os-shell\b", "sqlmap OS shell is blocked"),
|
||||
(r"\bsqlmap\b.*\s--file-(read|write)\b", "sqlmap file read/write is blocked by default"),
|
||||
(r"\bDROP\s+(TABLE|DATABASE)\b", "destructive SQL payload is blocked"),
|
||||
(r"\brm\s+-rf\s+(/|\*)", "destructive filesystem deletion is blocked"),
|
||||
(r"\bmkfs(\.|\\s|$)", "filesystem formatting is blocked"),
|
||||
(r"\bdd\s+if=.*\s+of=/dev/", "raw device writes are blocked"),
|
||||
(r"\bnc\s+.*\s-e\s+", "reverse shell payload is blocked"),
|
||||
(r"\bbash\s+-i\b", "interactive reverse shell pattern is blocked"),
|
||||
(r"/dev/tcp/[^/\s]+/\d+", "reverse shell TCP redirection pattern is blocked"),
|
||||
(r"\b(meterpreter|msfvenom)\b", "payload generation or meterpreter requires explicit review"),
|
||||
]
|
||||
1) BLOCKS if a prior approved command's artifacts (ptt.md / history.md /
|
||||
hypothesis-board.md) have not been synced yet (caller must run the
|
||||
command, update the artifacts, then call ``sync-done``).
|
||||
2) BLOCKS if a periodic coarse review (heartbeat) is pending — the LLM
|
||||
must re-read SKILL.md and review the engagement files, then call
|
||||
``heartbeat-done``.
|
||||
3) Runs the normal safety gate (scope, skill-load, PTT/hypothesis
|
||||
freshness, dangerous/tier3 patterns). BLOCK => block.
|
||||
4) On allow: marks a pending-sync, ticks the command counter, and sets a
|
||||
heartbeat lock if the cadence interval was hit.
|
||||
|
||||
TIER3_PATTERNS = [
|
||||
(r"\b(hydra|medusa|patator|hashcat|john)\b", "credential attack or cracking tool requires RoE carve-out"),
|
||||
(r"\b(masscan|zmap)\b", "high-volume scanning requires phase approval and rate limits"),
|
||||
(r"\b--rate\s+[1-9]\d{2,}\b", "high request rate requires approval"),
|
||||
(r"\b--threads\s+[5-9]\d*\b", "high concurrency requires approval"),
|
||||
(r"\b--forms\b|\b--crawl\b", "broad authenticated crawling requires approval"),
|
||||
]
|
||||
The raw ``check_command`` function still exists for non-engagement /
|
||||
pre-bootstrap use; the enforced wrapper is what makes doc completion
|
||||
mandatory rather than advisory.
|
||||
"""
|
||||
eng_dir = args.eng_dir or ""
|
||||
phase = (args.phase or "").lower()
|
||||
command = args.command or ""
|
||||
|
||||
METADATA_TARGETS = {
|
||||
"169.254.169.254",
|
||||
"100.100.100.200",
|
||||
"metadata.google.internal",
|
||||
"fd00:ec2::254",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
infos: list[str] = field(default_factory=list)
|
||||
|
||||
def add_error(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def add_warning(self, message: str) -> None:
|
||||
self.warnings.append(message)
|
||||
|
||||
def add_info(self, message: str) -> None:
|
||||
self.infos.append(message)
|
||||
|
||||
def exit_code(self) -> int:
|
||||
if self.errors:
|
||||
if eng_dir:
|
||||
# 1) doc-sync gate
|
||||
pending = sync_state.has_pending_sync(eng_dir)
|
||||
if pending is not None:
|
||||
print("BLOCK: prior command's artifacts not synced yet.")
|
||||
print(f" pending_command: {pending.get('command')}")
|
||||
print(" ACTION: run the command, update ptt.md 'Last updated:' + state/history.md"
|
||||
" (+ hypothesis-board.md 'Updated:' in vuln-research/exploitation), then call:"
|
||||
" violin_guard.py sync-done --eng-dir \"$ENG_DIR\"")
|
||||
return 1
|
||||
# 2) heartbeat gate
|
||||
hb = sync_state.has_heartbeat_pending(eng_dir)
|
||||
if hb is not None:
|
||||
print("BLOCK: periodic engagement-file review (heartbeat) is pending.")
|
||||
print(f" reason: {hb.get('reason')}")
|
||||
print(" ACTION: re-read skills/pentest/SKILL.md (drift guard + vuln playbooks),"
|
||||
" review scope.yaml / ptt.md / hypotheses.md / history.md for drift, then call:"
|
||||
" violin_guard.py heartbeat-done --eng-dir \"$ENG_DIR\"")
|
||||
return 1
|
||||
# Anti-stuck: re-issuing the exact same command past the limit is the
|
||||
# classic "stuck retrying" anti-pattern. Force research instead.
|
||||
if sync_state.repeat_count(eng_dir, command) >= sync_state.RETRY_LIMIT:
|
||||
print(f"BLOCK: command repeated {sync_state.RETRY_LIMIT}+ times without progress.")
|
||||
print(" ACTION: stop retrying. Record the observation as a hypothesis or note, run a"
|
||||
" different command, or web_search / web_extract for the service's CVEs & exploits"
|
||||
" before re-attempting. Document the change in ptt.md.")
|
||||
return 1
|
||||
if self.warnings:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
def print(self) -> None:
|
||||
for message in self.errors:
|
||||
print(f"BLOCK: {message}")
|
||||
for message in self.warnings:
|
||||
print(f"REVIEW: {message}")
|
||||
for message in self.infos:
|
||||
print(f"OK: {message}")
|
||||
# 3) safety gate
|
||||
rc = check_command(args)
|
||||
|
||||
# 4) on allow/review, arm the next-call gates.
|
||||
# A REVIEW (rc=2) means "allowed but record this" (e.g. history-not-yet-
|
||||
# recorded) — the command still ran, so the LLM MUST sync its artifacts
|
||||
# before the next one. Only a hard BLOCK (rc=1) must NOT arm the gate.
|
||||
if rc in (0, 2) and eng_dir:
|
||||
sync_state.record_ok_check(eng_dir, command, phase)
|
||||
sync_state.mark_pending_sync(eng_dir, command, phase)
|
||||
count = sync_state.tick_command(eng_dir)
|
||||
if count % sync_state.COMMAND_INTERVAL == 0:
|
||||
sync_state.set_heartbeat_pending(
|
||||
eng_dir,
|
||||
f"Reached {count} approved target commands (interval {sync_state.COMMAND_INTERVAL})."
|
||||
" Review engagement files for drift before continuing.",
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
if yaml is None:
|
||||
raise RuntimeError("PyYAML is required. Install with: python -m pip install pyyaml")
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
def cmd_check_closeout(args: argparse.Namespace) -> int:
|
||||
"""Hard gate: verify mandatory REPORTING/RETROSPECTIVE artifacts exist.
|
||||
|
||||
|
||||
def as_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def normalize_host(value: str) -> str:
|
||||
return value.strip().strip("[]").strip(".").lower()
|
||||
|
||||
|
||||
def host_from_url(value: str) -> str | None:
|
||||
parsed = urlparse(value if "://" in value else f"//{value}")
|
||||
return normalize_host(parsed.hostname or "")
|
||||
|
||||
|
||||
def get_targets(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]:
|
||||
targets = scope.get("targets", {}) or {}
|
||||
domains = {normalize_host(str(item)) for item in as_list(targets.get("domains")) if str(item).strip()}
|
||||
ip_addresses = {normalize_host(str(item)) for item in as_list(targets.get("ip_addresses")) if str(item).strip()}
|
||||
networks: list[ipaddress._BaseNetwork] = []
|
||||
for item in as_list(targets.get("cidrs")):
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(str(item), strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
url_hosts = {host_from_url(str(item)) for item in as_list(targets.get("urls")) if str(item).strip()}
|
||||
return domains, ip_addresses, networks, {host for host in url_hosts if host}
|
||||
|
||||
|
||||
def get_exclusions(scope: dict[str, Any]) -> tuple[set[str], set[str], list[ipaddress._BaseNetwork], set[str]]:
|
||||
exclusions = scope.get("exclusions", {}) or {}
|
||||
domains = {normalize_host(str(item)) for item in as_list(exclusions.get("domains")) if str(item).strip()}
|
||||
ip_addresses = {normalize_host(str(item)) for item in as_list(exclusions.get("ip_addresses")) if str(item).strip()}
|
||||
networks: list[ipaddress._BaseNetwork] = []
|
||||
for item in as_list(exclusions.get("cidrs")):
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(str(item), strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
url_hosts = {host_from_url(str(item)) for item in as_list(exclusions.get("urls")) if str(item).strip()}
|
||||
return domains, ip_addresses, networks, {host for host in url_hosts if host}
|
||||
|
||||
|
||||
def domain_matches(host: str, domains: set[str]) -> bool:
|
||||
host = normalize_host(host)
|
||||
return any(host == domain or host.endswith(f".{domain}") for domain in domains)
|
||||
|
||||
|
||||
def ip_matches(host: str, addresses: set[str], networks: list[ipaddress._BaseNetwork]) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return host in addresses or any(ip in network for network in networks)
|
||||
|
||||
|
||||
def is_scoped_host(host: str, scope: dict[str, Any]) -> bool:
|
||||
domains, ips, networks, url_hosts = get_targets(scope)
|
||||
return domain_matches(host, domains | url_hosts) or ip_matches(host, ips, networks)
|
||||
|
||||
|
||||
def is_excluded_host(host: str, scope: dict[str, Any]) -> bool:
|
||||
domains, ips, networks, url_hosts = get_exclusions(scope)
|
||||
return domain_matches(host, domains | url_hosts) or ip_matches(host, ips, networks)
|
||||
|
||||
|
||||
def validate_scope_data(scope: dict[str, Any]) -> CheckResult:
|
||||
result = CheckResult()
|
||||
engagement = scope.get("engagement", {}) or {}
|
||||
targets = scope.get("targets", {}) or {}
|
||||
roe = scope.get("rules_of_engagement", {}) or {}
|
||||
auth = scope.get("authorisation", {}) or {}
|
||||
|
||||
for field_name in ("client", "tester", "date", "duration"):
|
||||
if not str(engagement.get(field_name, "")).strip():
|
||||
result.add_error(f"engagement.{field_name} is required")
|
||||
|
||||
target_values = []
|
||||
for key in ("domains", "ip_addresses", "cidrs", "urls"):
|
||||
target_values.extend(as_list(targets.get(key)))
|
||||
if not any(str(item).strip() for item in target_values):
|
||||
result.add_error("at least one target domain, IP, CIDR, or URL is required")
|
||||
|
||||
if str(targets.get("app_type", "")).strip() == "":
|
||||
result.add_warning("targets.app_type is empty")
|
||||
|
||||
if scope.get("mode") not in {"passive-recon", "active-recon", "standard-pentest", "exploit-validation"}:
|
||||
result.add_error("mode must be passive-recon, active-recon, standard-pentest, or exploit-validation")
|
||||
|
||||
if scope.get("depth") not in {"black-box", "grey-box", "white-box"}:
|
||||
result.add_error("depth must be black-box, grey-box, or white-box")
|
||||
|
||||
try:
|
||||
rate_limit = int(roe.get("max_requests_per_second", 0))
|
||||
if rate_limit <= 0:
|
||||
result.add_error("rules_of_engagement.max_requests_per_second must be positive")
|
||||
elif rate_limit > 20:
|
||||
result.add_warning("rate limit above 20 req/s requires explicit client approval")
|
||||
except (TypeError, ValueError):
|
||||
result.add_error("rules_of_engagement.max_requests_per_second must be an integer")
|
||||
|
||||
forbidden = set(as_list(roe.get("forbidden_actions")))
|
||||
default_forbidden = {
|
||||
"credential-stuffing",
|
||||
"social-engineering",
|
||||
"persistence",
|
||||
"stealth-evasion",
|
||||
"malware-delivery",
|
||||
"destructive-payloads",
|
||||
}
|
||||
missing_forbidden = sorted(default_forbidden - forbidden)
|
||||
if missing_forbidden:
|
||||
result.add_warning(f"default forbidden actions missing: {', '.join(missing_forbidden)}")
|
||||
|
||||
if auth.get("confirmed") is not True:
|
||||
result.add_error("authorisation.confirmed must be true before target interaction")
|
||||
if not str(auth.get("confirmed_by", "")).strip():
|
||||
result.add_error("authorisation.confirmed_by is required")
|
||||
if not str(auth.get("confirmed_at", "")).strip():
|
||||
result.add_error("authorisation.confirmed_at is required")
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info("scope is valid")
|
||||
return result
|
||||
|
||||
|
||||
def validate_scope(args: argparse.Namespace) -> int:
|
||||
result = validate_scope_data(load_yaml(Path(args.scope)))
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
|
||||
|
||||
def command_tokens(command: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(command, posix=False)
|
||||
except ValueError:
|
||||
return command.split()
|
||||
|
||||
|
||||
def extract_hosts(command: str) -> set[str]:
|
||||
hosts: set[str] = set()
|
||||
for url in re.findall(r"https?://[^\s'\"<>]+", command, flags=re.IGNORECASE):
|
||||
host = host_from_url(url)
|
||||
if host:
|
||||
hosts.add(host)
|
||||
hosts.update(normalize_host(item) for item in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", command))
|
||||
for host in re.findall(r"\b[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}\b", command):
|
||||
normalized = normalize_host(host)
|
||||
if not normalized.endswith((".txt", ".md", ".yaml", ".yml", ".json", ".py", ".sh", ".ps1")):
|
||||
hosts.add(normalized)
|
||||
return hosts
|
||||
|
||||
|
||||
def has_allowed_carveout(scope: dict[str, Any], *needles: str) -> bool:
|
||||
allowed = " ".join(str(item).lower() for item in as_list((scope.get("rules_of_engagement") or {}).get("allowed_actions")))
|
||||
return any(needle in allowed for needle in needles)
|
||||
|
||||
|
||||
def check_command(args: argparse.Namespace) -> int:
|
||||
scope = load_yaml(Path(args.scope))
|
||||
result = CheckResult()
|
||||
phase = args.phase.upper().replace("-", "_")
|
||||
command = args.command.strip()
|
||||
lowered = command.lower()
|
||||
|
||||
if phase not in PHASES:
|
||||
result.add_error(f"unknown phase: {args.phase}")
|
||||
|
||||
scope_result = validate_scope_data(scope)
|
||||
if scope_result.errors:
|
||||
result.errors.extend(f"scope invalid: {error}" for error in scope_result.errors)
|
||||
|
||||
for pattern, reason in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, lowered, flags=re.IGNORECASE):
|
||||
result.add_error(reason)
|
||||
|
||||
for pattern, reason in TIER3_PATTERNS:
|
||||
if re.search(pattern, lowered, flags=re.IGNORECASE):
|
||||
if "credential" in reason and has_allowed_carveout(scope, "credential", "brute", "password"):
|
||||
result.add_warning(f"{reason}; RoE carve-out found, require explicit per-command approval")
|
||||
else:
|
||||
result.add_warning(reason)
|
||||
|
||||
tokens = command_tokens(command)
|
||||
tool = Path(tokens[0]).name.lower() if tokens else ""
|
||||
hosts = extract_hosts(command)
|
||||
|
||||
if tool and tool not in TARGET_TOOLS and hosts:
|
||||
result.add_warning(f"command uses unclassified tool '{tool}' against detected target(s)")
|
||||
|
||||
if tool in TARGET_TOOLS and not hosts:
|
||||
result.add_warning("target-touching tool used but no target was detected; ask for review")
|
||||
|
||||
for host in sorted(hosts):
|
||||
if host in METADATA_TARGETS and not has_allowed_carveout(scope, "metadata", "ssrf"):
|
||||
result.add_error(f"cloud metadata target is not scoped by default: {host}")
|
||||
elif is_excluded_host(host, scope):
|
||||
result.add_error(f"target is explicitly excluded: {host}")
|
||||
elif not is_scoped_host(host, scope):
|
||||
result.add_error(f"target is outside approved scope: {host}")
|
||||
|
||||
if phase in {"SCOPING", "REPORTING", "RETROSPECTIVE"} and (hosts or tool in TARGET_TOOLS):
|
||||
result.add_error(f"target interaction is not allowed during {phase}")
|
||||
|
||||
if phase in {"RECON", "VULN_RESEARCH"} and any(term in lowered for term in ("--os-pwn", "--risk=3", "reverse shell")):
|
||||
result.add_error(f"exploit-style command is not allowed during {phase}")
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info("command is allowed by current lightweight guard")
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
|
||||
|
||||
def local_markdown_links(path: Path, text: str) -> list[str]:
|
||||
refs = set(re.findall(r"`([^`]+\.md)`", text))
|
||||
refs.update(match for match in re.findall(r"\]\(([^)]+\.md)\)", text) if "://" not in match)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def resolve_reference(base: Path, ref: str) -> Path:
|
||||
cleaned = ref.strip().split("#", 1)[0]
|
||||
if "$" in cleaned or "<" in cleaned:
|
||||
return Path()
|
||||
if cleaned.startswith("/"):
|
||||
return ROOT / cleaned.lstrip("/")
|
||||
if cleaned.startswith("skills/") or cleaned in {"README.md", "SOUL.md", "PLAN.md", ".hermes.md"}:
|
||||
return ROOT / cleaned
|
||||
if cleaned.startswith(("playbooks/", "references/")):
|
||||
skill_root = ROOT / "skills/pentest"
|
||||
if base.is_relative_to(skill_root):
|
||||
return base.parent / cleaned
|
||||
return skill_root / cleaned
|
||||
if cleaned.startswith("templates/"):
|
||||
return ROOT / "skills/pentest" / cleaned
|
||||
return base.parent / cleaned
|
||||
|
||||
|
||||
def check_release(_: argparse.Namespace) -> int:
|
||||
result = CheckResult()
|
||||
for yaml_path in ("distribution.yaml", "config.yaml", "skills/pentest/templates/scope-template.yaml"):
|
||||
try:
|
||||
load_yaml(ROOT / yaml_path)
|
||||
result.add_info(f"YAML valid: {yaml_path}")
|
||||
except Exception as exc: # noqa: BLE001 - report any validation failure
|
||||
result.add_error(f"YAML invalid: {yaml_path}: {exc}")
|
||||
|
||||
distribution = load_yaml(ROOT / "distribution.yaml")
|
||||
for item in as_list(distribution.get("distribution_owned")):
|
||||
if not (ROOT / str(item)).exists():
|
||||
result.add_error(f"distribution_owned path missing: {item}")
|
||||
|
||||
playbooks = sorted((ROOT / "skills/pentest/playbooks").glob("*.md"))
|
||||
if len(playbooks) != 31:
|
||||
result.add_error(f"expected 31 playbooks, found {len(playbooks)}")
|
||||
Returns exit 1 (denied — NOT auto-approved under --yolo) when a mandated
|
||||
artifact is missing for the given phase. Pass --command for the
|
||||
artifact-producing command so it is exempted (no deadlock).
|
||||
"""
|
||||
res = check_closeout(args.eng_dir, args.phase, args.command or "")
|
||||
if res.errors or res.warnings:
|
||||
res.print()
|
||||
else:
|
||||
result.add_info("31 playbooks present")
|
||||
print("OK: close-out artifacts satisfied.")
|
||||
return res.exit_code()
|
||||
|
||||
phase_playbooks = {"scoping", "recon", "vuln-research", "exploitation", "reporting", "tools", "post-exploitation"}
|
||||
for playbook in playbooks:
|
||||
text = playbook.read_text(encoding="utf-8")
|
||||
if playbook.stem not in phase_playbooks:
|
||||
for section in ("## Evidence", "## Stop", "## Blocked"):
|
||||
if section not in text:
|
||||
result.add_error(f"{playbook.relative_to(ROOT)} missing {section}")
|
||||
if re.search(r"\./evidence\b|\./report\b", text):
|
||||
result.add_error(f"{playbook.relative_to(ROOT)} contains stale ./evidence or ./report path")
|
||||
|
||||
for md_path in [ROOT / "README.md", ROOT / "SOUL.md", ROOT / ".hermes.md", ROOT / "skills/pentest/SKILL.md", *playbooks]:
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
for ref in local_markdown_links(md_path, text):
|
||||
resolved = resolve_reference(md_path, ref)
|
||||
if str(resolved) == ".":
|
||||
continue
|
||||
if not resolved.exists():
|
||||
result.add_error(f"{md_path.relative_to(ROOT)} references missing markdown file: {ref}")
|
||||
def cmd_sync_done(args: argparse.Namespace) -> int:
|
||||
"""Verify the prior command's artifacts are fresh; clear the sync lock."""
|
||||
eng_dir = args.eng_dir or ""
|
||||
pending = sync_state.has_pending_sync(eng_dir)
|
||||
if pending is None:
|
||||
print("OK: nothing pending — artifacts already in sync.")
|
||||
return 0
|
||||
if sync_state.artifacts_are_fresh(eng_dir, pending):
|
||||
sync_state.clear_pending_sync(eng_dir)
|
||||
print("OK: artifacts verified fresh. Next target command allowed.")
|
||||
return 0
|
||||
|
||||
# Provide actionable guidance on what specifically is stale
|
||||
pending_ts = pending.get("ts", "")
|
||||
pending_cmd = pending.get("command", "")
|
||||
pending_phase = pending.get("phase", "")
|
||||
print("REVIEW: artifacts not yet updated to the prior command's timestamp.")
|
||||
print(f" pending_command: {pending_cmd}")
|
||||
print(f" pending_phase: {pending_phase}")
|
||||
print(f" pending_ts: {pending_ts}")
|
||||
print()
|
||||
print(" ACTION REQUIRED: Update ALL of the following after running the command:")
|
||||
print(" 1) ptt.md (state/ptt.md): Update the relevant PT-XXX row status AND")
|
||||
print(" bump the '*Last updated: YYYY-MM-DD HH:MM UTC*' footer")
|
||||
print(" 2) history.md (state/history.md): record-history with the EXACT command string")
|
||||
print(" 3) hypotheses.md (top-level, for vuln-research/exploitation phases):")
|
||||
print(" Update the 'Updated: YYYY-MM-DD HH:MM' field for active hypotheses")
|
||||
print()
|
||||
print(" Example workflow after running a command:")
|
||||
print(" python scripts/violin_guard.py record-history --eng-dir \"$ENG_DIR\" \\")
|
||||
print(" --command \"<exact command>\" --exit-code <N> --phase <PHASE>")
|
||||
print(" python scripts/violin_guard.py record-ptt --eng-dir \"$ENG_DIR\" \\")
|
||||
print(" --id PT-XXX --status \"[~]\" --note \"<result summary>\"")
|
||||
print(" python scripts/violin_guard.py sync-done --eng-dir \"$ENG_DIR\"")
|
||||
return 2
|
||||
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
if "fully autonomous" in readme.lower():
|
||||
result.add_error("README still claims fully autonomous operation")
|
||||
if "supervised agentic" not in readme.lower():
|
||||
result.add_warning("README does not use supervised agentic positioning")
|
||||
|
||||
if not (ROOT / "scripts/smoke-test.ps1").exists():
|
||||
result.add_error("Windows smoke test missing: scripts/smoke-test.ps1")
|
||||
def cmd_sync_clear(args: argparse.Namespace) -> int:
|
||||
"""Force-clear a pending-sync lock regardless of artifact freshness.
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
result.add_info("release check passed")
|
||||
result.print()
|
||||
return result.exit_code()
|
||||
Session-start reconciliation: a prior session may have approved a command,
|
||||
run it, recorded history, but exited before calling ``sync-done``. That
|
||||
leftover lock would otherwise BLOCK the first command of the new session
|
||||
(root-cause fix, issue 3). ``sync-clear`` drops it unconditionally.
|
||||
"""
|
||||
eng_dir = args.eng_dir or ""
|
||||
cleared = sync_state.force_clear_pending_sync(eng_dir)
|
||||
if cleared:
|
||||
print("OK: pending-sync lock force-cleared.")
|
||||
else:
|
||||
print("OK: no pending-sync lock to clear.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_heartbeat_done(args: argparse.Namespace) -> int:
|
||||
"""Clear the pending heartbeat review lock (LLM self-attests the review)."""
|
||||
eng_dir = args.eng_dir or ""
|
||||
hb = sync_state.has_heartbeat_pending(eng_dir)
|
||||
if hb is None:
|
||||
print("OK: no heartbeat review pending.")
|
||||
return 0
|
||||
sync_state.clear_heartbeat_pending(eng_dir)
|
||||
print("OK: heartbeat review cleared. Re-read of pentest SKILL.md and engagement-file"
|
||||
" review complete — target commands allowed.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_message_tick(args: argparse.Namespace) -> int:
|
||||
"""LLM-opt-in: tick the message counter; set a heartbeat lock on interval."""
|
||||
eng_dir = args.eng_dir or ""
|
||||
count = sync_state.tick_message(eng_dir)
|
||||
if count % sync_state.MESSAGE_INTERVAL == 0:
|
||||
sync_state.set_heartbeat_pending(
|
||||
eng_dir,
|
||||
f"Reached {count} messages (interval {sync_state.MESSAGE_INTERVAL})."
|
||||
" Review engagement files for drift before continuing.",
|
||||
)
|
||||
print(f"OK: message_count={count}; heartbeat triggered — next command requires heartbeat-done.")
|
||||
return 2
|
||||
print(f"OK: message_count={count}.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_eng_root(args: argparse.Namespace) -> int:
|
||||
"""Print the canonical engagement root and resolve an eng_dir to absolute.
|
||||
|
||||
The skill/scoping bootstrap calls this to obtain an ABSOLUTE ENG_DIR that
|
||||
the violin-guard plugin will resolve identically. When --eng-dir is given, prints the
|
||||
resolved absolute path; otherwise prints just ENG_ROOT.
|
||||
"""
|
||||
if args.eng_dir:
|
||||
resolved = resolve_eng_dir(args.eng_dir)
|
||||
print(f"ENG_ROOT={ENG_ROOT}")
|
||||
print(f"ENG_DIR={resolved}")
|
||||
else:
|
||||
print(f"ENG_ROOT={ENG_ROOT}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -422,16 +234,103 @@ def main() -> int:
|
||||
scope_parser.add_argument("--scope", required=True)
|
||||
scope_parser.set_defaults(func=validate_scope)
|
||||
|
||||
command_parser = subparsers.add_parser("check-command", help="check a target-touching terminal command")
|
||||
command_parser = subparsers.add_parser("check-command", help="check a target-touching terminal command (enforced: blocks until prior artifacts synced + periodic review done)")
|
||||
command_parser.add_argument("--scope", required=True)
|
||||
command_parser.add_argument("--phase", required=True)
|
||||
command_parser.add_argument("--command", required=True)
|
||||
command_parser.set_defaults(func=check_command)
|
||||
command_parser.add_argument("--eng-dir", default="", help="engagement directory; enables doc-sync/heartbeat/stuck-loop enforcement")
|
||||
command_parser.add_argument("--skill-loaded-file", default="", help="skill-load marker path; when set, missing marker blocks the command")
|
||||
command_parser.add_argument("--session-id", default="", help="current session or goal label; when set, --skill-loaded-file must encode the same session id")
|
||||
command_parser.set_defaults(func=check_command_enforced)
|
||||
|
||||
closeout_parser = subparsers.add_parser(
|
||||
"check-closeout",
|
||||
help="hard gate: verify mandatory REPORTING/RETROSPECTIVE artifacts exist "
|
||||
"(report.md, retrospective.md, phase-summary.md, CVSS:3.1, Research Log). "
|
||||
"Missing artifacts BLOCK even under --yolo.",
|
||||
)
|
||||
closeout_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
closeout_parser.add_argument("--phase", required=True, help="REPORTING or RETROSPECTIVE")
|
||||
closeout_parser.add_argument("--command", default="", help="artifact-producing command (exempts the gate)")
|
||||
closeout_parser.set_defaults(func=cmd_check_closeout)
|
||||
|
||||
sync_parser = subparsers.add_parser("sync-done", help="call AFTER updating ptt.md/history.md/hypothesis-board.md for the last approved command; verifies freshness and unlocks the next command")
|
||||
sync_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
sync_parser.set_defaults(func=cmd_sync_done)
|
||||
|
||||
sync_clear_parser = subparsers.add_parser(
|
||||
"sync-clear",
|
||||
help="force-clear a stale pending-sync lock (use at session start to drop a "
|
||||
"leftover lock from a prior session that died before sync-done)",
|
||||
)
|
||||
sync_clear_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
sync_clear_parser.set_defaults(func=cmd_sync_clear)
|
||||
|
||||
heartbeat_parser = subparsers.add_parser("heartbeat-done", help="call AFTER re-reading SKILL.md + reviewing engagement files on the cadence; clears the heartbeat lock")
|
||||
heartbeat_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
heartbeat_parser.set_defaults(func=cmd_heartbeat_done)
|
||||
|
||||
tick_parser = subparsers.add_parser("message-tick", help="LLM-opt-in: call once per assistant message; sets a heartbeat lock every MESSAGE_INTERVAL messages")
|
||||
tick_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
tick_parser.set_defaults(func=cmd_message_tick)
|
||||
|
||||
eng_root_parser = subparsers.add_parser(
|
||||
"eng-root",
|
||||
help="print the canonical engagement root (ENG_ROOT) and resolve a given "
|
||||
"engagement directory to its absolute path under it; used by the "
|
||||
"skill/scoping bootstrap to build an ABSOLUTE ENG_DIR that matches "
|
||||
"the plugin (root-cause fix for divergent engagement trees)",
|
||||
)
|
||||
eng_root_parser.add_argument(
|
||||
"--eng-dir", default="",
|
||||
help="optional engagement dir to resolve (e.g. '10.129.46.56-2026-07-08' "
|
||||
"or 'engagements/10.129.46.56-2026-07-08'); if omitted, prints ENG_ROOT",
|
||||
)
|
||||
eng_root_parser.set_defaults(func=cmd_eng_root)
|
||||
|
||||
bootstrap_parser = subparsers.add_parser("check-bootstrap", help="verify engagement bootstrap is complete (scope, PTT, hypothesis board, history exist)")
|
||||
bootstrap_parser.add_argument("--eng-dir", default="", help="engagement directory (ENG_DIR); pass explicitly or export as env var")
|
||||
bootstrap_parser.add_argument("--auto-repair", action="store_true", help="if a required bootstrap artifact is a directory (LLM bootstrap drift), remove it and re-create from the canonical template")
|
||||
bootstrap_parser.set_defaults(func=check_bootstrap)
|
||||
|
||||
init_parser = subparsers.add_parser("init-engagement", help="auto-create a complete, guard-clean engagement directory from templates")
|
||||
init_parser.add_argument("--eng-dir", required=True, help="engagement directory to create (name should contain the host, e.g. engagements/10.129.45.228-2026-07-08)")
|
||||
init_parser.add_argument("--host", default="", help="target host/IP to pre-fill in scope.yaml; if omitted, derived from --eng-dir name")
|
||||
init_parser.set_defaults(func=lambda a: init_engagement(a.eng_dir, host=a.host))
|
||||
|
||||
release_parser = subparsers.add_parser("check-release", help="validate release readiness")
|
||||
release_parser.set_defaults(func=check_release)
|
||||
|
||||
ptt_parser = subparsers.add_parser("record-ptt", help="update a PT-XXX row in the PTT")
|
||||
ptt_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
ptt_parser.add_argument("--id", required=True, help="PT-XXX id (e.g. PT-016)")
|
||||
ptt_parser.add_argument("--status", required=True, choices=sorted(VALID_STATUSES), help="new status marker")
|
||||
ptt_parser.add_argument("--note", default="", help="one-line note appended to Evidence column")
|
||||
ptt_parser.set_defaults(func=record_ptt)
|
||||
|
||||
skill_parser = subparsers.add_parser("check-skill-loaded", help="mark SKILL.md as read for the current session/work-block")
|
||||
skill_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
skill_parser.add_argument("--session-id", required=True, help="session or goal label, used in marker filename")
|
||||
skill_parser.add_argument("--skill-loaded-file", default="", help="write marker to explicit path; default: $ENG_DIR/state/.skill-loaded-<session-id>")
|
||||
skill_parser.set_defaults(func=check_skill_loaded)
|
||||
|
||||
history_parser = subparsers.add_parser("record-history", help="append a timestamped entry to history.md")
|
||||
history_parser.add_argument("--eng-dir", required=True, help="engagement directory")
|
||||
history_parser.add_argument("--command", required=True, help="shell command that was just run")
|
||||
history_parser.add_argument("--exit-code", required=True, type=int, help="exit code of the command")
|
||||
history_parser.add_argument("--phase", default="UNKNOWN", help="phase tag (default: UNKNOWN)")
|
||||
history_parser.add_argument("--evidence", default="", help="evidence path under $ENG_DIR/evidence/")
|
||||
history_parser.set_defaults(func=record_history)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# ROOT-CAUSE FIX (issue 1): resolve every --eng-dir through a single source
|
||||
# of truth so the skill's relative "engagements/..." form and an absolute
|
||||
# path both land on the same canonical tree under ENG_ROOT. Subcommands that
|
||||
# take --eng-dir read args.eng_dir AFTER this point.
|
||||
if getattr(args, "eng_dir", None):
|
||||
args.eng_dir = resolve_eng_dir(args.eng_dir)
|
||||
|
||||
try:
|
||||
return args.func(args)
|
||||
except Exception as exc: # noqa: BLE001 - CLI should fail clearly
|
||||
|
||||
Reference in New Issue
Block a user