mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
Merge P0 PTT hypothesis phase enforcement
This commit is contained in:
@@ -334,7 +334,9 @@ def _matches_network(candidate: str, networks: list[ipaddress._BaseNetwork]) ->
|
|||||||
network = ipaddress.ip_network(candidate, strict=False)
|
network = ipaddress.ip_network(candidate, strict=False)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
return any(network.version == allowed.version and network.subnet_of(allowed) for allowed in networks)
|
return any(
|
||||||
|
network.version == allowed.version and network.subnet_of(allowed) for allowed in networks
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_scope_targets(scope_path: Path, command: str) -> CheckResult:
|
def check_scope_targets(scope_path: Path, command: str) -> CheckResult:
|
||||||
@@ -447,6 +449,31 @@ def check_hypothesis_freshness(eng_dir: Path, phase: Phase, command: str) -> Hyp
|
|||||||
result.add_error(f"phase {phase.value} requires at least one hypothesis in hypotheses.md")
|
result.add_error(f"phase {phase.value} requires at least one hypothesis in hypotheses.md")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
acceptable_phases = {
|
||||||
|
Phase.VULN_RESEARCH: {Phase.VULN_RESEARCH},
|
||||||
|
Phase.EXPLOITATION: {Phase.VULN_RESEARCH, Phase.EXPLOITATION},
|
||||||
|
Phase.POST_EXPLOITATION: {Phase.EXPLOITATION, Phase.POST_EXPLOITATION},
|
||||||
|
Phase.PRIVESC: {Phase.EXPLOITATION, Phase.POST_EXPLOITATION, Phase.PRIVESC},
|
||||||
|
Phase.FLAGS: {Phase.PRIVESC, Phase.FLAGS},
|
||||||
|
}.get(phase, {phase})
|
||||||
|
targets = set(_extract_target_candidates(command))
|
||||||
|
relevant = []
|
||||||
|
for hypothesis in hyps:
|
||||||
|
if hypothesis.canonical_status() == "Rejected" or not hypothesis.target:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
hypothesis_phase = normalize_phase(hypothesis.phase)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
target = _normalise_scope_host(hypothesis.target)
|
||||||
|
if hypothesis_phase in acceptable_phases and (not targets or target in targets):
|
||||||
|
relevant.append(hypothesis)
|
||||||
|
if not relevant:
|
||||||
|
result.add_error(
|
||||||
|
f"phase {phase.value} requires a non-rejected hypothesis matching the command target"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
# Check for stale hypotheses (no update in 48h)
|
# Check for stale hypotheses (no update in 48h)
|
||||||
stale = 0
|
stale = 0
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -529,6 +556,12 @@ def check_command(args: CheckCommandArgs) -> CheckResult:
|
|||||||
result.warnings.extend(ptt_validation.warnings)
|
result.warnings.extend(ptt_validation.warnings)
|
||||||
if ptt_validation.active_task:
|
if ptt_validation.active_task:
|
||||||
result.infos.append(f"active PTT task: {ptt_validation.active_task}")
|
result.infos.append(f"active PTT task: {ptt_validation.active_task}")
|
||||||
|
active_task = ptt.find_active_task(ptt_validation.tasks)
|
||||||
|
if active_task and not ptt.task_matches_phase(active_task, phase):
|
||||||
|
result.add_error(
|
||||||
|
f"active PTT task {active_task.id} belongs to {active_task.phase or 'no phase'}; "
|
||||||
|
f"requested phase is {phase.value}"
|
||||||
|
)
|
||||||
|
|
||||||
# 5. History staleness (duplicate detection)
|
# 5. History staleness (duplicate detection)
|
||||||
hist_result = check_history_staleness(eng_dir, args.command)
|
hist_result = check_history_staleness(eng_dir, args.command)
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .phases import Phase, normalize_phase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PttTask",
|
"PttTask",
|
||||||
"PttValidationResult",
|
"PttValidationResult",
|
||||||
"parse_ptt",
|
"parse_ptt",
|
||||||
"validate_ptt",
|
"validate_ptt",
|
||||||
"find_active_task",
|
"find_active_task",
|
||||||
|
"task_matches_phase",
|
||||||
"is_stale",
|
"is_stale",
|
||||||
"update_task",
|
"update_task",
|
||||||
]
|
]
|
||||||
@@ -43,6 +46,7 @@ class PttTask:
|
|||||||
title: str
|
title: str
|
||||||
note: str = ""
|
note: str = ""
|
||||||
updated: str = ""
|
updated: str = ""
|
||||||
|
phase: str = ""
|
||||||
|
|
||||||
def to_markdown(self) -> str:
|
def to_markdown(self) -> str:
|
||||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
|
datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
|
||||||
@@ -76,7 +80,14 @@ def parse_ptt(path: Path) -> list[PttTask]:
|
|||||||
return []
|
return []
|
||||||
content = path.read_text(encoding="utf-8")
|
content = path.read_text(encoding="utf-8")
|
||||||
tasks = []
|
tasks = []
|
||||||
|
current_phase = ""
|
||||||
for line in content.splitlines():
|
for line in content.splitlines():
|
||||||
|
heading = re.match(r"^##\s+Phase:\s*(?P<phase>.+?)\s*$", line.strip(), re.IGNORECASE)
|
||||||
|
if heading:
|
||||||
|
current_phase = (
|
||||||
|
heading.group("phase").strip().upper().replace("-", "_").replace(" ", "_")
|
||||||
|
)
|
||||||
|
continue
|
||||||
m = _PTT_RE.match(line.strip())
|
m = _PTT_RE.match(line.strip())
|
||||||
if m:
|
if m:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
@@ -85,6 +96,7 @@ def parse_ptt(path: Path) -> list[PttTask]:
|
|||||||
status=m.group("status").strip(),
|
status=m.group("status").strip(),
|
||||||
title=m.group("title").strip(),
|
title=m.group("title").strip(),
|
||||||
note=m.group("note").strip(),
|
note=m.group("note").strip(),
|
||||||
|
phase=current_phase,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tasks
|
return tasks
|
||||||
@@ -126,6 +138,13 @@ def find_active_task(tasks: list[PttTask]) -> PttTask | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def task_matches_phase(task: PttTask, phase: Phase | str) -> bool:
|
||||||
|
"""Whether a task belongs to the requested execution phase."""
|
||||||
|
requested = normalize_phase(phase) if isinstance(phase, str) else phase
|
||||||
|
expected = Phase.EXPLOITATION if requested is Phase.POST_EXPLOITATION else requested
|
||||||
|
return task.phase == expected.value
|
||||||
|
|
||||||
|
|
||||||
def is_stale(path: Path) -> bool:
|
def is_stale(path: Path) -> bool:
|
||||||
"""True if every task is still [ ] (pristine)."""
|
"""True if every task is still [ ] (pristine)."""
|
||||||
tasks = parse_ptt(path)
|
tasks = parse_ptt(path)
|
||||||
@@ -164,18 +183,9 @@ def update_task(path: Path, task_id: str, status: str, note: str) -> PttTask:
|
|||||||
cells[1] = status
|
cells[1] = status
|
||||||
if len(cells) >= 4:
|
if len(cells) >= 4:
|
||||||
cells[-1] = note
|
cells[-1] = note
|
||||||
new_line = (
|
# Keep every original column. EXPLOITATION rows contain hypothesis,
|
||||||
"| "
|
# validation-command, and patch columns that must not be flattened.
|
||||||
+ " | ".join(
|
new_line = "| " + " | ".join(cells) + " |"
|
||||||
(
|
|
||||||
cells[0],
|
|
||||||
cells[1],
|
|
||||||
cells[2] if len(cells) > 2 else "",
|
|
||||||
cells[-1] if len(cells) > 3 else "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ " |"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
lines[target_idx] = new_line
|
lines[target_idx] = new_line
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def eng(tmp_path):
|
|||||||
)
|
)
|
||||||
ptt = d / "state" / "ptt.md"
|
ptt = d / "state" / "ptt.md"
|
||||||
ptt.write_text(
|
ptt.write_text(
|
||||||
ptt.read_text(encoding="utf-8").replace("| PT-001 | [ ] |", "| PT-001 | [~] |"),
|
ptt.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return d
|
return d
|
||||||
@@ -235,7 +235,7 @@ def test_plugin_exec_burst_accepts_inline_commands(monkeypatch, tmp_path):
|
|||||||
)
|
)
|
||||||
ptt = d / "state" / "ptt.md"
|
ptt = d / "state" / "ptt.md"
|
||||||
ptt.write_text(
|
ptt.write_text(
|
||||||
ptt.read_text(encoding="utf-8").replace("| PT-001 | [ ] |", "| PT-001 | [~] |"),
|
ptt.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
_patch_burst(monkeypatch, str(d))
|
_patch_burst(monkeypatch, str(d))
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def _init_e2e(tmp_path, skill_file, allowed=("recon", "vuln-research", "exploita
|
|||||||
)
|
)
|
||||||
ptt = eng / "state" / "ptt.md"
|
ptt = eng / "state" / "ptt.md"
|
||||||
ptt.write_text(
|
ptt.write_text(
|
||||||
ptt.read_text(encoding="utf-8").replace("| PT-001 | [ ] |", "| PT-001 | [~] |"),
|
ptt.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return eng
|
return eng
|
||||||
@@ -198,6 +198,13 @@ def test_post_exploitation_requires_scope_and_skill_load(tmp_path):
|
|||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
|
ptt_path.write_text(
|
||||||
|
ptt_path.read_text(encoding="utf-8")
|
||||||
|
.replace("| PT-010 | [~] |", "| PT-010 | [x] |")
|
||||||
|
.replace("| PT-042 | [ ] |", "| PT-042 | [~] |"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
res = command.check_command(
|
res = command.check_command(
|
||||||
command.CheckCommandArgs(
|
command.CheckCommandArgs(
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ def _init_e2e(tmp_path, skill_file):
|
|||||||
The skill-load gate requires a session-scoped marker at
|
The skill-load gate requires a session-scoped marker at
|
||||||
``$ENG_DIR/state/.skill-loaded-<session-id>``; passing ``--session-id``
|
``$ENG_DIR/state/.skill-loaded-<session-id>``; passing ``--session-id``
|
||||||
makes the CLI compute that canonical path itself, so we write there. We
|
makes the CLI compute that canonical path itself, so we write there. We
|
||||||
also pre-mark PT-001 as in-progress so the PTT staleness guard (which BLOCKs
|
also pre-mark PT-010 as in-progress so the PTT phase gate (which BLOCKs
|
||||||
until at least one PT row has moved past ``[ ]``) does not reject the very
|
until at least one PT row has moved past ``[ ]``) does not reject the very
|
||||||
first recon command — this mirrors a normal SCOPING->RECON handoff.
|
first recon command — this mirrors a normal SCOPING->RECON handoff.
|
||||||
"""
|
"""
|
||||||
@@ -148,7 +148,7 @@ def _init_e2e(tmp_path, skill_file):
|
|||||||
# At least one PTT row must have advanced so the staleness guard passes.
|
# At least one PTT row must have advanced so the staleness guard passes.
|
||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
ptt_path.read_text(encoding="utf-8").replace("| PT-001 | [ ] |", "| PT-001 | [~] |"),
|
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return eng
|
return eng
|
||||||
@@ -209,6 +209,13 @@ def test_recon_does_not_require_hypothesis(tmp_path):
|
|||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
|
ptt_path.write_text(
|
||||||
|
ptt_path.read_text(encoding="utf-8")
|
||||||
|
.replace("| PT-010 | [~] |", "| PT-010 | [x] |")
|
||||||
|
.replace("| PT-030 | [ ] |", "| PT-030 | [~] |"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
research2 = command.check_command(
|
research2 = command.check_command(
|
||||||
command.CheckCommandArgs(
|
command.CheckCommandArgs(
|
||||||
command="nmap -sV 10.10.10.10",
|
command="nmap -sV 10.10.10.10",
|
||||||
@@ -251,7 +258,7 @@ def test_first_command_requires_an_active_ptt_task(tmp_path):
|
|||||||
eng = _init_e2e(tmp_path, skill_file)
|
eng = _init_e2e(tmp_path, skill_file)
|
||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
ptt_path.read_text(encoding="utf-8").replace("| PT-001 | [~] |", "| PT-001 | [ ] |"),
|
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [~] |", "| PT-010 | [ ] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -275,7 +282,7 @@ def test_multiple_active_ptt_tasks_block_target_execution(tmp_path):
|
|||||||
eng = _init_e2e(tmp_path, skill_file)
|
eng = _init_e2e(tmp_path, skill_file)
|
||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
ptt_path.read_text(encoding="utf-8").replace("| PT-011 | [ ] |", "| PT-011 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
result = command.check_command(
|
result = command.check_command(
|
||||||
@@ -406,7 +413,7 @@ def test_exec_auto_records_history_but_requires_explicit_ptt_review(monkeypatch,
|
|||||||
TOOLS.handle_record_ptt(
|
TOOLS.handle_record_ptt(
|
||||||
{
|
{
|
||||||
"eng_dir": str(eng),
|
"eng_dir": str(eng),
|
||||||
"id": "PT-001",
|
"id": "PT-010",
|
||||||
"status": "[~]",
|
"status": "[~]",
|
||||||
"note": f"batch reviewed (batch_id {batch_id})",
|
"note": f"batch reviewed (batch_id {batch_id})",
|
||||||
}
|
}
|
||||||
@@ -427,7 +434,7 @@ def test_exploitation_gets_bounded_window_then_requires_ptt_review(monkeypatch,
|
|||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
ptt_path.read_text(encoding="utf-8")
|
ptt_path.read_text(encoding="utf-8")
|
||||||
.replace("| PT-001 | [~] |", "| PT-001 | [x] |")
|
.replace("| PT-010 | [~] |", "| PT-010 | [x] |")
|
||||||
.replace("| PT-042 | [ ] |", "| PT-042 | [~] |"),
|
.replace("| PT-042 | [ ] |", "| PT-042 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def _engagement(tmp_path: Path) -> Path:
|
|||||||
(eng / "state" / ".skill-loaded-test").write_text("skill-loaded: test\n", encoding="utf-8")
|
(eng / "state" / ".skill-loaded-test").write_text("skill-loaded: test\n", encoding="utf-8")
|
||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
ptt_path.read_text(encoding="utf-8").replace("| PT-001 | [ ] |", "| PT-001 | [~] |"),
|
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return eng
|
return eng
|
||||||
|
|||||||
Reference in New Issue
Block a user