diff --git a/Discovery/adr_discovery/catalog/__init__.py b/Discovery/adr_discovery/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Discovery/adr_discovery/catalog/catalog.json b/Discovery/adr_discovery/catalog/catalog.json new file mode 100644 index 0000000..344246a --- /dev/null +++ b/Discovery/adr_discovery/catalog/catalog.json @@ -0,0 +1,191 @@ +{ + "version": "2026.08.22", + "entries": [ + { + "id": "claude-code", "name": "Claude Code", "vendor": "Anthropic", "kind": "cli_agent", + "fingerprints": {"binaries": ["claude"], "packages": ["npm:@anthropic-ai/claude-code"], + "state_dirs": ["~/.claude"]}, + "proofs": {"provenance": ["npm:@anthropic-ai/claude-code", "brew:claude"], + "version_probe": ["--version"], "version_shape": "^\\d+\\.\\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "gemini-cli", "name": "Gemini CLI", "vendor": "Google", "kind": "cli_agent", + "fingerprints": {"binaries": ["gemini"], "packages": ["npm:@google/gemini-cli"], + "state_dirs": ["~/.gemini"]}, + "proofs": {"provenance": ["npm:@google/gemini-cli"], + "version_probe": ["--version"], "version_shape": "^\\d+\\.\\d+\\.\\d+$"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "codex-cli", "name": "Codex CLI", "vendor": "OpenAI", "kind": "cli_agent", + "fingerprints": {"binaries": ["codex"], "packages": ["npm:@openai/codex"], + "state_dirs": ["~/.codex"]}, + "proofs": {"provenance": ["npm:@openai/codex"], + "version_probe": ["--version"], "version_shape": "^codex-cli \\d+\\.\\d+\\.\\d+|^\\d+\\.\\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "aider", "name": "Aider", "vendor": "Aider AI", "kind": "cli_agent", + "fingerprints": {"binaries": ["aider"], "packages": ["pipx:aider-chat", "uv:aider-chat"], + "state_dirs": ["~/.aider"]}, + "proofs": {"provenance": ["pipx:aider-chat"], + "version_probe": ["--version"], "version_shape": "^aider \\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "opencode", "name": "opencode", "vendor": "opencode", "kind": "cli_agent", + "fingerprints": {"binaries": ["opencode"], "packages": ["npm:opencode-ai"], + "state_dirs": ["~/.config/opencode"]}, + "proofs": {"provenance": ["npm:opencode-ai"], + "version_probe": ["--version"], "version_shape": "^\\d+\\.\\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "goose", "name": "goose", "vendor": "Block", "kind": "cli_agent", + "fingerprints": {"binaries": ["goose"], "state_dirs": ["~/.config/goose"]}, + "proofs": {"version_probe": ["--version"], "version_shape": "^goose \\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "cursor", "name": "Cursor", "vendor": "Anysphere", "kind": "app", + "fingerprints": {"bundle_ids": ["com.todesktop.230313mzl4w4u92"], + "desktop_ids": ["cursor"], "state_dirs": ["~/.cursor"]}, + "proofs": {"provenance": ["brew:cursor"]}, + "risk_factors": [] + }, + { + "id": "claude-desktop", "name": "Claude Desktop", "vendor": "Anthropic", "kind": "app", + "fingerprints": {"bundle_ids": ["com.anthropic.claudefordesktop"], + "state_dirs": ["~/Library/Application Support/Claude"]}, + "proofs": {}, + "risk_factors": [] + }, + { + "id": "vscode", "name": "Visual Studio Code", "vendor": "Microsoft", "kind": "app", + "fingerprints": {"bundle_ids": ["com.microsoft.VSCode"], "desktop_ids": ["code"], + "state_dirs": ["~/.vscode/extensions"]}, + "proofs": {}, + "risk_factors": [] + }, + { + "id": "ollama", "name": "Ollama", "vendor": "Ollama", "kind": "model_runtime", + "fingerprints": {"binaries": ["ollama"], "ports": [11434], + "model_dirs": ["~/.ollama/models"], "state_dirs": ["~/.ollama"]}, + "proofs": {"provenance": ["brew:ollama"], + "version_probe": ["--version"], "version_shape": "\\d+\\.\\d+\\.\\d+"}, + "risk_factors": ["local_inference"] + }, + { + "id": "lm-studio", "name": "LM Studio", "vendor": "LM Studio", "kind": "model_runtime", + "fingerprints": {"bundle_ids": ["ai.elementlabs.lmstudio"], "ports": [1234]}, + "proofs": {}, + "risk_factors": ["local_inference"] + }, + { + "id": "continue", "name": "Continue", "vendor": "Continue", "kind": "extension", + "fingerprints": {"extension_ids": ["continue.continue"], "state_dirs": ["~/.continue"]}, + "proofs": {}, + "risk_factors": [] + }, + { + "id": "github-copilot", "name": "GitHub Copilot", "vendor": "GitHub", "kind": "extension", + "fingerprints": {"extension_ids": ["github.copilot", "github.copilot-chat"]}, + "proofs": {}, + "risk_factors": [] + }, + { + "id": "comet", "name": "Comet", "vendor": "Perplexity", "kind": "ai_browser", + "fingerprints": {"bundle_ids": ["ai.perplexity.comet"]}, + "proofs": {}, + "risk_factors": ["browses_autonomously"] + }, + { + "id": "amp", "name": "Amp", "vendor": "Sourcegraph", "kind": "cli_agent", + "fingerprints": {"binaries": ["amp"], "packages": ["npm:@sourcegraph/amp"]}, "proofs": {} + }, + { + "id": "crush", "name": "Crush", "vendor": "Charm", "kind": "cli_agent", + "fingerprints": {"binaries": ["crush"], "packages": ["brew:crush"]}, "proofs": {} + }, + { + "id": "qwen-code", "name": "Qwen Code", "vendor": "Alibaba", "kind": "cli_agent", + "fingerprints": {"binaries": ["qwen"], "packages": ["npm:@qwen-code/qwen-code"]}, "proofs": {} + }, + { + "id": "kilo-cli", "name": "Kilo CLI", "vendor": "Kilo Code", "kind": "cli_agent", + "fingerprints": {"binaries": ["kilo"], "packages": ["npm:@kilocode/cli"]}, + "proofs": {"provenance": ["npm:@kilocode/cli"], + "version_probe": ["--version"], "version_shape": "^\\d+\\.\\d+\\.\\d+"}, + "risk_factors": ["executes_shell"] + }, + { + "id": "github-copilot-cli", "name": "GitHub Copilot CLI", "vendor": "GitHub", "kind": "cli_agent", + "fingerprints": {"binaries": ["github-copilot-cli"], "packages": ["npm:@github/copilot"]}, "proofs": {} + }, + { + "id": "windsurf", "name": "Windsurf", "vendor": "Codeium", "kind": "app", + "fingerprints": {"bundle_ids": ["com.exafunction.windsurf"], "desktop_ids": ["windsurf"]}, "proofs": {} + }, + { + "id": "zed", "name": "Zed", "vendor": "Zed Industries", "kind": "app", + "fingerprints": {"bundle_ids": ["dev.zed.Zed"], "desktop_ids": ["dev.zed.Zed", "zed"]}, "proofs": {} + }, + { + "id": "trae", "name": "Trae", "vendor": "ByteDance", "kind": "app", + "fingerprints": {"bundle_ids": ["com.trae.app"], "desktop_ids": ["trae"]}, "proofs": {} + }, + { + "id": "chatgpt-desktop", "name": "ChatGPT", "vendor": "OpenAI", "kind": "app", + "fingerprints": {"bundle_ids": ["com.openai.chat", "com.openai.chatgpt"]}, "proofs": {} + }, + { + "id": "perplexity-desktop", "name": "Perplexity", "vendor": "Perplexity", "kind": "app", + "fingerprints": {"bundle_ids": ["ai.perplexity.mac"]}, "proofs": {} + }, + { + "id": "cline", "name": "Cline", "vendor": "Cline", "kind": "extension", + "fingerprints": {"extension_ids": ["saoudrizwan.claude-dev"]}, "proofs": {} + }, + { + "id": "roo-code", "name": "Roo Code", "vendor": "Roo Code", "kind": "extension", + "fingerprints": {"extension_ids": ["rooveterinaryinc.roo-cline"]}, "proofs": {} + }, + { + "id": "kilo-code", "name": "Kilo Code", "vendor": "Kilo Code", "kind": "extension", + "fingerprints": {"extension_ids": ["kilocode.kilo-code"]}, "proofs": {} + }, + { + "id": "jetbrains-ai", "name": "JetBrains AI Assistant", "vendor": "JetBrains", "kind": "extension", + "fingerprints": {"extension_ids": ["com.intellij.ml.llm"]}, "proofs": {} + }, + { + "id": "llama-cpp", "name": "llama.cpp", "vendor": "ggml-org", "kind": "model_runtime", + "fingerprints": {"binaries": ["llama-server", "server"], "packages": ["brew:llama.cpp"]}, "proofs": {} + }, + { + "id": "vllm", "name": "vLLM", "vendor": "vLLM", "kind": "model_runtime", + "fingerprints": {"binaries": ["vllm"], "packages": ["pipx:vllm", "uv:vllm"]}, "proofs": {} + }, + { + "id": "gpt4all", "name": "GPT4All", "vendor": "Nomic", "kind": "model_runtime", + "fingerprints": {"bundle_ids": ["com.nomic.gpt4all"], "desktop_ids": ["gpt4all"]}, "proofs": {} + }, + { + "id": "jan", "name": "Jan", "vendor": "Jan", "kind": "model_runtime", + "fingerprints": {"bundle_ids": ["com.jan.ai"], "desktop_ids": ["jan"]}, "proofs": {} + }, + { + "id": "localai", "name": "LocalAI", "vendor": "LocalAI", "kind": "model_runtime", + "fingerprints": {"binaries": ["local-ai"], "packages": ["brew:localai"]}, "proofs": {} + }, + { + "id": "open-webui", "name": "Open WebUI", "vendor": "Open WebUI", "kind": "app", + "fingerprints": {"packages": ["pipx:open-webui", "uv:open-webui"]}, "proofs": {} + }, + { + "id": "arc", "name": "Arc", "vendor": "The Browser Company", "kind": "ai_browser", + "fingerprints": {"bundle_ids": ["company.thebrowser.Browser"]}, "proofs": {} + } + ] +} diff --git a/Discovery/adr_discovery/catalog/load.py b/Discovery/adr_discovery/catalog/load.py new file mode 100644 index 0000000..abdc735 --- /dev/null +++ b/Discovery/adr_discovery/catalog/load.py @@ -0,0 +1,151 @@ +"""C1 -- the catalog, as data. + +This package imports nothing from the collector, on purpose: the landscape +changes weekly and a client release cannot, so the catalog has to ship on +its own cadence. Coupling it to the pipeline would put that churn rate on +the release train. + +Every rule below is enforced *at load*. A catalog defect that first shows up +at match time has already shipped to the fleet. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + + +class CatalogError(ValueError): + """Rejected at load, with the reason. Never a warning.""" + + +@dataclass(frozen=True, slots=True) +class Entry: + id: str + name: str + vendor: str + kind: str + fingerprints: dict[str, list] = field(default_factory=dict) + proofs: dict[str, object] = field(default_factory=dict) + risk_factors: tuple[str, ...] = () + + @property + def version_probe(self) -> tuple[str, ...]: + return tuple(self.proofs.get("version_probe") or ()) + + @property + def version_shape(self) -> str | None: + shape = self.proofs.get("version_shape") + return str(shape) if shape else None + + @property + def provenance(self) -> tuple[str, ...]: + return tuple(self.proofs.get("provenance") or ()) + + @property + def content_hashes(self) -> tuple[str, ...]: + return tuple(self.proofs.get("content_hashes") or ()) + + +@dataclass(frozen=True, slots=True) +class Catalog: + version: str + entries: tuple[Entry, ...] + by_id: dict[str, Entry] + #: fingerprint kind -> value -> entry id. Built at load, which is where + #: an ambiguous value is caught. + index: dict[str, dict[str, str]] + + def match(self, kind: str, value: str) -> Entry | None: + entry_id = self.index.get(kind, {}).get(str(value).lower()) + return self.by_id.get(entry_id) if entry_id else None + + def __len__(self) -> int: + return len(self.entries) + + +EMPTY = Catalog("empty", (), {}, {}) + +#: Fingerprint kinds that must be unique across the whole catalog. +UNIQUE_KINDS = ( + "binaries", "packages", "bundle_ids", "registry_names", + "extension_ids", "desktop_ids", "ports", "model_dirs", +) + +#: Proof kinds, indexed the same way and subject to the same ambiguity +#: rule. A hash claimed by two entries is the worst kind of ambiguity -- +#: it is the one piece of evidence that is supposed to be conclusive. +UNIQUE_PROOFS = ("content_hashes",) + +INDEXED_KINDS = UNIQUE_KINDS + UNIQUE_PROOFS + + +def loads(text: str) -> Catalog: + try: + document = json.loads(text) + except json.JSONDecodeError as exc: + raise CatalogError(f"catalog is not valid JSON: {exc}") from exc + + version = str(document.get("version", "unknown")) + entries: list[Entry] = [] + by_id: dict[str, Entry] = {} + index: dict[str, dict[str, str]] = {kind: {} for kind in INDEXED_KINDS} + + for raw in document.get("entries", []): + entry = _entry(raw) + if entry.id in by_id: + raise CatalogError(f"duplicate entry id {entry.id!r}") + by_id[entry.id] = entry + entries.append(entry) + + for kind in INDEXED_KINDS: + source = entry.proofs if kind in UNIQUE_PROOFS else entry.fingerprints + for value in source.get(kind, ()) or (): + key = str(value).lower() + owner = index[kind].get(key) + if owner is not None and owner != entry.id: + # Whichever loaded last would otherwise win every match, + # and the fleet is attributed to a tool nobody chose. + raise CatalogError( + f"ambiguous fingerprint {kind}:{value!r} claimed by " + f"{owner!r} and {entry.id!r}" + ) + index[kind][key] = entry.id + + return Catalog(version, tuple(entries), by_id, index) + + +def _entry(raw: object) -> Entry: + if not isinstance(raw, dict): + raise CatalogError(f"entry must be a mapping, got {type(raw).__name__}") + for required in ("id", "name", "vendor", "kind"): + if not isinstance(raw.get(required), str) or not raw[required]: + raise CatalogError(f"entry missing {required!r}: {raw.get('id', '?')}") + + proofs = raw.get("proofs") or {} + if not isinstance(proofs, dict): + raise CatalogError(f"{raw['id']}: proofs must be a mapping") + + probe, shape = proofs.get("version_probe"), proofs.get("version_shape") + if probe and not shape: + # An unverified probe is how a renamed `sleep` acquired version 9.4. + raise CatalogError( + f"{raw['id']}: version_probe without version_shape -- " + "a probe whose output is not checked is not a proof" + ) + if shape: + try: + re.compile(str(shape)) + except re.error as exc: + raise CatalogError(f"{raw['id']}: version_shape is not a valid regex: {exc}") from exc + + fingerprints = raw.get("fingerprints") or {} + if not isinstance(fingerprints, dict): + raise CatalogError(f"{raw['id']}: fingerprints must be a mapping") + + return Entry( + id=raw["id"], name=raw["name"], vendor=raw["vendor"], kind=raw["kind"], + fingerprints=fingerprints, proofs=proofs, + risk_factors=tuple(raw.get("risk_factors") or ()), + ) diff --git a/Discovery/adr_discovery/extractor/__init__.py b/Discovery/adr_discovery/extractor/__init__.py new file mode 100644 index 0000000..a8b8403 --- /dev/null +++ b/Discovery/adr_discovery/extractor/__init__.py @@ -0,0 +1,270 @@ +"""M3 -- turns a located surface into declarations. + +The least glamorous module, and the one where hostile or merely sloppy +input does the most damage. Two things are true of every return value: one +malformed record never removes its valid siblings, and the count reported +is the count that was in the file. +""" + +from __future__ import annotations + +import re + +from ..contracts.records import Candidate, Declaration, ExtractError, Extraction, Kind +from ..redact import rules as redact +from .formats import format_for, parse_bytes +from .formats.workflow import agent_steps, env_names_of +from .isolate import as_args, as_env, as_text, per_record +from .surfaces import SURFACE_NAMES, extract_surface, surface_for + +__all__ = ["extract", "SCOPES", "SURFACE_NAMES"] + +#: Every settings scope a host application actually loads. Reading two of +#: four and reporting the result as a count is how the hooks probe returned +#: eight of twenty-eight. +SCOPES: tuple[tuple[str, str], ...] = ( + ("user", "settings.json"), + ("project", ".mcp.json"), + ("project_local", "settings.local.json"), + ("plugin", "mcp.json"), +) + +#: Declarations are capped per surface; a cap that fires reports the true count. +RECORD_CAP = 500 + + +def extract(gate, candidate: Candidate) -> Extraction: + """Read one surface and emit the declarations inside it. + + Two shapes of surface reach this function: a config *file* holding + records, and a *directory* whose members are the records. They share + the isolation rule and the true-count rule; only the reader differs. + """ + if candidate.kind == "instruction_file": + return Extraction( + declarations=(Declaration( + kind=Kind.INSTRUCTIONS, + name=candidate.path.rsplit("/", 1)[-1], + path=candidate.path, + scope=_scope_of(candidate.path), + raw={"surface": Kind.INSTRUCTIONS.value}, + ),), + declared=1, + ) + + if candidate.kind == "shell_profile": + return _shell_profile(gate, candidate.path) + + if surface_for(candidate.path) is not None: + directory = extract_surface(gate, candidate, _scope_of(candidate.path)) + if directory is not None: + if directory.truncated: + gate.ledger.truncate(candidate.path, len(directory.declarations), directory.declared) + return directory + return Extraction(declared=0) + + fmt = format_for(candidate.path) + if fmt is None: + return Extraction(declared=0) + + raw = gate.read_bytes(candidate.path) + if not raw.ok: + return Extraction(errors=(ExtractError(candidate.path, None, raw.reason),), declared=0) + + try: + document = parse_bytes(raw.value, fmt) + except Exception as exc: + if "/.mcpb/" in candidate.path and candidate.path.endswith("/manifest.json"): + return Extraction( + declarations=(Declaration( + kind=Kind.MCP_BUNDLE, + name=candidate.path.rstrip("/").rsplit("/", 2)[-2], + path=candidate.path, + scope=_scope_of(candidate.path), + raw={"flags": ("malformed",), "surface": Kind.MCP_BUNDLE.value}, + ),), + errors=(ExtractError(candidate.path, None, f"{type(exc).__name__}: {exc}"),), + declared=1, + ) + gate.ledger.probe("extractor", "degraded", f"{candidate.path}: {type(exc).__name__}") + return Extraction( + errors=(ExtractError(candidate.path, None, f"{type(exc).__name__}: {exc}"),), + declared=0, + ) + + if not isinstance(document, dict): + return Extraction( + errors=(ExtractError(candidate.path, None, "document root is not a mapping"),), + declared=0, + ) + + scope = _scope_of(candidate.path) + + if fmt == "workflow": + return _workflow(document, candidate.path) + + servers = _server_block(document) + chunks: list[Extraction] = [] + if servers: + records = list(servers.items()) + chunks.append(per_record( + records, + lambda i, rec: _server(rec, candidate.path, scope), + candidate.path, + cap=RECORD_CAP, + )) + + hooks = list(_hook_records(document)) + if hooks: + chunks.append(per_record( + hooks, + lambda i, rec: _hook(rec, candidate.path, scope, i), + candidate.path, + cap=RECORD_CAP, + )) + + if not chunks: + return Extraction(declared=0) + + result = Extraction( + declarations=tuple(d for chunk in chunks for d in chunk.declarations), + errors=tuple(e for chunk in chunks for e in chunk.errors), + declared=sum(chunk.declared for chunk in chunks), + truncated=any(chunk.truncated for chunk in chunks), + ) + + if result.truncated: + gate.ledger.truncate(candidate.path, len(result.declarations), result.declared) + return result + + +def _hook_records(document: dict): + """Yield each executable hook independently, preserving its event.""" + block = document.get("hooks") + if not isinstance(block, dict): + return + for event, matchers in block.items(): + if not isinstance(matchers, list): + continue + for matcher in matchers: + if not isinstance(matcher, dict): + continue + nested = matcher.get("hooks") + if not isinstance(nested, list): + continue + for hook in nested: + if isinstance(hook, dict) and hook.get("type") == "command" and hook.get("command"): + yield str(event), hook + + +def _hook(record, path: str, scope: str, index: int) -> Declaration: + event, body = record + command = as_text(body.get("command"), "command") + scrubbed = redact.scrub_argv((command,))[0] + return Declaration( + kind=Kind.HOOK, + name=f"{event} hook {index + 1}", + path=path, + scope=scope, + command=scrubbed, + raw={"event": event, "surface": Kind.HOOK.value}, + ) + + +_EXPORT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=") + + +def _shell_profile(gate, path: str) -> Extraction: + """Collect only AI credential variable names, never profile values.""" + raw = gate.read_text(path, limit=256 * 1024) + if not raw.ok: + return Extraction(errors=(ExtractError(path, None, raw.reason),), declared=0) + names = [] + for line in raw.value.splitlines(): + match = _EXPORT.match(line) + if match and redact.credential_kinds((match.group(1),)): + names.append(match.group(1)) + if not names: + return Extraction(declared=0) + return Extraction( + declarations=(Declaration( + kind=Kind.AGENT_PLATFORM, + name="AI credential environment", + path=path, + scope="user", + env_names=tuple(sorted(set(names))), + raw={"surface": "shell_profile", "env_names": tuple(sorted(set(names)))}, + ),), + declared=1, + ) + + +def _workflow(document: dict, path: str) -> Extraction: + """An agent arranged to run with no person present.""" + steps = list(agent_steps(document)) + return per_record( + steps, + lambda index, record: _ci_agent(record, path), + path, + cap=RECORD_CAP, + ) + + +def _ci_agent(record, path: str) -> Declaration: + job_name, step, reference, catalog_id = record + return Declaration( + kind=Kind.CI_AGENT, + name=f"{reference} ({job_name})", + path=path, + scope="ci", + command=reference, + env_names=env_names_of(step), + raw={"catalog_id": catalog_id, "job": job_name, "unattended": True, + "transport": "ci"}, + ) + + +def _server_block(document: dict) -> dict: + """MCP servers live under different keys in different host apps.""" + for key in ("mcpServers", "mcp_servers", "servers", "mcp"): + block = document.get(key) + if isinstance(block, dict): + inner = block.get("servers") + return inner if isinstance(inner, dict) else block + return {} + + +def _server(record: tuple[str, object], path: str, scope: str) -> Declaration: + name, body = record + if not isinstance(body, dict): + raise TypeError(f"server {name!r} must be a mapping, got {type(body).__name__}") + + args = as_args(body.get("args")) + env = as_env(body.get("env")) + url = body.get("url") + + return Declaration( + kind=Kind.MCP_SERVER, + name=str(name), + path=path, + scope=scope, + command=as_text(body["command"], "command") if "command" in body else None, + args=redact.scrub_argv(args), + env_names=redact.env_names(env), + url=redact.strip_url(as_text(url, "url")) if url is not None else None, + raw={"transport": body.get("type") or ("http" if url else "stdio")}, + ) + + +def _scope_of(path: str) -> str: + name = path.rsplit("/", 1)[-1] + for scope, filename in SCOPES: + if name == filename: + return scope + if path.startswith(("/etc/", "/Library/Application Support/ADR/", "/Library/Application Support/ClaudeCode/")): + return "enterprise_managed" + if "/plugins/" in path or "/plugin/" in path: + return "plugin" + if "/Library/" in path or "/.config/" in path or path.count("/") <= 3: + return "user" + return "project" diff --git a/Discovery/adr_discovery/extractor/formats/__init__.py b/Discovery/adr_discovery/extractor/formats/__init__.py new file mode 100644 index 0000000..039615c --- /dev/null +++ b/Discovery/adr_discovery/extractor/formats/__init__.py @@ -0,0 +1,49 @@ +"""Real parsers, always. + +Every defect in the last two review rounds that reached production +behaviour came from hand-rolled parsing of structured input: TOML split on +punctuation, flags split on the first separator, image references split on +a colon, URLs split before the port. Each function below hands the bytes to +a parser that understands the grammar. +""" + +from __future__ import annotations + +import json as _json +import plistlib as _plistlib +import tomllib as _tomllib + +from . import yaml as _yaml + +Unrepresentable = _yaml.Unrepresentable + + +def parse(text: str, fmt: str) -> dict: + if fmt == "json": + return _json.loads(text) + if fmt == "toml": + return _tomllib.loads(text) + if fmt in ("yaml", "yml", "workflow"): + return _yaml.loads(text) + raise ValueError(f"no parser for format {fmt!r}") + + +def parse_bytes(data: bytes, fmt: str) -> dict: + if fmt == "plist": + return _plistlib.loads(data) + return parse(data.decode("utf-8", errors="replace"), fmt) + + +def format_for(path: str) -> str | None: + lowered = path.lower() + if lowered.endswith(".json") or lowered.endswith(".jsonc"): + return "json" + if lowered.endswith(".toml"): + return "toml" + if lowered.endswith(".plist"): + return "plist" + if "/.github/workflows/" in lowered and (lowered.endswith(".yml") or lowered.endswith(".yaml")): + return "workflow" + if lowered.endswith(".yaml") or lowered.endswith(".yml"): + return "yaml" + return None diff --git a/Discovery/adr_discovery/extractor/formats/frontmatter.py b/Discovery/adr_discovery/extractor/formats/frontmatter.py new file mode 100644 index 0000000..09a5ec9 --- /dev/null +++ b/Discovery/adr_discovery/extractor/formats/frontmatter.py @@ -0,0 +1,51 @@ +"""YAML frontmatter, read under an allowlist. + +A skill file is two things stacked: a small block of operational metadata, +and a body of prose that tells an agent what to do. The body is business +context and C2 forbids reading it, so this parser stops at the closing +delimiter and never returns what follows. + +The key allowlist is the second half of the same rule. `description` is +deliberately absent: it is the field most likely to describe what a team +does, and knowing a skill exists and what it is permitted to touch is the +whole of what an inventory needs. +""" + +from __future__ import annotations + +from .yaml import Unrepresentable, loads + +DELIMITER = "---" +MAX_FRONTMATTER_LINES = 60 + +#: Operational metadata only. Anything not named here is not read. +ALLOWED_KEYS: frozenset[str] = frozenset( + {"name", "model", "allowed-tools", "allowed_tools", "tools", + "disable-model-invocation", "argument-hint", "version", "kind"} +) + + +class NoFrontmatter(ValueError): + """The file does not open with a frontmatter block.""" + + +def parse(text: str) -> dict: + """Return the allowlisted keys, and nothing else. + + Raises rather than guessing, so a file whose frontmatter cannot be + represented becomes a recorded error instead of a wrong value. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != DELIMITER: + raise NoFrontmatter("file does not open with a frontmatter delimiter") + + block: list[str] = [] + for line in lines[1 : MAX_FRONTMATTER_LINES + 1]: + if line.strip() == DELIMITER: + document = loads("\n".join(block)) + return {k: v for k, v in document.items() if k in ALLOWED_KEYS} + block.append(line) + + raise Unrepresentable( + f"frontmatter not closed within {MAX_FRONTMATTER_LINES} lines" + ) diff --git a/Discovery/adr_discovery/extractor/formats/workflow.py b/Discovery/adr_discovery/extractor/formats/workflow.py new file mode 100644 index 0000000..5805757 --- /dev/null +++ b/Discovery/adr_discovery/extractor/formats/workflow.py @@ -0,0 +1,73 @@ +"""CI workflows, read for the agents they run. + +A workflow is not an asset. What matters is that a repository is arranged +to run an agent with no person present -- which is the AI-agents target, +and is invisible to every other source because nothing about it exists on +the endpoint except a YAML file in a directory nobody scans for binaries. + +Only the step's action reference and its declared environment names are +read. Nothing here reads a `run:` body: that is a script, out of scope by +ยง1, and it is also arbitrary user text. +""" + +from __future__ import annotations + +from ..isolate import as_env + +#: Action references that are an agent, not a build step. Prefix match, so +#: a version suffix does not have to be enumerated. +AGENT_ACTIONS: tuple[tuple[str, str], ...] = ( + ("anthropics/claude-code-action", "claude-code"), + ("anthropics/claude-code-base-action", "claude-code"), + ("openai/codex-action", "codex-cli"), + ("google-github-actions/run-gemini-cli", "gemini-cli"), + ("github/copilot", "github-copilot"), + ("cursor/cursor-agent", "cursor"), + ("continuedev/continue-action", "continue"), + ("aider-ai/aider-action", "aider"), +) + + +def steps_of(document: dict): + """Yield (job_name, step) for every step in a workflow document.""" + jobs = document.get("jobs") + if not isinstance(jobs, dict): + return + for job_name, job in jobs.items(): + if not isinstance(job, dict): + continue + steps = job.get("steps") + if not isinstance(steps, list): + continue + for step in steps: + if isinstance(step, dict): + yield str(job_name), step + + +def agent_steps(document: dict): + """Only the steps that run an agent. + + A workflow full of build steps yields nothing, which is the precision + half: `uses: actions/checkout@v4` is not an AI agent however many + agents run after it. + """ + for job_name, step in steps_of(document): + uses = step.get("uses") + if not isinstance(uses, str): + continue + reference = uses.split("@", 1)[0].strip() + for prefix, catalog_id in AGENT_ACTIONS: + if reference == prefix or reference.startswith(prefix + "/"): + yield job_name, step, reference, catalog_id + break + + +def env_names_of(step: dict) -> tuple[str, ...]: + try: + names = tuple(sorted(str(k) for k in as_env(step.get("env")))) + except TypeError: + names = () + with_block = step.get("with") + if isinstance(with_block, dict): + names += tuple(sorted(f"with.{k}" for k in with_block)) + return names diff --git a/Discovery/adr_discovery/extractor/formats/yaml.py b/Discovery/adr_discovery/extractor/formats/yaml.py new file mode 100644 index 0000000..fabc2a3 --- /dev/null +++ b/Discovery/adr_discovery/extractor/formats/yaml.py @@ -0,0 +1,124 @@ +"""A YAML subset that refuses rather than guesses. + +The last hand-rolled parser standing, written to fail in the only honest +direction: a construct outside the subset raises, so the record becomes a +recorded error instead of a plausible wrong value. Silent mis-parsing of +structured input produced every production defect in the last two review +rounds, and a subset parser that guesses is precisely that engine. + +Supported nested mappings by indentation, block sequences of scalars and + of mappings, quoted and bare scalars, booleans, integers, + floats, null, `#` comments +Refused anchors, aliases, tags, flow collections, multi-line scalars, + multiple documents, merge keys +""" + +from __future__ import annotations + +REFUSED_PREFIXES = ("&", "*", "!", "<<", "---", "...") +REFUSED_CHARS = ("{", "[") + + +class Unrepresentable(ValueError): + """Outside the subset. Never mis-parse it instead.""" + + +def loads(text: str) -> dict: + lines: list[tuple[int, int, str]] = [] + for lineno, raw in enumerate(text.splitlines(), 1): + line = raw.split(" #", 1)[0].rstrip() if " #" in raw else raw.rstrip() + if not line.strip() or line.lstrip().startswith("#"): + continue + lines.append((lineno, len(line) - len(line.lstrip()), line.strip())) + + value, consumed = _block(lines, 0, lines[0][1] if lines else 0) + if consumed != len(lines): + raise Unrepresentable(f"line {lines[consumed][0]}: indentation does not resolve") + return value if isinstance(value, dict) else {"": value} + + +def _block(lines, i: int, indent: int): + """Parse every line at `indent`, returning the value and lines consumed.""" + if i < len(lines) and lines[i][2].startswith("- "): + return _sequence(lines, i, indent) + return _mapping(lines, i, indent) + + +def _sequence(lines, i: int, indent: int): + """Block sequence. An item is a scalar, or a mapping introduced on the + dash line and continued at the column just past it.""" + out: list = [] + while i < len(lines) and lines[i][1] == indent and lines[i][2].startswith("- "): + lineno, _, body = lines[i] + rest = body[2:].strip() + i += 1 + + if ":" not in rest: + out.append(_scalar(rest, lineno)) + continue + + # `- uses: x` opens a mapping whose first key sits two columns in. + item_indent = indent + 2 + block = [(lineno, item_indent, rest)] + while i < len(lines) and lines[i][1] > indent: + block.append(lines[i]) + i += 1 + value, consumed = _mapping(block, 0, item_indent) + if consumed != len(block): + raise Unrepresentable(f"line {block[consumed][0]}: indentation does not resolve") + out.append(value) + return out, i + + +def _mapping(lines, i: int, indent: int): + out: dict = {} + while i < len(lines): + lineno, depth, body = lines[i] + if depth < indent: + break + if depth > indent: + raise Unrepresentable(f"line {lineno}: unexpected indentation") + _guard(body, lineno) + if ":" not in body: + raise Unrepresentable(f"line {lineno}: not a mapping entry") + key, _, rest = body.partition(":") + key, rest = key.strip(), rest.strip() + i += 1 + if rest: + out[key] = _scalar(rest, lineno) + continue + if i < len(lines) and lines[i][1] > indent: + out[key], i = _block(lines, i, lines[i][1]) + else: + out[key] = None + return out, i + + +def _guard(body: str, lineno: int) -> None: + if body.startswith(REFUSED_PREFIXES): + raise Unrepresentable(f"line {lineno}: construct outside the supported subset") + tail = body.split(":", 1)[-1] + if any(c in tail for c in REFUSED_CHARS): + raise Unrepresentable(f"line {lineno}: flow collection outside the supported subset") + + +def _scalar(token: str, lineno: int): + if token.startswith(REFUSED_PREFIXES) or any(c in token for c in REFUSED_CHARS): + raise Unrepresentable(f"line {lineno}: scalar outside the supported subset") + if len(token) >= 2 and token[0] == token[-1] and token[0] in "\"'": + return token[1:-1] + low = token.lower() + if low in ("true", "yes"): + return True + if low in ("false", "no"): + return False + if low in ("null", "~", ""): + return None + try: + return int(token) + except ValueError: + pass + try: + return float(token) + except ValueError: + return token diff --git a/Discovery/adr_discovery/extractor/isolate.py b/Discovery/adr_discovery/extractor/isolate.py new file mode 100644 index 0000000..cabdb0c --- /dev/null +++ b/Discovery/adr_discovery/extractor/isolate.py @@ -0,0 +1,79 @@ +"""The per-record try boundary, written once and applied everywhere. + +The whole of M3's change lives here. Under per-*file* isolation one bad +record abandons the file and four valid siblings vanish -- and the reported +count is silently wrong, which is worse than the loss. Under per-*record* +isolation the survivors survive, the bad one becomes an error, and the +count reported is the count that was in the file. +""" + +from __future__ import annotations + +from typing import Callable, Iterable, Sequence + +from ..contracts.records import Declaration, ExtractError, Extraction + + +def per_record( + records: Sequence[object], + parse_one: Callable[[int, object], Declaration], + path: str, + cap: int | None = None, +) -> Extraction: + """Parse each record behind its own boundary. + + `declared` is the number of records *present*, taken before parsing and + before the cap, so a truncated or partially-failed read still reports + what was really there. + """ + declared = len(records) + window: Iterable[tuple[int, object]] = enumerate(records) + truncated = False + if cap is not None and declared > cap: + window = list(enumerate(records))[:cap] + truncated = True + + good: list[Declaration] = [] + bad: list[ExtractError] = [] + for index, record in window: + try: + good.append(parse_one(index, record)) + except Exception as exc: # one record, one boundary + bad.append(ExtractError(path, index, f"{type(exc).__name__}: {exc}")) + + return Extraction(tuple(good), tuple(bad), declared, truncated) + + +# --------------------------------------------------------------- shape rules +# +# A string where an array belongs is one argument, not one argument per +# character -- and it changes both the identity and the pinning verdict. + + +def as_args(value: object) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + raise TypeError("args must be an array of scalars, not a string") + if not isinstance(value, (list, tuple)): + raise TypeError(f"args must be an array, got {type(value).__name__}") + out: list[str] = [] + for item in value: + if isinstance(item, (dict, list, tuple)): + raise TypeError("args must contain scalars only") + out.append(str(item)) + return tuple(out) + + +def as_env(value: object) -> dict[str, object]: + if value is None: + return {} + if not isinstance(value, dict): + raise TypeError(f"env must be a mapping, got {type(value).__name__}") + return value + + +def as_text(value: object, field: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{field} must be a string, got {type(value).__name__}") + return value diff --git a/Discovery/adr_discovery/extractor/surfaces.py b/Discovery/adr_discovery/extractor/surfaces.py new file mode 100644 index 0000000..0808dbd --- /dev/null +++ b/Discovery/adr_discovery/extractor/surfaces.py @@ -0,0 +1,135 @@ +"""Directory-shaped declarations. + +Skills, commands, agent definitions, output styles and plugins are not +records inside a config file -- they are files in a structure a host +application knows how to load. The unit of extraction is therefore a +directory, and the same per-record isolation applies: one unreadable skill +never removes its siblings, and the count reported is the count on disk. + +Nothing here reads a body. A skill file contributes its path, its name and +whatever operational metadata its frontmatter declares; the prose beneath +is business context and stays on the machine (C2). +""" + +from __future__ import annotations + +from types import MappingProxyType + +from ..contracts.records import Declaration, Kind +from .formats.frontmatter import NoFrontmatter +from .formats.frontmatter import parse as parse_frontmatter +from .isolate import per_record + +#: marker directory name -> (kind, file suffix, whether the entry is a dir) +SURFACES: tuple[tuple[str, Kind, tuple[str, ...], bool], ...] = ( + ("skills", Kind.SKILL, (".md",), True), + ("commands", Kind.COMMAND, (".md", ".toml"), False), + ("prompts", Kind.COMMAND, (".md",), False), + ("agents", Kind.AGENT_DEFINITION, (".md",), False), + ("output-styles", Kind.OUTPUT_STYLE, (".md",), False), + ("plugins", Kind.PLUGIN, (".json",), True), +) + +SURFACE_NAMES = frozenset(name for name, _, _, _ in SURFACES) +ENTRY_CAP = 500 + + +def surface_for(path: str): + """Which surface, if any, this marker directory is.""" + name = path.rstrip("/").rsplit("/", 1)[-1] + for surface, kind, suffixes, nested in SURFACES: + if name == surface: + return kind, suffixes, nested + return None + + +def extract_surface(gate, candidate, scope: str): + """One directory of declarations.""" + surface = surface_for(candidate.path) + if surface is None: + return None + kind, suffixes, nested = surface + + listing = gate.list_dir(candidate.path) + if not listing.ok: + return None + + entries = [e for e in listing.value if _is_member(e, suffixes, nested)] + return per_record( + entries, + lambda index, entry: _declaration(gate, entry, kind, scope, nested), + candidate.path, + cap=ENTRY_CAP, + ) + + +def _is_member(entry, suffixes: tuple[str, ...], nested: bool) -> bool: + name = entry.path.rsplit("/", 1)[-1] + if name.startswith("."): + return False + if nested: + return entry.is_dir + return any(name.endswith(s) for s in suffixes if s) + + +#: A directory is only a member of its surface if it carries the manifest +#: the host application loads. Without this, `~/.claude/plugins/cache` and +#: every marketplace listing under it become "plugins" -- which is how one +#: endpoint reported 191 assets, most of them internal bookkeeping. +MANIFESTS = MappingProxyType({ + Kind.SKILL: ("/SKILL.md",), + Kind.PLUGIN: ("/.claude-plugin/plugin.json", "/plugin.json"), +}) + + +def _declaration(gate, entry, kind: Kind, scope: str, nested: bool) -> Declaration: + name = entry.path.rsplit("/", 1)[-1] + body_path = entry.path + + if nested: + body_path = _manifest_of(gate, entry.path, kind, name) + + metadata: dict[str, object] = {} + if body_path.endswith(".md"): + raw = gate.read_text(body_path, limit=64 * 1024) + if raw.ok: + try: + metadata = parse_frontmatter(raw.value) + except NoFrontmatter: + metadata = {} + + declared_name = str(metadata.get("name") or name.removesuffix(".md")) + tools = metadata.get("allowed-tools") or metadata.get("allowed_tools") or metadata.get("tools") + + return Declaration( + kind=kind, + name=declared_name, + path=entry.path, + scope=scope, + raw={ + "surface": kind.value, + # What it is permitted to touch, which is the whole reason a + # skill is inventory and not documentation. + "allowed_tools": _as_tuple(tools), + "model": metadata.get("model"), + }, + ) + + +def _manifest_of(gate, path: str, kind: Kind, name: str) -> str: + for suffix in MANIFESTS.get(kind, ()): + if gate.stat(path + suffix).ok: + return path + suffix + raise FileNotFoundError( + f"{name}: no {' or '.join(MANIFESTS.get(kind, ('manifest',)))} -- not a {kind.value}" + ) + + +def _as_tuple(value) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return tuple(part.strip() for part in value.split(",") if part.strip()) + if isinstance(value, (list, tuple)): + return tuple(str(v) for v in value) + return () diff --git a/Discovery/adr_discovery/identifier/__init__.py b/Discovery/adr_discovery/identifier/__init__.py new file mode 100644 index 0000000..4049c16 --- /dev/null +++ b/Discovery/adr_discovery/identifier/__init__.py @@ -0,0 +1,13 @@ +"""M4 -- decides what a candidate actually is. + +No verdict rests on a filename alone. Every verdict carries the proof that +produced it, so a reviewer can check it and M5 can weigh it. +""" + +from __future__ import annotations + +from .ladder import binary_format, content_hash, identify +from .openworld import THRESHOLD, is_reviewable, score, signals_for +from .verify import check_version + +__all__ = ["identify", "content_hash", "binary_format", "check_version", "score", "signals_for", "is_reviewable", "THRESHOLD"] diff --git a/Discovery/adr_discovery/identifier/ladder.py b/Discovery/adr_discovery/identifier/ladder.py new file mode 100644 index 0000000..f3e53bb --- /dev/null +++ b/Discovery/adr_discovery/identifier/ladder.py @@ -0,0 +1,259 @@ +"""The evidence ladder -- cheapest and strongest first. + +A candidate stops climbing as soon as it has proof, which is both the cost +control and the correctness rule. Convention sits on the bottom rung and +raises priority; it never concludes, because the next rename breaks any +verdict that rests on it. + + 1 provenance which package owns this file one cacheable query + 2 content hash, format metadata, strings one size-capped read + 3 convention filename, path, directory shape free, never conclusive + +Discovered executables are untrusted input and are never run. Behavioural +signals come from already-observed process and network state, not probes. +""" + +from __future__ import annotations + +import hashlib + +from ..contracts.evidence import Channel, Evidence, Rung +from ..contracts.records import Candidate, Kind, Verdict +from .openworld import score, signals_for + +MAX_HASH_BYTES = 8 * 1024 * 1024 + +#: Executable formats, by magic. This is descriptive metadata only; format +#: is not evidence that a file belongs to a particular catalog entry. +_MAGIC: tuple[tuple[bytes, str], ...] = ( + (b"\x7fELF", "elf"), + (b"\xcf\xfa\xed\xfe", "mach-o"), + (b"\xce\xfa\xed\xfe", "mach-o"), + (b"\xca\xfe\xba\xbe", "mach-o-universal"), + (b"MZ", "pe"), + (b"#!", "script"), +) + + +def binary_format(gate, path: str) -> str | None: + """What kind of executable this is, from its first bytes. + + Format is metadata, not identity: any attacker can create a file with + the right magic bytes. + """ + raw = gate.read_bytes(path, limit=64) + if not raw.ok or not raw.value: + return None + for magic, name in _MAGIC: + if raw.value.startswith(magic): + return name + return None + + +def content_hash(gate, path: str, limit: int = MAX_HASH_BYTES) -> str | None: + """One size-capped read. Returns None when the target is not a file we + are allowed, or able, to read -- never a hash of partial bytes.""" + stat = gate.stat(path) + if not stat.ok or stat.value.size > limit: + return None + raw = gate.read_bytes(path, limit=limit) + if not raw.ok or len(raw.value) < stat.value.size: + return None + return hashlib.sha256(raw.value).hexdigest() + + +def identify(gate, candidate: Candidate, catalog) -> Verdict: + """Produce a verdict, with the proof that produced it.""" + name = candidate.path.rsplit("/", 1)[-1] + + if candidate.kind == "model_weight_candidate": + raw = gate.read_bytes(candidate.path, limit=64) + if raw.ok and _is_model_weight(raw.value, candidate.path): + return Verdict( + catalog_id=None, kind=Kind.MODEL_WEIGHTS, name=name, + rung=Rung.CONTENT, + evidence=(Evidence("identifier", Channel.FILESYSTEM, candidate.path, + "verified model-weight format", 0.85, Rung.CONTENT),), + ) + + # ---------------------------------------------------------- rung 1 + entry, evidence, pkg_version = _by_provenance(gate, candidate, catalog) + if entry is not None: + # Before accepting it, ask whether any other channel names something + # else. A silent preference is how an inventory becomes confidently + # wrong; a recorded conflict is something a reviewer can settle. + conflict = _conflicting(catalog, candidate, name, entry) + return _catalogued(entry, candidate, Rung.PROVENANCE, evidence, + version=pkg_version, conflict=conflict) + + # ---------------------------------------------------------- rung 2 + entry, evidence = _by_content(gate, candidate, catalog) + if entry is not None: + return _catalogued(entry, candidate, Rung.CONTENT, evidence) + + # ---------------------------------------------------------- rung 3 (as a hint) + suspected = _suspected(catalog, candidate, name) + + # ------------------------------------------------- uncatalogued, scored + signals = signals_for(candidate) + value, fired = score(candidate, signals) + hint = ( + (Evidence("identifier", Channel.FILESYSTEM, candidate.path, + f"name resembles {suspected.id!r}", 0.1, Rung.CONVENTION),) + if suspected is not None else () + ) + return Verdict( + catalog_id=None, kind=None, name=name, rung=Rung.CONVENTION if hint else None, + evidence=hint or ( + Evidence("identifier", Channel.FILESYSTEM, candidate.path, + "no conclusive evidence", 0.0, None), + ), + signals=fired, score=value, + ) + + +# ------------------------------------------------------------------ rungs + + +def _by_provenance(gate, candidate: Candidate, catalog): + """Definitive for anything a package manager installed, which is most + things -- and it survives a rename, because the package record does.""" + if candidate.source.startswith("package:"): + manager = candidate.source.split(":", 1)[1] + pkg_name = str(candidate.detail.get("name", "")) + entry = catalog.match("packages", f"{manager}:{pkg_name}") + if entry is not None: + return entry, ( + Evidence("identifier", Channel.PACKAGE, candidate.path, + f"{manager} owns {pkg_name}", 0.95, Rung.PROVENANCE), + ), candidate.detail.get("version") + + # An OS application registry and an editor/browser extension registry are + # installed-artifact records, not filename conventions. Their stable IDs + # are provenance and are sufficient to establish identity. + if candidate.kind == "application": + ident = str(candidate.detail.get("ident", "")) + entry = catalog.match("bundle_ids", ident) or catalog.match("desktop_ids", ident) + if entry is not None: + return entry, ( + Evidence("identifier", Channel.REGISTRY, candidate.path, + f"application registry id {ident}", 0.9, Rung.PROVENANCE), + ), candidate.detail.get("version") + if candidate.kind == "extension": + ident = str(candidate.detail.get("extension_id", "")) + entry = catalog.match("extension_ids", ident) + if entry is not None: + return entry, ( + Evidence("identifier", Channel.REGISTRY, candidate.path, + f"extension registry id {ident}", 0.9, Rung.PROVENANCE), + ), candidate.detail.get("version") + + # Package ownership is a subprocess on Linux. Running it for every entry + # in /usr/bin turns one scan into thousands of sequential dpkg queries. + # Package-registry candidates were already handled above; for filesystem + # binaries, query ownership only when another channel makes the path + # relevant. Live processes and journal events remain eligible because + # execution itself is that independent signal and must survive renames. + basename = candidate.path.rstrip("/").rsplit("/", 1)[-1] + ownership_relevant = ( + candidate.kind in ("process", "exec_event") + or catalog.match("binaries", basename) is not None + ) + if not ownership_relevant: + return None, (), None + + owner = gate.package_owner(candidate.path) + if owner.ok: + pkg = owner.value + entry = catalog.match("packages", f"{pkg.manager}:{pkg.name}") + if entry is not None: + return entry, ( + Evidence("identifier", Channel.PACKAGE, candidate.path, + f"{pkg.manager} -S resolved to {pkg.name}", 0.95, Rung.PROVENANCE), + ), pkg.version + return None, (), None + + +def _by_content(gate, candidate: Candidate, catalog): + """Survives renaming, catches copies, identifies self-compiled builds.""" + known = catalog.index.get("content_hashes") or {} + if not known: + return None, () + digest = content_hash(gate, candidate.path) + if digest is None: + return None, () + entry_id = known.get(digest.lower()) + if entry_id is None: + return None, () + return catalog.by_id[entry_id], ( + Evidence("identifier", Channel.FILESYSTEM, candidate.path, + f"sha256 {digest[:12]} is a known build", 0.9, Rung.CONTENT), + ) + + +def _suspected(catalog, candidate: Candidate, name: str): + """Convention. Raises priority; concludes nothing.""" + for kind, value in ( + ("binaries", name), + ("bundle_ids", str(candidate.detail.get("ident", ""))), + ("desktop_ids", str(candidate.detail.get("ident", ""))), + ("extension_ids", str(candidate.detail.get("extension_id", ""))), + ("ports", str(candidate.detail.get("port", ""))), + ("state_dirs", candidate.path), + ("state_dirs", _home_relative(candidate.path)), + ): + if not value: + continue + entry = catalog.match(kind, value) + if entry is not None: + return entry + return None + + +def _home_relative(path: str) -> str: + """`~/.claude` in the catalog must match /Users/alice/.claude on disk.""" + for base in ("/Users/", "/home/"): + if path.startswith(base): + tail = path[len(base):] + if "/" in tail: + return "~/" + tail.split("/", 1)[1] + return "" + + +def _conflicting(catalog, candidate: Candidate, name: str, chosen) -> str | None: + """Another channel naming a different entry.""" + other = _suspected(catalog, candidate, name) + if other is not None and other.id != chosen.id: + return f"convention names {other.id!r}, provenance names {chosen.id!r}" + return None + + +def _catalogued(entry, candidate: Candidate, rung: Rung, evidence, + version: object = None, conflict: str | None = None) -> Verdict: + """Identity is settled without executing the discovered artifact.""" + version = version or candidate.detail.get("version") + extra: tuple[Evidence, ...] = () + if conflict is not None: + extra += (Evidence("identifier", Channel.FILESYSTEM, candidate.path, + conflict, 0.0, Rung.CONVENTION),) + return Verdict( + catalog_id=entry.id, kind=_kind(entry), name=entry.name, vendor=entry.vendor, + version=str(version) if version else None, rung=rung, + evidence=tuple(evidence) + extra, conflict=conflict, + ) + + +def _kind(entry) -> Kind | None: + try: + return Kind(entry.kind) + except ValueError: + return None + + +def _is_model_weight(header: bytes, path: str) -> bool: + if header.startswith(b"GGUF"): + return True + if path.endswith(".safetensors") and len(header) >= 9: + length = int.from_bytes(header[:8], "little") + return 1 < length < (1 << 30) and header[8:9] == b"{" + return False diff --git a/Discovery/adr_discovery/identifier/openworld.py b/Discovery/adr_discovery/identifier/openworld.py new file mode 100644 index 0000000..ea587bb --- /dev/null +++ b/Discovery/adr_discovery/identifier/openworld.py @@ -0,0 +1,55 @@ +"""The open-world half. + +Everything the catalog rejects is scored on properties rather than +identity. Above threshold it becomes a review-queue item -- triage, never a +finding -- and a repeated one is what grows the catalog without shipping a +collector. +""" + +from __future__ import annotations + +from types import MappingProxyType + +from ..contracts.records import Candidate + +#: Signal -> weight. Properties, not names: nothing here asks what a thing +#: is called, which is the whole point of scoring the uncatalogued. +WEIGHTS = MappingProxyType({ + "network_intent": 0.35, # talks to a model provider + "credential_affinity": 0.25, # holds provider credentials + "mcp_participation": 0.20, # speaks MCP, or is declared as a server + "state_shape": 0.15, # keeps conversation-shaped state + "runtime_shape": 0.05, # answers a model-serving protocol +}) + +THRESHOLD = 0.40 + + +def score(candidate: Candidate, signals: set[str]) -> tuple[float, tuple[str, ...]]: + fired = tuple(sorted(s for s in signals if s in WEIGHTS)) + return round(sum(WEIGHTS[s] for s in fired), 3), fired + + +def signals_for(candidate: Candidate, credential_kinds: tuple[str, ...] = ()) -> set[str]: + found: set[str] = set() + detail = candidate.detail + + if candidate.kind in ("network_peer", "dns_peer") and detail.get("provider"): + found.add("network_intent") + if candidate.kind == "model_port": + found.add("runtime_shape") + if credential_kinds: + found.add("credential_affinity") + if candidate.kind in ("marker_file", "state_dir") and "mcp" in candidate.path.lower(): + found.add("mcp_participation") + if candidate.kind == "state_dir": + found.add("state_shape") + + env_names = detail.get("env_names") or () + if any("API_KEY" in str(n).upper() or "TOKEN" in str(n).upper() for n in env_names): + found.add("credential_affinity") + return found + + +def is_reviewable(value: float) -> bool: + return value >= THRESHOLD diff --git a/Discovery/adr_discovery/identifier/verify.py b/Discovery/adr_discovery/identifier/verify.py new file mode 100644 index 0000000..6c55ffb --- /dev/null +++ b/Discovery/adr_discovery/identifier/verify.py @@ -0,0 +1,9 @@ +"""Compatibility refusal for the removed executable-probe API.""" + +from __future__ import annotations + + +def check_version(gate, binary: str, probe: tuple[str, ...], shape: str | None) -> tuple[str | None, str]: + """Refuse executable probes; discovered artifacts are untrusted input.""" + del gate, binary, probe, shape + return None, "executable probes are disabled" diff --git a/Discovery/adr_discovery/tests_unit/test_m3_extractor.py b/Discovery/adr_discovery/tests_unit/test_m3_extractor.py new file mode 100644 index 0000000..f17e6eb --- /dev/null +++ b/Discovery/adr_discovery/tests_unit/test_m3_extractor.py @@ -0,0 +1,154 @@ +"""M3 -- extractor. + +Two things are asserted on every case: the declarations that survived, and +the count that was reported. The count is checked against the file rather +than against what the parser produced, which is the only way the isolation +claim can be measured at all. +""" + +from __future__ import annotations + +from adr_discovery.contracts.records import Candidate, Kind +from adr_discovery.extractor import extract + +CFG = "/proj/.mcp.json" + + +def read(world, document, path=CFG): + world.json(path, document) + return extract(world.gate(), Candidate(kind="marker_file", path=path, source="sweep")) + + +def test_u3_01_one_bad_record_does_not_remove_its_siblings(world): + result = read(world, {"mcpServers": { + "a": {"command": "npx", "args": ["-y", "a@1.0.0"]}, + "b": {"command": "uvx", "args": ["b"]}, + "c": "not a mapping", + "d": {"command": "docker", "args": ["run", "img:tag"]}, + "e": {"url": "https://h.example/sse"}, + }}) + + assert len(result.declarations) == 4 + assert len(result.errors) == 1 + assert result.declared == 5, "the reported count is what was in the file" + + +def test_u3_02_args_as_a_string_is_a_shape_error_not_a_split(world): + result = read(world, {"mcpServers": {"x": {"command": "npx", "args": "--port 8080"}}}) + + assert result.declarations == () + assert "array" in result.errors[0].reason + assert result.declared == 1 + + +def test_u3_03_env_must_be_a_mapping(world): + result = read(world, {"mcpServers": {"y": {"command": "c", "env": ["A=1"]}}}) + + assert "mapping" in result.errors[0].reason + + +def test_u3_04_a_real_parser_reads_toml(world): + world.file("/proj/config.toml", + '[mcp_servers.z]\ncommand = "docker"\nargs = ["run", "ghcr.io/x/y@sha256:ab34"]\n') + result = extract(world.gate(), Candidate("marker_file", "/proj/config.toml", "sweep")) + + (declaration,) = result.declarations + assert declaration.args == ("run", "ghcr.io/x/y@sha256:ab34"), "not split on the colon" + + +def test_u3_05_a_url_keeps_its_port_and_loses_its_query(world): + result = read(world, {"mcpServers": {"r": {"url": "https://u:pw@h.example:8443/sse?tok=SECRET#f"}}}) + + (declaration,) = result.declarations + assert declaration.url == "https://h.example:8443/sse" + assert "SECRET" not in declaration.url and "pw" not in declaration.url + + +def test_u3_06_a_digest_reference_survives_whole(world): + result = read(world, {"mcpServers": { + "d": {"command": "docker", "args": ["run", "ghcr.io/x/y@sha256:ab34cd56"]}}}) + + assert result.declarations[0].args[-1] == "ghcr.io/x/y@sha256:ab34cd56" + + +def test_u3_07_every_settings_scope_is_read(world): + scopes = { + "/Users/a/.claude/settings.json": "user", + "/proj/.mcp.json": "project", + "/proj/settings.local.json": "project_local", + "/proj/plugins/p/mcp.json": "plugin", + } + seen = set() + for path in scopes: + result = read(world, {"mcpServers": {"s": {"command": "x"}}}, path=path) + assert result.declared == 1, f"{path} was not read" + seen.add(result.declarations[0].scope) + + assert seen == set(scopes.values()), "reading two of four and reporting the total is the defect" + + +def test_u3_08_a_cap_reports_the_true_count(world, monkeypatch): + import adr_discovery.extractor as extractor + + monkeypatch.setattr(extractor, "RECORD_CAP", 3) + result = read(world, {"mcpServers": {f"s{i}": {"command": "x"} for i in range(10)}}) + + assert len(result.declarations) == 3 + assert result.declared == 10, "the true count, not the capped one" + assert result.truncated + + +def test_u3_09_an_unrepresentable_construct_is_refused_not_guessed(world): + world.file("/proj/config.yaml", "mcp_servers:\n q: &anchor\n command: x\n") + result = extract(world.gate(), Candidate("marker_file", "/proj/config.yaml", "sweep")) + + assert result.declarations == () + assert "Unrepresentable" in result.errors[0].reason + + +def test_hooks_are_emitted_individually_beside_mcp_servers(world): + result = read(world, { + "mcpServers": {"s": {"command": "node", "args": ["server.js"]}}, + "hooks": {"PreToolUse": [{"matcher": "*", "hooks": [ + {"type": "command", "command": "audit --event pre"}, + {"type": "command", "command": "audit --token secret"}, + ]}]}, + }, path="/Users/a/.claude/settings.json") + + assert result.declared == 3 + assert [d.kind for d in result.declarations] == [Kind.MCP_SERVER, Kind.HOOK, Kind.HOOK] + + +def test_instruction_files_are_declared_without_reading_the_body(world): + world.file("/Users/a/.codex/AGENTS.md", "private instructions") + result = extract(world.gate(), Candidate("instruction_file", "/Users/a/.codex/AGENTS.md", "sweep")) + + assert result.declared == 1 + assert result.declarations[0].kind is Kind.INSTRUCTIONS + assert "private instructions" not in repr(result.declarations[0]) + + +def test_shell_profile_keeps_credential_names_and_drops_values(world): + world.file("/Users/a/.bashrc", "export PATH=/bin\nexport ANTHROPIC_API_KEY=top-secret\n") + result = extract(world.gate(), Candidate("shell_profile", "/Users/a/.bashrc", "app_state:config")) + + assert result.declared == 1 + assert result.declarations[0].env_names == ("ANTHROPIC_API_KEY",) + assert "top-secret" not in repr(result.declarations[0]) + + +def test_malformed_mcp_bundle_is_inventory_not_a_server(world): + path = "/Users/a/.mcpb/broken/manifest.json" + world.file(path, '{"name": "broken", "server": {') + result = extract(world.gate(), Candidate("marker_file", path, "sweep")) + + assert result.declared == 1 + assert result.declarations[0].kind is Kind.MCP_BUNDLE + assert result.declarations[0].raw["flags"] == ("malformed",) + + +def test_managed_config_has_enterprise_scope(world): + result = read(world, {"mcpServers": {"s": {"command": "node"}}}, + path="/etc/adr/managed-mcp.json") + + assert result.declarations[0].scope == "enterprise_managed" diff --git a/Discovery/adr_discovery/tests_unit/test_m3_surfaces.py b/Discovery/adr_discovery/tests_unit/test_m3_surfaces.py new file mode 100644 index 0000000..97ecf75 --- /dev/null +++ b/Discovery/adr_discovery/tests_unit/test_m3_surfaces.py @@ -0,0 +1,107 @@ +"""M3 -- directory-shaped surfaces. + +Skills, commands, agent definitions, output styles and plugins are files in +a structure a host application loads. They are the Skills target, and the +rule that governs them is C2: their existence and their permissions are +inventory, their prose is not. +""" + +from __future__ import annotations + +from adr_discovery.contracts.records import Candidate, Kind +from adr_discovery.extractor import extract + +SKILL_BODY = """--- +name: deploy-runbook +allowed-tools: Bash, Read +model: opus +--- + +# Deploy runbook + +Internal: call the payments team before touching the ledger service. +""" + + +def read(world, path): + return extract(world.gate(), Candidate(kind="marker_dir", path=path, source="sweep")) + + +def test_a_skill_directory_becomes_declarations(world): + world.file("/Users/a/.claude/skills/deploy-runbook/SKILL.md", SKILL_BODY) + world.file("/Users/a/.claude/skills/other/SKILL.md", "---\nname: other\n---\nbody\n") + + result = read(world, "/Users/a/.claude/skills") + + assert result.declared == 2 + assert {d.kind for d in result.declarations} == {Kind.SKILL} + assert {d.name for d in result.declarations} == {"deploy-runbook", "other"} + + +def test_the_permissions_are_kept_and_the_prose_is_not(world): + world.file("/Users/a/.claude/skills/deploy-runbook/SKILL.md", SKILL_BODY) + + (declaration,) = read(world, "/Users/a/.claude/skills").declarations + + assert declaration.raw["allowed_tools"] == ("Bash", "Read") + assert declaration.raw["model"] == "opus" + blob = repr(declaration) + assert "payments team" not in blob and "ledger service" not in blob + + +def test_description_is_not_read_even_when_present(world): + """The field most likely to describe what a team does is not on the + allowlist, so it cannot reach a snapshot.""" + world.file("/Users/a/.claude/skills/s/SKILL.md", + "---\nname: s\ndescription: reconcile the EMEA merchant ledger\n---\nbody\n") + + (declaration,) = read(world, "/Users/a/.claude/skills").declarations + + assert "EMEA" not in repr(declaration) + assert "description" not in declaration.raw + + +def test_commands_and_agents_and_output_styles(world): + world.file("/Users/a/.claude/commands/ship.md", "---\nname: ship\n---\ngo\n") + world.file("/Users/a/.claude/agents/reviewer.md", "---\nname: reviewer\nmodel: sonnet\n---\ngo\n") + world.file("/Users/a/.claude/output-styles/terse.md", "---\nname: terse\n---\ngo\n") + + kinds = {} + for surface in ("commands", "agents", "output-styles"): + result = read(world, f"/Users/a/.claude/{surface}") + assert result.declared == 1, surface + kinds[surface] = result.declarations[0].kind + + assert kinds == {"commands": Kind.COMMAND, "agents": Kind.AGENT_DEFINITION, + "output-styles": Kind.OUTPUT_STYLE} + + +def test_one_broken_skill_does_not_remove_its_siblings(world): + world.file("/Users/a/.claude/skills/good/SKILL.md", "---\nname: good\n---\nbody\n") + world.dir("/Users/a/.claude/skills/empty") # no SKILL.md at all + world.file("/Users/a/.claude/skills/bad/SKILL.md", "---\nname: &anchor\n---\nbody\n") + + result = read(world, "/Users/a/.claude/skills") + + assert result.declared == 3 + assert len(result.declarations) == 1 + assert len(result.errors) == 2 + + +def test_a_file_without_frontmatter_still_counts(world): + """A command that is plain markdown is still a command.""" + world.file("/Users/a/.claude/commands/bare.md", "just prose, no frontmatter\n") + + (declaration,) = read(world, "/Users/a/.claude/commands").declarations + + assert declaration.name == "bare" + assert declaration.raw["allowed_tools"] == () + + +def test_a_repository_marker_declares_nothing(world): + """`.git` locates a repository. It is not a surface with records.""" + world.dir("/Users/a/proj/.git") + + result = read(world, "/Users/a/proj/.git") + + assert result.declared == 0 and result.declarations == () diff --git a/Discovery/adr_discovery/tests_unit/test_m4_identifier.py b/Discovery/adr_discovery/tests_unit/test_m4_identifier.py new file mode 100644 index 0000000..6c5088b --- /dev/null +++ b/Discovery/adr_discovery/tests_unit/test_m4_identifier.py @@ -0,0 +1,189 @@ +"""M4 -- identifier. + +Every case asserts the verdict *and* the rung that produced it. A correct +answer reached by convention is a failing case here, because the next +rename breaks it. +""" + +from __future__ import annotations + +from adr_discovery.contracts.evidence import Rung +from adr_discovery.contracts.records import Candidate, Priority +from adr_discovery.identifier import identify, is_reviewable, score, signals_for + + +def candidate(path, **detail): + return Candidate("binary", path, detail.pop("source", "sweep"), Priority.HOME, detail) + + +def test_u4_01_a_rename_does_not_hide_a_real_agent(world, catalog): + world.exe("/opt/tools/notes-helper", "2.1.234") + world.surface("packages", [{"manager": "npm", "name": "@anthropic-ai/claude-code", + "version": "2.1.234", "path": "/opt/tools/notes-helper"}]) + gate = world.gate() + + verdict = identify(gate, candidate("/opt/tools/notes-helper", source="package:npm", + name="@anthropic-ai/claude-code", version="2.1.234"), catalog) + + assert verdict.catalog_id == "claude-code" + assert verdict.rung is Rung.PROVENANCE + assert verdict.version == "2.1.234" + assert verdict.is_concluded + + +def test_kilo_cli_has_package_provenance(world, catalog): + world.surface("packages", [{"manager": "npm", "name": "@kilocode/cli", + "version": "7.4.23", "path": "/opt/tools/kilo"}]) + verdict = identify(world.gate(), candidate("/opt/tools/kilo", source="package:npm", + name="@kilocode/cli", version="7.4.23"), catalog) + + assert verdict.catalog_id == "kilo-cli" + assert verdict.version == "7.4.23" + + +def test_u4_02_a_decoy_is_not_believed(world, catalog): + """A catalog-looking filename is only convention and is never run.""" + world.exe("/decoy/gemini", "sleep (GNU coreutils) 9.4") + gate = world.gate() + + verdict = identify(gate, candidate("/decoy/gemini"), catalog) + + assert verdict.catalog_id is None + assert verdict.version is None + assert not verdict.is_concluded + assert verdict.rung is Rung.CONVENTION, "a name may raise priority and nothing more" + assert gate.calls["run"] == 0 + + +def test_u4_02b_a_catalog_looking_binary_is_not_executed(world, catalog): + world.binary("/real/gemini", '#!/bin/sh\ntouch "$0.executed"\necho 0.55.1\n') + gate = world.gate() + + verdict = identify(gate, candidate("/real/gemini"), catalog) + + assert verdict.catalog_id is None + assert verdict.rung is Rung.CONVENTION + assert gate.calls["run"] == 0 + assert not __import__("os").path.exists(world.root + "/real/gemini.executed") + + +def test_u4_03_the_ladder_stops_at_the_first_proof(world, catalog): + world.exe("/opt/tools/claude", "2.1.234") + world.surface("packages", [{"manager": "npm", "name": "@anthropic-ai/claude-code", + "version": "2.1.234", "path": "/opt/tools/claude"}]) + gate = world.gate() + before = gate.calls["run"] + + identify(gate, candidate("/opt/tools/claude"), catalog) + + assert gate.calls["run"] == before, "provenance settled it; no subprocess should be spent" + + +def test_u4_04_convention_never_concludes(world, catalog): + world.dir("/somewhere/claude-code") + gate = world.gate() + + verdict = identify(gate, candidate("/somewhere/claude-code"), catalog) + + assert not verdict.is_concluded + + +def test_u4_07_an_uncatalogued_ai_shape_goes_to_the_review_queue(world, catalog): + """An unknown binary that holds provider credentials *and* talks to a + model provider. Two properties, no name involved.""" + peer = Candidate("network_peer", "api.anthropic.com", "network:established", + Priority.HOME, {"provider": True, "pid": 8812, + "env_names": ("ANTHROPIC_API_KEY", "PATH")}) + value, fired = score(peer, signals_for(peer)) + + assert set(fired) == {"network_intent", "credential_affinity"} + assert is_reviewable(value), "properties, not names, put this in triage" + + +def test_u4_07b_one_signal_alone_sits_below_the_threshold(world, catalog): + """Documents where the line currently falls. + + A lone outbound connection to a model provider scores 0.35 against a + threshold of 0.40, so it is *not* queued on its own. That is a tuning + decision, not a law: the design argues this connection is the one piece + of evidence an unknown tool cannot suppress, which is an argument for + raising it. Asserted here so the choice is visible rather than implicit. + """ + peer = Candidate("network_peer", "api.anthropic.com", "network:established", + Priority.HOME, {"provider": True}) + value, fired = score(peer, signals_for(peer)) + + assert fired == ("network_intent",) + assert value == 0.35 and not is_reviewable(value) + + +def test_u4_08_nothing_is_a_verdict(world, catalog): + world.exe("/usr/bin/ls", "ls (GNU coreutils) 9.4") + gate = world.gate() + + verdict = identify(gate, candidate("/usr/bin/ls"), catalog) + + assert verdict.catalog_id is None + assert not is_reviewable(verdict.score) + assert gate.calls["package_owner"] == 0, "irrelevant system binaries must not trigger package queries" + + +def test_u4_09_no_verdict_is_bare(world, catalog): + world.exe("/opt/tools/claude", "2.1.234") + world.exe("/decoy/gemini", "sleep (GNU coreutils) 9.4") + world.dir("/somewhere/cursor") + gate = world.gate() + + for path in ("/opt/tools/claude", world.root + "/decoy/gemini", "/somewhere/cursor"): + verdict = identify(gate, candidate(path), catalog) + assert verdict.evidence, f"{path} produced a verdict with no evidence" + if verdict.is_concluded: + assert verdict.rung is not Rung.CONVENTION + + +def test_u4_05_an_unknown_compiled_build_is_not_executed(world, catalog): + """An executable format plus a matching name still does not establish identity.""" + import os + + world.file("/opt/built/gemini", "\x7fELF\x02\x01\x01" + "\x00" * 64) + os.chmod(world.root + "/opt/built/gemini", 0o755) + gate = world.gate() + + from adr_discovery.identifier import binary_format + + assert binary_format(gate, "/opt/built/gemini") == "elf" + assert binary_format(gate, "/opt/shipped/gemini") is None # not written yet + + world.exe("/opt/shipped/gemini", "0.55.1") + verdict = identify(gate, candidate("/opt/shipped/gemini"), catalog) + + assert verdict.catalog_id is None + assert not verdict.is_concluded + assert gate.calls["run"] == 0 + + +def test_u4_05b_a_shell_wrapper_is_not_content_evidence(world, catalog): + """A `#!` file and plausible output establish neither content nor identity.""" + world.exe("/opt/shipped/gemini", "0.55.1") + gate = world.gate() + + verdict = identify(gate, candidate("/opt/shipped/gemini"), catalog) + + assert verdict.rung is Rung.CONVENTION + assert Rung.CONTENT not in {e.rung for e in verdict.evidence} + assert gate.calls["run"] == 0 + + +def test_u4_06_channels_that_disagree_record_a_conflict(world, catalog): + """Provenance says one thing, the name says another. Neither is picked + silently: the conflict is recorded so a reviewer can settle it.""" + world.exe("/opt/tools/gemini", "2.1.234") + world.surface("packages", [{"manager": "npm", "name": "@anthropic-ai/claude-code", + "version": "2.1.234", "path": "/opt/tools/gemini"}]) + gate = world.gate() + + verdict = identify(gate, candidate("/opt/tools/gemini"), catalog) + + assert verdict.catalog_id == "claude-code", "provenance outranks a filename" + assert verdict.conflict is not None + assert "gemini-cli" in verdict.conflict and "claude-code" in verdict.conflict