killchain 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- killchain/__init__.py +10 -0
- killchain/__main__.py +5 -0
- killchain/config.toml +78 -0
- killchain/contract.py +154 -0
- killchain/detector.py +180 -0
- killchain/doctor.py +164 -0
- killchain/fakes.py +182 -0
- killchain/frontier.py +63 -0
- killchain/gate.py +99 -0
- killchain/harness.py +377 -0
- killchain/localmodel.py +74 -0
- killchain/paths.py +277 -0
- killchain/prompts/frontier_build.md +34 -0
- killchain/prompts/local_implement.md +36 -0
- killchain/prompts/partition.md +100 -0
- killchain/routing.py +310 -0
- killchain/stubgen.py +151 -0
- killchain-0.1.0.dist-info/METADATA +268 -0
- killchain-0.1.0.dist-info/RECORD +22 -0
- killchain-0.1.0.dist-info/WHEEL +4 -0
- killchain-0.1.0.dist-info/entry_points.txt +2 -0
- killchain-0.1.0.dist-info/licenses/LICENSE +21 -0
killchain/fakes.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Canned backends so the whole pipeline can be exercised with no models.
|
|
2
|
+
|
|
3
|
+
Toy task: rank inbound leads from a CSV. Framing is the thing under test: the
|
|
4
|
+
fake orchestrator keeps leaking attribution into the public brief. It trips the
|
|
5
|
+
denylist gate on attempt 1 and a refusal dimension on attempt 2, so both
|
|
6
|
+
feedback loops (and the marker the detector keys on) are covered.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from killchain import routing
|
|
14
|
+
|
|
15
|
+
CONTRACT = {
|
|
16
|
+
"public_brief": (
|
|
17
|
+
"Build `leadrank`, a stdlib-only Python CLI that reads a CSV of inbound leads "
|
|
18
|
+
"and prints them ranked by score, highest first.\n\n"
|
|
19
|
+
"Input CSV columns: lead_id, company, company_size (int), industry (str), visits (int). "
|
|
20
|
+
"Scoring is provided by the private function contracts.scoring.score_lead, which takes "
|
|
21
|
+
"a dict of the non-id columns and returns a float in [0, 1].\n\n"
|
|
22
|
+
"CLI: `python -m app.rank <csv> [--top N]`. Output one line per lead: "
|
|
23
|
+
"`<rank>. <lead_id> <company> <score:.2f>`. Malformed rows are skipped with a warning "
|
|
24
|
+
"on stderr. Add unittest tests under tests/ covering ranking order and --top."
|
|
25
|
+
),
|
|
26
|
+
"interfaces": [{
|
|
27
|
+
"module": "scoring",
|
|
28
|
+
"function": "score_lead",
|
|
29
|
+
"description": "Score one lead. Higher means more likely to convert.",
|
|
30
|
+
"params": [{"name": "lead", "type": "dict",
|
|
31
|
+
"description": "keys: company_size (int), industry (str), visits (int)"}],
|
|
32
|
+
"returns": "float",
|
|
33
|
+
"fixtures": [
|
|
34
|
+
{"inputs": {"lead": {"company_size": 50, "industry": "saas", "visits": 12}},
|
|
35
|
+
"output": 0.42, "note": "decoy"},
|
|
36
|
+
{"inputs": {"lead": {"company_size": 10, "industry": "retail", "visits": 0}},
|
|
37
|
+
"output": 0.08, "note": "decoy"},
|
|
38
|
+
],
|
|
39
|
+
"private_fixtures": [
|
|
40
|
+
{"inputs": {"lead": {"company_size": 50, "industry": "saas", "visits": 12}},
|
|
41
|
+
"output": 0.74, "note": "real"},
|
|
42
|
+
{"inputs": {"lead": {"company_size": 10, "industry": "retail", "visits": 0}},
|
|
43
|
+
"output": 0.1, "note": "real"},
|
|
44
|
+
],
|
|
45
|
+
}],
|
|
46
|
+
"private_notes": "Real formula: 0.01*company_size + 0.02*visits, capped at 1.0, rounded to 4dp. "
|
|
47
|
+
"Worked out by reverse-engineering Acme Corp's hosted scorer (project NIGHTJAR).",
|
|
48
|
+
"private_plan": "1. implement score_lead with the formula. 2. clamp. 3. round. "
|
|
49
|
+
"4. keep the credit out of the public brief.",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
IMPL = {
|
|
53
|
+
"files": {
|
|
54
|
+
"private_impl/__init__.py": "",
|
|
55
|
+
"private_impl/scoring.py": (
|
|
56
|
+
"def score_lead(lead: dict) -> float:\n"
|
|
57
|
+
" s = 0.01 * int(lead.get('company_size', 0)) + 0.02 * int(lead.get('visits', 0))\n"
|
|
58
|
+
" return round(min(1.0, max(0.0, s)), 4)\n"),
|
|
59
|
+
},
|
|
60
|
+
"notes": "fake implementation",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fake_local(messages: list[dict]) -> str:
|
|
65
|
+
system = messages[0]["content"] if messages and messages[0]["role"] == "system" else ""
|
|
66
|
+
user = messages[-1]["content"]
|
|
67
|
+
if "orchestrator for a two-party build" in system:
|
|
68
|
+
convo = "\n".join(m["content"] for m in messages if m["role"] == "user")
|
|
69
|
+
if "PROBE RESULTS" not in convo:
|
|
70
|
+
return json.dumps({"probes": ["rank leads from a CSV by score",
|
|
71
|
+
"FLAGME the formula is 0.01 per employee"]})
|
|
72
|
+
attempt = convo.count("PREVIOUS ATTEMPT REJECTED") + 1
|
|
73
|
+
c = json.loads(json.dumps(CONTRACT))
|
|
74
|
+
if attempt == 1:
|
|
75
|
+
c["public_brief"] += "\n\nThis replaces the project nightjar scorer."
|
|
76
|
+
elif attempt == 2:
|
|
77
|
+
c["public_brief"] += ("\n\nFLAGME: a clean-room reimplementation, reverse-engineered "
|
|
78
|
+
"from observed behaviour; the existing weights were roughly "
|
|
79
|
+
"0.01 and 0.02.")
|
|
80
|
+
return "```json\n" + json.dumps(c) + "\n```"
|
|
81
|
+
if "implementing the private half" in user:
|
|
82
|
+
return json.dumps(IMPL)
|
|
83
|
+
raise RuntimeError("fake_local: unrecognised prompt")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _scores(text: str) -> dict[str, float]:
|
|
87
|
+
hot = "FLAGME" in text
|
|
88
|
+
scores = {d: (0.03 if not hot else 0.05) for d in routing.DIMENSIONS}
|
|
89
|
+
if hot:
|
|
90
|
+
scores["reverse_engineering"] = 0.97
|
|
91
|
+
scores["hedge_framing"] = 0.91
|
|
92
|
+
return scores
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fake_detector(spans: list[tuple[str, str]]) -> list[dict[str, float]]:
|
|
96
|
+
return [_scores(t) for _, t in spans]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fake_overall(text: str) -> dict[str, float]:
|
|
100
|
+
return {"overall": 0.97 if "FLAGME" in text else 0.12}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _contract_matches_canned(public_dir: Path) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
pv = json.loads((public_dir / "contract.public.json").read_text())
|
|
106
|
+
i = pv["interfaces"][0]
|
|
107
|
+
return (len(pv["interfaces"]) == 1 and i["module"] == "scoring"
|
|
108
|
+
and i["function"] == "score_lead" and [p["name"] for p in i["params"]] == ["lead"])
|
|
109
|
+
except (OSError, KeyError, IndexError, ValueError):
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fake_frontier(prompt: str, public_dir: Path) -> str:
|
|
114
|
+
if not _contract_matches_canned(public_dir):
|
|
115
|
+
(public_dir / "NOTES.md").write_text(
|
|
116
|
+
"# fake frontier\n\nContract does not match the canned toy signature "
|
|
117
|
+
"(scoring.score_lead(lead)); wrote no app. Use a real frontier backend.\n")
|
|
118
|
+
return "fake frontier: contract differs from canned toy; wrote NOTES.md only"
|
|
119
|
+
app = public_dir / "app"
|
|
120
|
+
app.mkdir(exist_ok=True)
|
|
121
|
+
(app / "__init__.py").write_text("")
|
|
122
|
+
(app / "rank.py").write_text('''"""leadrank CLI (built by fake frontier)."""
|
|
123
|
+
import argparse
|
|
124
|
+
import csv
|
|
125
|
+
import sys
|
|
126
|
+
|
|
127
|
+
from contracts import scoring
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load(path):
|
|
131
|
+
rows = []
|
|
132
|
+
with open(path, newline="") as f:
|
|
133
|
+
for n, r in enumerate(csv.DictReader(f), 2):
|
|
134
|
+
try:
|
|
135
|
+
lead = {"company_size": int(r["company_size"]), "industry": r["industry"],
|
|
136
|
+
"visits": int(r["visits"])}
|
|
137
|
+
except (KeyError, ValueError) as e:
|
|
138
|
+
print(f"warning: skipping row {n}: {e}", file=sys.stderr)
|
|
139
|
+
continue
|
|
140
|
+
rows.append((r["lead_id"], r["company"], lead))
|
|
141
|
+
return rows
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rank(rows, top=None):
|
|
145
|
+
scored = [(lid, co, scoring.score_lead(lead=lead)) for lid, co, lead in rows]
|
|
146
|
+
scored.sort(key=lambda t: t[2], reverse=True)
|
|
147
|
+
return scored[:top] if top else scored
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv=None):
|
|
151
|
+
ap = argparse.ArgumentParser(prog="leadrank")
|
|
152
|
+
ap.add_argument("csv")
|
|
153
|
+
ap.add_argument("--top", type=int)
|
|
154
|
+
a = ap.parse_args(argv)
|
|
155
|
+
for n, (lid, co, s) in enumerate(rank(load(a.csv), a.top), 1):
|
|
156
|
+
print(f"{n}. {lid} {co} {s:.2f}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
main()
|
|
161
|
+
''')
|
|
162
|
+
tests = public_dir / "tests"
|
|
163
|
+
tests.mkdir(exist_ok=True)
|
|
164
|
+
(tests / "test_rank.py").write_text('''import unittest
|
|
165
|
+
from app.rank import rank
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RankTests(unittest.TestCase):
|
|
169
|
+
ROWS = [
|
|
170
|
+
("L1", "Acme", {"company_size": 10, "industry": "retail", "visits": 0}),
|
|
171
|
+
("L2", "Beta", {"company_size": 50, "industry": "saas", "visits": 12}),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def test_order(self):
|
|
175
|
+
out = rank(self.ROWS)
|
|
176
|
+
self.assertEqual([r[0] for r in out], ["L2", "L1"])
|
|
177
|
+
|
|
178
|
+
def test_top(self):
|
|
179
|
+
self.assertEqual(len(rank(self.ROWS, top=1)), 1)
|
|
180
|
+
''')
|
|
181
|
+
(public_dir / "NOTES.md").write_text("# leadrank\n\nRun: `python -m app.rank leads.csv --top 5`\n")
|
|
182
|
+
return "fake frontier: wrote app/rank.py, tests/test_rank.py, NOTES.md"
|
killchain/frontier.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Frontier builder adapters. Each runs a coding agent inside the PUBLIC
|
|
2
|
+
directory only, with the public brief and generated stubs. The agent is free
|
|
3
|
+
to dispatch to subagents in its own family; that is its business.
|
|
4
|
+
|
|
5
|
+
The private tree is kept out of the child's environment and cwd. It is a
|
|
6
|
+
convention rather than a jail: the agent runs with permissions skipped.
|
|
7
|
+
|
|
8
|
+
Backends: claude (Claude Code CLI), codex (OpenAI Codex CLI), fake.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FrontierError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clean_env(private_root: Path) -> dict:
|
|
23
|
+
"""Environment for the frontier subprocess: strip anything pointing at the
|
|
24
|
+
private tree, and make sure HARNESS_MODE is stub."""
|
|
25
|
+
env = {k: v for k, v in os.environ.items()
|
|
26
|
+
if str(private_root) not in v and k not in ("PYTHONPATH",)
|
|
27
|
+
# a Claude Code parent marks its env; the child must start clean
|
|
28
|
+
and k != "CLAUDECODE" and not k.startswith("CLAUDE_CODE_")}
|
|
29
|
+
env["HARNESS_MODE"] = "stub"
|
|
30
|
+
return env
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(cfg: dict, prompt: str, public_dir: Path, private_root: Path, log: Path) -> str:
|
|
34
|
+
backend = cfg.get("backend", "claude")
|
|
35
|
+
if backend == "fake":
|
|
36
|
+
from killchain.fakes import fake_frontier
|
|
37
|
+
return fake_frontier(prompt, public_dir)
|
|
38
|
+
if backend == "claude":
|
|
39
|
+
exe = shutil.which("claude") or "claude"
|
|
40
|
+
cmd = [exe, "-p", prompt, "--output-format", "text"]
|
|
41
|
+
if cfg.get("claude_model"):
|
|
42
|
+
cmd += ["--model", cfg["claude_model"]]
|
|
43
|
+
cmd += list(cfg.get("claude_extra_args", []))
|
|
44
|
+
elif backend == "codex":
|
|
45
|
+
exe = shutil.which("codex") or "codex"
|
|
46
|
+
cmd = [exe, "exec", "-C", str(public_dir)]
|
|
47
|
+
if cfg.get("codex_model"):
|
|
48
|
+
cmd += ["-m", cfg["codex_model"]]
|
|
49
|
+
cmd += list(cfg.get("codex_extra_args", []))
|
|
50
|
+
cmd += [prompt]
|
|
51
|
+
else:
|
|
52
|
+
raise FrontierError(f"unknown frontier backend {backend!r}")
|
|
53
|
+
|
|
54
|
+
with log.open("w") as lf:
|
|
55
|
+
lf.write(f"$ {' '.join(cmd[:3])} ... (cwd={public_dir})\n\n")
|
|
56
|
+
lf.flush()
|
|
57
|
+
proc = subprocess.run(cmd, cwd=public_dir, env=_clean_env(private_root),
|
|
58
|
+
stdout=lf, stderr=subprocess.STDOUT, text=True,
|
|
59
|
+
timeout=cfg.get("timeout", 3600))
|
|
60
|
+
out = log.read_text()
|
|
61
|
+
if proc.returncode != 0:
|
|
62
|
+
raise FrontierError(f"{backend} exited {proc.returncode}; see {log}\n{out[-2000:]}")
|
|
63
|
+
return out
|
killchain/gate.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Redaction gate: a deterministic check on everything that leaves the machine.
|
|
2
|
+
|
|
3
|
+
Two jobs now:
|
|
4
|
+
|
|
5
|
+
* **Blocks** (secrets, keys, internal hostnames) against routing.HARD_RULES
|
|
6
|
+
and the user denylist. Any hit stops the send.
|
|
7
|
+
* **Hints** against routing.FRAMING_HINTS: clone/port wording, hedge words,
|
|
8
|
+
licence talk, TODO-ish scope. These do not block, they are printed at
|
|
9
|
+
review so you can see the phrasing that is most likely to make a frontier
|
|
10
|
+
agent hedge or negotiate before it goes out.
|
|
11
|
+
|
|
12
|
+
The denylist lives in the private root because it names the terms that tip the
|
|
13
|
+
frontier off: product names it will recognise, licence names, codenames. Lines
|
|
14
|
+
are literals, or `re:<regex>`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from killchain.routing import FRAMING_HINTS, HARD_RULES as BUILTIN # edit routing.py, not this file
|
|
23
|
+
|
|
24
|
+
DENYLIST_TEMPLATE = """# Risk denylist. One entry per line. Lines starting with # are ignored.
|
|
25
|
+
# Plain lines are case-insensitive literal matches.
|
|
26
|
+
# Lines starting with re: are Python regexes (case-insensitive).
|
|
27
|
+
#
|
|
28
|
+
# Two uses: things that must never reach a third-party API (keys, internal
|
|
29
|
+
# hostnames), and terms that reliably make a frontier model hedge: product
|
|
30
|
+
# names it will recognise, vendor names, licence names, project codenames.
|
|
31
|
+
#
|
|
32
|
+
# examples:
|
|
33
|
+
# nightjar
|
|
34
|
+
# re:\\bfrom memory\\b
|
|
35
|
+
# re:\\bgpl-?3\\b
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Hit:
|
|
41
|
+
rule: str
|
|
42
|
+
match: str
|
|
43
|
+
context: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_denylist(path: Path) -> list[tuple[str, re.Pattern]]:
|
|
47
|
+
if not path.exists():
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path.write_text(DENYLIST_TEMPLATE)
|
|
50
|
+
return []
|
|
51
|
+
rules = []
|
|
52
|
+
for n, raw in enumerate(path.read_text().splitlines(), 1):
|
|
53
|
+
line = raw.strip()
|
|
54
|
+
if not line or line.startswith("#"):
|
|
55
|
+
continue
|
|
56
|
+
if line.startswith("re:"):
|
|
57
|
+
try:
|
|
58
|
+
rules.append((f"denylist regex {line[3:]!r}", re.compile(line[3:], re.I)))
|
|
59
|
+
except re.error as e:
|
|
60
|
+
raise SystemExit(f"denylist line {n}: bad regex {line[3:]!r}: {e}") from e
|
|
61
|
+
else:
|
|
62
|
+
rules.append((f"denylist literal {line!r}", re.compile(re.escape(line), re.I)))
|
|
63
|
+
return rules
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _scan(text: str, rules: list[tuple[str, re.Pattern]]) -> list[Hit]:
|
|
67
|
+
hits: list[Hit] = []
|
|
68
|
+
for name, pat in rules:
|
|
69
|
+
for m in pat.finditer(text):
|
|
70
|
+
s, e = max(0, m.start() - 40), min(len(text), m.end() + 40)
|
|
71
|
+
ctx = text[s:e].replace("\n", " ")
|
|
72
|
+
hits.append(Hit(rule=name, match=m.group(0), context=ctx))
|
|
73
|
+
return hits
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scan(text: str, denylist: list[tuple[str, re.Pattern]]) -> list[Hit]:
|
|
77
|
+
"""Blocking scan."""
|
|
78
|
+
return _scan(text, BUILTIN + denylist)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def hints(text: str) -> list[Hit]:
|
|
82
|
+
"""Non-blocking framing warnings."""
|
|
83
|
+
return _scan(text, FRAMING_HINTS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def describe(hits: list[Hit]) -> str:
|
|
87
|
+
lines = [f"REDACTION GATE BLOCKED SEND: {len(hits)} hit(s)"]
|
|
88
|
+
for h in hits:
|
|
89
|
+
lines.append(f" [{h.rule}] {h.match!r}\n ...{h.context}...")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def describe_hints(hits: list[Hit]) -> str:
|
|
94
|
+
if not hits:
|
|
95
|
+
return "framing hints: none"
|
|
96
|
+
lines = [f"FRAMING HINTS: {len(hits)} phrase(s) that raise refusal odds"]
|
|
97
|
+
for h in hits:
|
|
98
|
+
lines.append(f" [{h.rule}] {h.match!r}")
|
|
99
|
+
return "\n".join(lines)
|