File size: 5,867 Bytes
e1e980e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#!/usr/bin/env python3
"""Pose CLI Command Risk as typed decisions for the d1 scripts in this folder.

Writes cli-data/decisions/{train,validation,test}.parquet with the columns `common.load_split` reads: id,
task_slug (the template family), state ({shell, command}), questions (17, identical per row) and gold.
Train and validation keep one shell per distinct command and at most --train-cap / --val-cap commands per
family, because rows within a family are near copies. Test keeps every row, so scores compare with the
TF-IDF baseline in cli-command-risk/baseline.py.

    uv run --no-project --with pyarrow python build_cli_decisions.py
"""
import argparse
import collections
import hashlib
import json
import re
from pathlib import Path

import pyarrow as pa
import pyarrow.parquet as pq

HERE = Path(__file__).resolve().parent
SOURCE = HERE.parent / "cli-command-risk"
OUT = HERE / "cli-data" / "decisions"
LEVELS = ["safe", "low", "moderate", "high", "critical"]
SHELL_SUFFIX = re.compile(r"-(?:bash|zsh|sh|fish|dash)-[0-9a-f]+$")


def questions(taxonomy, cats):
    defs = {c["name"]: c["definition"] for c in taxonomy["risk_categories"]}
    level_defs = {lv["name"]: lv["definition"] for lv in taxonomy["risk_levels"]}
    out = {
        "risk_level": {"type": "score", "instructions": "How much harm can running this shell command as written cause?",
                       "criteria": [f"{lv.capitalize()}. {level_defs[lv]}" for lv in LEVELS]},
        "risky": {"type": "noul", "instructions": "Is this shell command risky to run as written?",
                  "criteria": {"true": "Running it can destroy data, change permissions or privileges, expose secrets, "
                                       "or disrupt the system.",
                               "false": "It is read-only or routine, with no plausible path to harm."}},
    }
    for c in cats:
        name = c.replace("_", " ")
        out[c] = {"type": "noul", "instructions": f"Does this command carry {name} risk?",
                  "criteria": {"true": defs[c], "false": f"Nothing in the command matches {name}."}}
    out["reversible"] = {"type": "noul", "instructions": "Can an average user undo the effect of this command without backups?",
                         "criteria": {"true": "The effect can be undone without backups, or there is no effect to undo.",
                                      "false": "Undoing the effect needs backups or is not possible."}}
    out["requires_root"] = {"type": "noul", "instructions": "Does the harm from this command need root or sudo?",
                            "criteria": {"true": "The command needs root or sudo to do its harm.",
                                         "false": "An ordinary user can run it with the same effect."}}
    return out


def family(row):
    if row["derived_from"]:
        return row["derived_from"]
    if row["source"] == "open_source":
        return "os:" + row["command"]
    return SHELL_SUFFIX.sub("", row["id"])


def rank(text):
    return hashlib.sha256(text.encode()).hexdigest()


def thin(rows, cap):
    """One shell per distinct command, then the first `cap` commands per family in hash order."""
    by_family = collections.defaultdict(dict)
    for r in rows:
        best = by_family[family(r)].get(r["command"])
        if best is None or rank(r["id"]) < rank(best["id"]):
            by_family[family(r)][r["command"]] = r
    kept = []
    for fam in sorted(by_family):
        kept += sorted(by_family[fam].values(), key=lambda r: rank(r["command"]))[:cap]
    return kept


def gold(row, cats):
    noul = lambda value: {"label": "true" if value else "false"}  # noqa: E731
    out = {"risk_level": {"label": str(LEVELS.index(row["risk_level"]))},
           "risky": noul(row["risk_level"] != "safe")}
    out.update({c: noul(c in row["risk_categories"]) for c in cats})
    out["reversible"] = noul(row["reversible"])
    out["requires_root"] = noul(row["requires_root"])
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--train-cap", type=int, default=40)
    ap.add_argument("--val-cap", type=int, default=8)
    ap.add_argument("--full-train", action="store_true", help="keep every train row, all shells, no family cap")
    ap.add_argument("--out", type=Path, default=OUT.parent)
    args = ap.parse_args()
    out = args.out / "decisions"
    taxonomy = json.loads((SOURCE / "taxonomy.json").read_text())
    data = {s: pq.read_table(SOURCE / "data" / f"{s}.parquet").to_pylist() for s in ("train", "validation", "test")}
    present = {c for r in data["train"] for c in r["risk_categories"]}
    cats = [c["name"] for c in taxonomy["risk_categories"] if c["name"] != "safe_baseline" and c["name"] in present]
    qs = json.dumps(questions(taxonomy, cats))
    picked = {"train": data["train"] if args.full_train else thin(data["train"], args.train_cap),
              "validation": thin(data["validation"], args.val_cap), "test": data["test"]}
    out.mkdir(parents=True, exist_ok=True)
    summary = {}
    for split, rows in picked.items():
        table = pa.table({
            "id": [r["id"] for r in rows],
            "task_slug": [family(r) for r in rows],
            "state": [json.dumps({"shell": r["shell"], "command": r["command"]}) for r in rows],
            "questions": [qs] * len(rows),
            "gold": [json.dumps(gold(r, cats)) for r in rows],
        })
        pq.write_table(table, out / f"{split}.parquet")
        summary[split] = {"cases": len(rows), "families": len({family(r) for r in rows}),
                          "levels": dict(collections.Counter(r["risk_level"] for r in rows))}
    summary["questions"] = len(json.loads(qs))
    (out.parent / "summary.json").write_text(json.dumps(summary, indent=1) + "\n")
    print(json.dumps(summary, indent=1))


if __name__ == "__main__":
    main()