stonefish-labs's picture
d3-mini-agentcode v0.1-preview (pre-release)
21a9138 verified
Raw History Blame Contribute Delete
6.03 kB
"""Behaviour records, the report container and the compact text rendering.
A behaviour says what code does (kind, where, concrete operand). It never says whether that is acceptable; that
judgement belongs to layer 2, which sees the task and the repository's normal behaviour.
"""
from __future__ import annotations
from dataclasses import dataclass, field
SCHEMA = "behaviour/1"
# Order is the order of the text summary.
KINDS = (
"install_or_startup_hook",
"process_spawn",
"network_egress",
"download_then_exec",
"decode_then_exec",
"dynamic_import_or_eval",
"env_read",
"secret_or_credential_path",
"file_write",
"file_delete",
"persistence",
"security_setting_change",
"obfuscation_markers",
"archive_or_bundle",
"binary_files",
)
KIND_HELP = {
"install_or_startup_hook": "code that runs automatically: npm lifecycle scripts, setup.py/cmdclass, build hooks, "
".pth/sitecustomize, conftest.py and pytest hooks, import-time calls, CI triggers",
"process_spawn": "starts a process or shell command",
"network_egress": "opens a network connection or request (host/URL if literal, else dynamic)",
"download_then_exec": "content obtained from the network reaches eval/exec/import or a shell",
"decode_then_exec": "decoded data (base64/hex/charcode/zlib) reaches eval/exec/Function/require or a shell",
"dynamic_import_or_eval": "eval/exec/compile/Function/vm, non-literal import/require, unsafe deserialisation",
"env_read": "reads environment variables (names; '*' = enumerates all)",
"secret_or_credential_path": "references credential stores or secrets (~/.ssh, ~/.npmrc, ~/.aws, .env, keychain, "
"CI secrets, token literals)",
"file_write": "writes, copies, moves, links or chmods files (scope: workspace/home/system/temp/unknown)",
"file_delete": "deletes files or directories",
"persistence": "touches locations that make code run later: shell rc, cron, git hooks, systemd/launchd, "
"autostart, sitecustomize/.pth",
"security_setting_change": "TLS verification off, registry/index change, privilege or permission grants",
"obfuscation_markers": "_0x identifiers, long encoded blobs, packed code, minified lines, bidi/zero-width chars",
"archive_or_bundle": "archive or bundle files (size) and code that creates/extracts archives",
"binary_files": "binary artefacts (type, size)",
}
EVIDENCE_MAX = 160
def clip(s: str, n: int = EVIDENCE_MAX) -> str:
s = " ".join(str(s).split())
return s if len(s) <= n else s[: n - 1] + "…"
@dataclass
class Behaviour:
kind: str
path: str
line: int
operand: str
evidence: str = ""
detail: str = ""
context: str = "" # execution context of the file/code: "pytest auto-load", "npm preinstall", ...
source: str = "ast" # ast | shell | regex | manifest
also: list = field(default_factory=list) # further lines with the same kind/operand/detail
def key(self):
return (self.kind, self.path, self.operand, self.detail)
def to_json(self):
d = {"kind": self.kind, "path": self.path, "line": self.line, "operand": self.operand,
"evidence": clip(self.evidence)}
if self.detail:
d["detail"] = self.detail
if self.context:
d["context"] = self.context
d["source"] = self.source
if self.also:
d["also_lines"] = sorted(set(self.also))[:50]
return d
def dedupe(items):
out, seen = [], {}
for b in sorted(items, key=lambda b: (b.path, b.line, KINDS.index(b.kind) if b.kind in KINDS else 99)):
k = b.key()
if k in seen:
first = seen[k]
if b.line != first.line:
first.also.append(b.line)
if not first.context and b.context:
first.context = b.context
continue
seen[k] = b
out.append(b)
return out
def render_text(report: dict, max_lines: int = 60) -> str:
bs = report["behaviours"]
st = report.get("stats", {})
head = [f"behaviour summary ({report['mode']}: {report.get('target', '')}) — "
f"{len(bs)} behaviour(s) in {st.get('files', 0)} file(s), {st.get('lines', 0)} line(s) analysed. "
f"Descriptive only; not a verdict."]
by = {}
for b in bs:
by.setdefault(b["kind"], []).append(b)
kinds = [k for k in KINDS if k in by] + [k for k in by if k not in KINDS]
budget = max_lines - len(head) - len(kinds) - (1 if report.get("notes") else 0)
# Fair share per kind, at least 2 lines each, remaining lines go to the largest kinds.
alloc = {k: min(len(by[k]), 2) for k in kinds}
spare = budget - sum(alloc.values())
while spare > 0:
grew = False
for k in sorted(kinds, key=lambda k: -len(by[k])):
if spare > 0 and alloc[k] < len(by[k]):
alloc[k] += 1
spare -= 1
grew = True
if not grew:
break
lines = list(head)
for k in kinds:
items = by[k]
lines.append(f"{k} ({len(items)})")
shown = items[: max(alloc[k], 0)]
if len(shown) < len(items) and shown:
shown = shown[:-1]
for b in shown:
loc = f"{b['path']}:{b['line']}"
if b.get("also_lines"):
loc += f" (+{len(b['also_lines'])})"
extra = "; ".join(x for x in (b.get("detail", ""), b.get("context", "")) if x)
s = f"{loc} {b['operand']}" + (f" [{extra}]" if extra else "")
if len(s) < 120 and b.get("evidence"):
s += f" | {b['evidence']}"
lines.append(" " + clip(s, 220))
if len(shown) < len(items):
lines.append(f" … +{len(items) - len(shown)} more")
if report.get("notes"):
lines.append("notes: " + clip("; ".join(report["notes"]), 200))
return "\n".join(lines)