interlockd 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.
Files changed (84) hide show
  1. interlockd/__init__.py +3 -0
  2. interlockd/__main__.py +7 -0
  3. interlockd/aibom.py +136 -0
  4. interlockd/api.py +307 -0
  5. interlockd/auth.py +38 -0
  6. interlockd/calibration.py +260 -0
  7. interlockd/cli.py +1920 -0
  8. interlockd/compose.py +84 -0
  9. interlockd/config.py +80 -0
  10. interlockd/corpus.py +505 -0
  11. interlockd/data/packs/_corpus.yaml +7 -0
  12. interlockd/data/packs/egress.jsonl +38 -0
  13. interlockd/data/packs/fileops.jsonl +20 -0
  14. interlockd/data/packs/git.jsonl +51 -0
  15. interlockd/data/packs/packages.jsonl +24 -0
  16. interlockd/data/packs/persistence.jsonl +31 -0
  17. interlockd/data/packs/secrets.jsonl +22 -0
  18. interlockd/data/packs/shell.jsonl +75 -0
  19. interlockd/det/__init__.py +53 -0
  20. interlockd/det/describe.py +66 -0
  21. interlockd/det/engine.py +153 -0
  22. interlockd/det/flow.py +106 -0
  23. interlockd/det/mandate.py +108 -0
  24. interlockd/det/mcp.py +70 -0
  25. interlockd/det/memory.py +50 -0
  26. interlockd/det/packs.py +531 -0
  27. interlockd/det/shellparse.py +438 -0
  28. interlockd/egress.py +124 -0
  29. interlockd/gateway.py +350 -0
  30. interlockd/ids.py +25 -0
  31. interlockd/insights.py +316 -0
  32. interlockd/integrations/__init__.py +2 -0
  33. interlockd/integrations/client.py +85 -0
  34. interlockd/integrations/langchain.py +59 -0
  35. interlockd/integrations/openai_agents.py +72 -0
  36. interlockd/integrations/strands.py +69 -0
  37. interlockd/judges/__init__.py +107 -0
  38. interlockd/judges/guardrails.py +94 -0
  39. interlockd/judges/local_probe.py +192 -0
  40. interlockd/judges/projector.py +82 -0
  41. interlockd/judges/strands_decider.py +160 -0
  42. interlockd/ledger.py +62 -0
  43. interlockd/mcp_http.py +216 -0
  44. interlockd/mcp_proxy.py +315 -0
  45. interlockd/mcp_server.py +211 -0
  46. interlockd/models.py +98 -0
  47. interlockd/normalize/__init__.py +1 -0
  48. interlockd/normalize/claude_code.py +89 -0
  49. interlockd/normalize/cursor.py +59 -0
  50. interlockd/normalize/gemini_cli.py +55 -0
  51. interlockd/normalize/kiro.py +65 -0
  52. interlockd/normalize/more.py +98 -0
  53. interlockd/ogr.py +542 -0
  54. interlockd/onboard.py +300 -0
  55. interlockd/otel.py +139 -0
  56. interlockd/paths.py +63 -0
  57. interlockd/pipeline.py +370 -0
  58. interlockd/policy/__init__.py +220 -0
  59. interlockd/policy_lint.py +76 -0
  60. interlockd/render.py +142 -0
  61. interlockd/sandbox.py +87 -0
  62. interlockd/scan.py +405 -0
  63. interlockd/schema/verdict.schema.json +68 -0
  64. interlockd/selftest.py +101 -0
  65. interlockd/service.py +247 -0
  66. interlockd/shims/claude-code/interlock-hook.sh +113 -0
  67. interlockd/shims/kiro/interlock-hook.mjs +101 -0
  68. interlockd/shims/kiro/interlock-hook.sh +82 -0
  69. interlockd/shims/opencode/interlock.js +60 -0
  70. interlockd/store/__init__.py +22 -0
  71. interlockd/store/drainer.py +74 -0
  72. interlockd/store/dynamodb.py +144 -0
  73. interlockd/store/memory.py +28 -0
  74. interlockd/store/spool.py +159 -0
  75. interlockd/testkit.py +401 -0
  76. interlockd/tiers.py +95 -0
  77. interlockd/transcript/__init__.py +1 -0
  78. interlockd/transcript/claude_code.py +73 -0
  79. interlockd/transcript/kiro.py +102 -0
  80. interlockd-0.1.0.dist-info/METADATA +125 -0
  81. interlockd-0.1.0.dist-info/RECORD +84 -0
  82. interlockd-0.1.0.dist-info/WHEEL +4 -0
  83. interlockd-0.1.0.dist-info/entry_points.txt +2 -0
  84. interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,153 @@
1
+ """Deterministic engine entry point: route a normalized event through the packs the policy enables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..models import InterlockEvent
6
+ from ..paths import is_protected, is_sensitive, is_under, normalize_path
7
+ from ..policy import Policy
8
+ from ..tiers import (
9
+ DESTRUCTIVE_TOOLS,
10
+ IN_WORKSPACE_WRITE_TOOLS,
11
+ NETWORK_TOOLS,
12
+ PATH_KEYS,
13
+ SHELL_TOOLS,
14
+ TierResult,
15
+ )
16
+ from . import DetResult
17
+ from .mcp import check_mcp_policy
18
+ from .packs import (
19
+ R_EGRESS_SHARE,
20
+ R_EGRESS_UNKNOWN,
21
+ R_SECRET_IN_RESULT,
22
+ R_SECRET_READ,
23
+ _host_allowed,
24
+ _host_of,
25
+ check_egress,
26
+ check_git,
27
+ check_packages,
28
+ check_persistence_file_op,
29
+ check_persistence_shell,
30
+ check_secrets_shell,
31
+ check_shell,
32
+ scan_secrets,
33
+ )
34
+ from .shellparse import parse
35
+
36
+ HARM = {"read": 0, "harness": 0, "build_or_test": 0, "edit_in_workspace": 0, "edit_outside_workspace": 2, "edit_protected": 3,
37
+ "install_or_configure": 2, "vcs_history_or_remote": 2, "network_egress": 2, "destructive": 2, "credential_access": 3,
38
+ "shell": 1, "subagent": 1, "mcp": 1, "other": 1}
39
+
40
+
41
+ def harm_for(res: DetResult) -> int:
42
+ """0 recoverable locally · 1 recoverable with effort · 2 hard to reverse · 3 irreversible or affects others."""
43
+ h = HARM.get(res.action_class, 1)
44
+ sev = {"low": 0, "medium": 1, "high": 2, "critical": 3}
45
+ for f in res.findings:
46
+ h = max(h, sev.get(f.severity, 1))
47
+ return min(h, 3)
48
+
49
+
50
+ def _command_of(event: InterlockEvent) -> str | None:
51
+ a = event.tool_call.arguments
52
+ for k in ("command", "cmd", "script", "commandLine"):
53
+ v = a.get(k)
54
+ if isinstance(v, str) and v.strip():
55
+ return v
56
+ if isinstance(a.get("args"), list) and a.get("args"):
57
+ return " ".join(str(x) for x in a["args"])
58
+ return None
59
+
60
+
61
+ def evaluate(event: InterlockEvent, policy: Policy, tier: TierResult) -> DetResult:
62
+ res = DetResult(action_class=tier.action_class)
63
+ cwd = event.context.cwd
64
+ enabled = set(policy.packs.enabled)
65
+ name = event.tool_call.name
66
+ args = event.tool_call.arguments
67
+ roots = policy.trust.workspace_roots or ([cwd] if cwd else [])
68
+
69
+ if event.kind == "post_action":
70
+ if "secrets" in enabled and event.result is not None:
71
+ scan_secrets(event.result.content, "result.content", res, policy, R_SECRET_IN_RESULT)
72
+ res.harm = harm_for(res)
73
+ return res
74
+
75
+ if tier.action_class == "mcp" or event.context.mcp_server:
76
+ check_mcp_policy(event, res, policy)
77
+ # MCP and unrecognized tools that run commands (terminal servers, SDK tools named run_command / exec / terminal)
78
+ # get the same shell analysis as the agent's own shell; only explicit command keys count, since a generic `args`
79
+ # list means something else on most tools
80
+ mcp_cmd = next((args[k] for k in ("command", "cmd", "script", "commandLine")
81
+ if isinstance(args.get(k), str) and args[k].strip()), None) if tier.action_class in ("mcp", "other") else None
82
+ if name in SHELL_TOOLS or mcp_cmd is not None:
83
+ cmd = _command_of(event) if mcp_cmd is None else mcp_cmd
84
+ if cmd is not None:
85
+ parsed = parse(cmd)
86
+ res.normalized["commands"] = parsed.commands()[:20]
87
+ if res.action_class == "shell":
88
+ res.action_class = "build_or_test" if parsed.commands() and all(
89
+ c in BUILD_TOOLS for c in parsed.commands()) else "shell"
90
+ if "shell" in enabled:
91
+ check_shell(parsed, res, policy, cwd)
92
+ if "git" in enabled:
93
+ check_git(parsed, res, policy)
94
+ if "egress" in enabled:
95
+ check_egress(parsed, res, policy, cwd)
96
+ if "persistence" in enabled:
97
+ check_persistence_shell(parsed, res, policy, cwd)
98
+ if "secrets" in enabled:
99
+ check_secrets_shell(parsed, res, policy, cwd)
100
+ if "packages" in enabled:
101
+ check_packages(parsed, res, policy, cwd)
102
+ elif name in IN_WORKSPACE_WRITE_TOOLS or name in DESTRUCTIVE_TOOLS or tier.paths:
103
+ for p in tier.paths or [normalize_path(args[k], cwd) for k in PATH_KEYS if isinstance(args.get(k), str)]:
104
+ where = "tool_call.arguments.path"
105
+ if "persistence" in enabled and name in IN_WORKSPACE_WRITE_TOOLS:
106
+ check_persistence_file_op(p, res, policy, cwd, where)
107
+ if "secrets" in enabled and is_sensitive(p, policy.trust.sensitive_paths):
108
+ res.add(policy, R_SECRET_READ, path=where, subject=p)
109
+ if name in DESTRUCTIVE_TOOLS and roots and not is_under(p, roots):
110
+ from .packs import R_RM_OUTSIDE
111
+ res.add(policy, R_RM_OUTSIDE, path=where, subject=p)
112
+ if name in IN_WORKSPACE_WRITE_TOOLS and is_protected(p, roots, policy.trust.protected_paths) and not res.findings:
113
+ from .packs import R_PERSIST_AGENT_CONFIG
114
+ res.add(policy, R_PERSIST_AGENT_CONFIG, path=where, subject=p)
115
+ content = args.get("content") or args.get("new_str") or args.get("file_text") or args.get("new_string") or ""
116
+ if res.normalized.get("memory_write") and isinstance(content, str) and content:
117
+ from ..judges.local_probe import heuristic_score
118
+ from .memory import R_MEMORY_INJECTED
119
+
120
+ score, hits = heuristic_score(content)
121
+ if score >= 0.6:
122
+ res.add(policy, R_MEMORY_INJECTED, path="tool_call.arguments.content",
123
+ subject=f"{res.normalized['memory_write']}: content reads as instructions to the agent ({score:.2f}: {', '.join(hits[:4])})")
124
+ if "secrets" in enabled and isinstance(content, str) and content:
125
+ from .packs import R_SECRET_IN_ARGS
126
+ scan_secrets(content, "tool_call.arguments.content", res, policy, R_SECRET_IN_ARGS)
127
+ elif name in NETWORK_TOOLS and "egress" in enabled:
128
+ url = args.get("url") or args.get("query") or ""
129
+ host = _host_of(str(url)) if url else None
130
+ if host:
131
+ # the same host checks, in the same order, as shell egress (packs.check_egress): web tools and the egress
132
+ # proxy reach the metadata endpoint just as well as curl does
133
+ from .packs import METADATA_HOSTS, R_EGRESS_METADATA, R_EGRESS_RAW_IP, SHARE_HOSTS, _is_ip
134
+
135
+ res.normalized["egress_hosts"] = [host]
136
+ where = "tool_call.arguments.url"
137
+ if host in METADATA_HOSTS or host.startswith("169.254."):
138
+ res.add(policy, R_EGRESS_METADATA, path=where, subject=host)
139
+ elif any(s in host for s in SHARE_HOSTS):
140
+ res.add(policy, R_EGRESS_SHARE, path=where, subject=host)
141
+ elif _is_ip(host) and not _host_allowed(host, policy.trust.allowed_hosts):
142
+ res.add(policy, R_EGRESS_RAW_IP, path=where, subject=host)
143
+ elif not _host_allowed(host, policy.trust.allowed_hosts):
144
+ res.add(policy, R_EGRESS_UNKNOWN, path=where, subject=host)
145
+
146
+ res.harm = harm_for(res)
147
+ return res
148
+
149
+
150
+ BUILD_TOOLS = {"npm", "pnpm", "yarn", "bun", "node", "python", "python3", "pytest", "uv", "pip", "make", "cmake", "cargo", "go",
151
+ "mvn", "gradle", "./gradlew", "tsc", "eslint", "ruff", "black", "mypy", "prettier", "jest", "vitest", "ls", "cat",
152
+ "echo", "pwd", "which", "env", "head", "tail", "grep", "rg", "find", "wc", "sort", "uniq", "diff", "git", "sed", "awk",
153
+ "cd", "true", "test", "sleep", "date", "docker", "kubectl"}
interlockd/det/flow.py ADDED
@@ -0,0 +1,106 @@
1
+ """Toxic-flow detection: the "lethal trifecta" across one session.
2
+
3
+ An agent that (1) has read private data, (2) has taken in untrusted content, and (3) now calls something that can send
4
+ data out, is one injected instruction away from exfiltration. Each call alone looks fine, so per-call rules miss it.
5
+ The session ledger keeps the first two legs with evidence; the third is checked on every pre-action call.
6
+
7
+ private sensitive-path reads, secrets seen in a tool result, MCP tools matching `flow.private_sources`
8
+ untrusted web / network tool results, MCP results matching `flow.untrusted_sources`, any result the injection probe
9
+ flagged
10
+ sink network egress (shell or web tool), git push, MCP tools matching `flow.sinks`
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from fnmatch import fnmatchcase
16
+ from typing import Any
17
+
18
+ from ..models import Finding, InterlockEvent
19
+ from ..policy import Policy
20
+ from ..tiers import NETWORK_TOOLS
21
+ from . import DetResult, rule
22
+ from .mcp import split_mcp_name
23
+
24
+ R_FLOW_TRIFECTA = rule("flow.lethal_trifecta", "security.data_exfiltration", "high", "ask")
25
+ R_FLOW_MEMORY = rule("flow.memory_poisoning", "security.memory_poisoning", "high", "ask")
26
+ PRIVATE_RULES = {"secrets.read_sensitive_path", "secrets.dump_environment", "secrets.literal_in_result"}
27
+
28
+
29
+ def _mcp_target(event: InterlockEvent) -> str | None:
30
+ st = split_mcp_name(event)
31
+ return f"{st[0]}/{st[1]}" if st else None
32
+
33
+
34
+ def _matches(target: str | None, patterns: list[str]) -> bool:
35
+ return bool(target) and any(fnmatchcase(target, p) for p in patterns)
36
+
37
+
38
+ def _legs(session: dict[str, Any]) -> dict[str, list[str]]:
39
+ return session.setdefault("flow", {"private": [], "untrusted": []})
40
+
41
+
42
+ def _note(legs: dict, leg: str, evidence: str) -> None:
43
+ if evidence not in legs[leg]:
44
+ legs[leg] = (legs[leg] + [evidence])[-5:]
45
+
46
+
47
+ def observe(event: InterlockEvent, session: dict[str, Any], policy: Policy, findings: list[Finding]) -> None:
48
+ """Update the session's private / untrusted legs from a decided event (pre or post)."""
49
+ if not policy.flow.enabled:
50
+ return
51
+ legs = _legs(session)
52
+ target = _mcp_target(event)
53
+ name = event.tool_call.name
54
+ for f in findings:
55
+ if f.rule in PRIVATE_RULES:
56
+ _note(legs, "private", f"{f.rule}: {(f.subject or '')[:80]}")
57
+ if event.kind == "post_action":
58
+ if _matches(target, policy.flow.private_sources):
59
+ _note(legs, "private", f"read from {target}")
60
+ if name in NETWORK_TOOLS:
61
+ _note(legs, "untrusted", f"{name} result")
62
+ if _matches(target, policy.flow.untrusted_sources):
63
+ _note(legs, "untrusted", f"result from {target}")
64
+ if any(f.category == "security.prompt_injection" for f in findings):
65
+ _note(legs, "untrusted", f"{name} result flagged as injected")
66
+
67
+
68
+ def is_sink(event: InterlockEvent, res: DetResult, policy: Policy) -> str | None:
69
+ if res.normalized.get("egress_hosts"):
70
+ return "network egress to " + ", ".join(res.normalized["egress_hosts"][:3])
71
+ if event.tool_call.name in NETWORK_TOOLS:
72
+ return f"{event.tool_call.name} request"
73
+ cmds = res.normalized.get("commands") or []
74
+ if "git" in cmds and "push" in str(event.tool_call.arguments.get("command", "")):
75
+ return "git push"
76
+ target = _mcp_target(event)
77
+ if _matches(target, policy.flow.sinks):
78
+ return f"MCP {target}"
79
+ return None
80
+
81
+
82
+ def check(event: InterlockEvent, res: DetResult, session: dict[str, Any], policy: Policy) -> None:
83
+ """Pre-action: fire when this call is a sink and both other legs are already present in the session."""
84
+ if not policy.flow.enabled or event.kind != "pre_action":
85
+ return
86
+ legs = _legs(session)
87
+ if legs["untrusted"]:
88
+ from .memory import is_memory_tool
89
+
90
+ target = _mcp_target(event)
91
+ mem = res.normalized.get("memory_write") or (f"MCP {target}" if is_memory_tool(target) else None)
92
+ if mem:
93
+ res.add(policy, R_FLOW_MEMORY, path="session",
94
+ subject=f"this session took in untrusted content ({legs['untrusted'][-1]}); now writing persistent agent memory: {mem}")
95
+ if not (legs["private"] and legs["untrusted"]):
96
+ return
97
+ sink = is_sink(event, res, policy)
98
+ if not sink:
99
+ return
100
+ subject = (f"this session read private data ({legs['private'][-1]}) and took in untrusted content "
101
+ f"({legs['untrusted'][-1]}); now: {sink}")
102
+ if policy.rule_action(R_FLOW_TRIFECTA, policy.flow.action) is not None:
103
+ before = len(res.findings)
104
+ res.add(policy, R_FLOW_TRIFECTA, path="session", subject=subject)
105
+ if len(res.findings) > before and R_FLOW_TRIFECTA not in policy.packs.overrides:
106
+ res.actions[R_FLOW_TRIFECTA] = policy.flow.action # flow.action unless an explicit rule override
@@ -0,0 +1,108 @@
1
+ """Cedar mandate (design D8): the operator's authorization envelope, evaluated locally with cedarpy.
2
+
3
+ Bindings map the deterministic engine's normalized structure onto Interlock::Action names and a context.
4
+ Unbound actions are reported in `unjudged`, never silently permitted (OGR mandate semantics). Cedar's own
5
+ default-deny applies only to bound actions when at least one policy exists.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..models import Finding
15
+ from ..policy import Policy
16
+ from . import DetResult
17
+
18
+
19
+ @dataclass
20
+ class MandateResult:
21
+ evaluated: bool = False
22
+ decision: str | None = None # "Allow" | "Deny" | None
23
+ action: str | None = None
24
+ reasons: list[str] = field(default_factory=list)
25
+ unjudged: list[str] = field(default_factory=list)
26
+ finding: Finding | None = None
27
+
28
+
29
+ def bind_action(res: DetResult, tool_name: str) -> tuple[str | None, dict[str, Any]]:
30
+ """Return (Interlock::Action id, context.input) for the normalized structure, or (None, {}) if unbound."""
31
+ n = res.normalized
32
+ if "git_push" in n:
33
+ gp = n["git_push"]
34
+ return "git.push", {"remote": gp["remote"], "branch": gp["branch"], "force": bool(gp["force"]), "delete": bool(gp["delete"])}
35
+ if "git_remote_add" in n:
36
+ return "git.remote_add", {"url": n["git_remote_add"]["url"]}
37
+ if any(f.rule in ("git.history_rewrite", "git.reset_hard", "git.clean_force") for f in res.findings):
38
+ return "git.history_rewrite", {}
39
+ if res.action_class == "network_egress" and n.get("egress_hosts"):
40
+ return "net.egress", {"hosts": n["egress_hosts"]}
41
+ if res.action_class == "destructive":
42
+ return "destructive", {}
43
+ if res.action_class == "install_or_configure":
44
+ return "install", {"undeclared": n.get("undeclared_packages", [])}
45
+ if res.action_class == "credential_access":
46
+ return "fs.read_sensitive", {}
47
+ if res.action_class in ("edit_in_workspace",):
48
+ return "fs.write", {}
49
+ if res.action_class in ("edit_outside_workspace", "edit_protected"):
50
+ return "fs.write_outside", {}
51
+ if any(f.rule == "shell.sudo" for f in res.findings):
52
+ return "sys.privilege", {}
53
+ if res.action_class in ("build_or_test",):
54
+ return "shell.build_or_test", {}
55
+ return None, {}
56
+
57
+
58
+ def evaluate_mandate(policy: Policy, res: DetResult, *, agent_id: str, workspace: str, session_counters: dict[str, Any]) -> MandateResult:
59
+ out = MandateResult()
60
+ m = policy.mandate
61
+ if m.mode == "off" or (not m.cedar and not m.inline):
62
+ return out
63
+ try:
64
+ import cedarpy # lazy, optional dependency
65
+ except ImportError:
66
+ out.unjudged = ["mandate"]
67
+ return out
68
+ text = m.inline or ""
69
+ if m.cedar:
70
+ p = Path(m.cedar)
71
+ if not p.exists():
72
+ out.unjudged = ["mandate"]
73
+ return out
74
+ text = p.read_text() + "\n" + text
75
+ action, ctx_input = bind_action(res, "")
76
+ if action is None:
77
+ out.unjudged = ["mandate"]
78
+ return out
79
+ request = {
80
+ "principal": f'Interlock::Agent::"{agent_id or "agent"}"',
81
+ "action": f'Interlock::Action::"{action}"',
82
+ "resource": f'Interlock::Workspace::"{workspace or "workspace"}"',
83
+ "context": {"input": ctx_input, "session": {k.replace(":", "_"): v for k, v in session_counters.items() if isinstance(v, int)}},
84
+ }
85
+ try:
86
+ r = cedarpy.is_authorized(request, text, [])
87
+ except Exception as e: # noqa: BLE001 a policy that fails to parse or evaluate is reported, not swallowed
88
+ out.unjudged = ["mandate"]
89
+ out.reasons = [f"cedar error: {type(e).__name__}: {e}"[:200]]
90
+ return out
91
+ out.evaluated, out.action = True, action
92
+ out.decision = "Allow" if str(r.decision).endswith("Allow") else "Deny"
93
+ try:
94
+ out.reasons = list(r.diagnostics.reasons)
95
+ except Exception: # noqa: BLE001 diagnostics shape differs across cedarpy versions
96
+ out.reasons = []
97
+ if out.decision == "Deny":
98
+ dim = "capability"
99
+ if action == "git.push" and ctx_input.get("force"):
100
+ dim = "irreversible"
101
+ elif action in ("net.egress", "git.push", "git.remote_add"):
102
+ dim = "scope"
103
+ elif any(k.endswith("_count") or k.startswith("class_") for k in request["context"]["session"]) and "destructive" in action:
104
+ dim = "limit"
105
+ out.finding = Finding(category=f"security.mandate_violation.{dim}", severity="high", rule=f"mandate:{action}",
106
+ detector="interlock.mandate", path="tool_call", score=1.0,
107
+ subject=f"{action} denied by mandate" + (f" ({', '.join(out.reasons)})" if out.reasons else ""))
108
+ return out
interlockd/det/mcp.py ADDED
@@ -0,0 +1,70 @@
1
+ """MCP server / tool policy (`mcp.tools` in policy): which servers and tools an agent may use, from any harness.
2
+
3
+ Tool names differ per harness: Claude Code / Codex and the gateway use `mcp__<server>__<tool>`, Gemini CLI
4
+ `mcp_<server>_<tool>`, Cursor `MCP:<tool>` with the server in the payload. `split_mcp_name` recovers (server, tool).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fnmatch import fnmatchcase
10
+
11
+ from ..models import InterlockEvent
12
+ from ..policy import Policy
13
+ from . import DetResult, rule
14
+
15
+ R_MCP_BLOCKED = rule("mcp.tool_blocked", "security.unauthorized_tool", "high", "block")
16
+ R_MCP_ASK = rule("mcp.tool_requires_approval", "security.unauthorized_tool", "medium", "ask")
17
+
18
+
19
+ def split_mcp_name(event: InterlockEvent, known_servers: list[str] = ()) -> tuple[str, str] | None:
20
+ name = event.tool_call.name
21
+ server_hint = (event.context.mcp_server or {}).get("name")
22
+ if name.startswith("mcp__"):
23
+ parts = name[5:].split("__", 1)
24
+ return (parts[0], parts[1]) if len(parts) == 2 else (parts[0], "")
25
+ if name.startswith("MCP:"):
26
+ return (server_hint or "*", name[4:])
27
+ if name.startswith("mcp_"):
28
+ rest = name[4:]
29
+ for s in sorted([*known_servers, *([server_hint] if server_hint else [])], key=len, reverse=True):
30
+ if rest.startswith(s + "_"):
31
+ return (s, rest[len(s) + 1:])
32
+ head, _, tail = rest.partition("_") # ambiguous without a known server: first segment
33
+ return (head, tail)
34
+ if server_hint:
35
+ return (server_hint, name)
36
+ return None
37
+
38
+
39
+ def _specificity(pattern: str) -> tuple[int, int, int]:
40
+ """Tool-level beats server-level, then exact beats glob, then longer beats shorter."""
41
+ return (1 if "/" in pattern else 0, 0 if any(c in pattern for c in "*?[") else 1, len(pattern.replace("*", "")))
42
+
43
+
44
+ def resolve(policy: Policy, server: str, tool: str) -> tuple[str, str | None]:
45
+ """(action, matching pattern). Patterns without a `/` match the whole server; a pattern that names a tool always
46
+ outranks a server-wide one."""
47
+ best, best_key = None, None
48
+ for pat, action in policy.mcp.tools.items():
49
+ target = f"{server}/{tool}" if "/" in pat else server
50
+ matches = fnmatchcase(target, pat) or pat == server
51
+ if matches and (best_key is None or _specificity(pat) > _specificity(best_key)):
52
+ best, best_key = action, pat
53
+ return (best or policy.mcp.default, best_key)
54
+
55
+
56
+ def check_mcp_policy(event: InterlockEvent, res: DetResult, policy: Policy) -> None:
57
+ if not policy.mcp.tools and policy.mcp.default == "allow":
58
+ return
59
+ known = sorted({p.split("/", 1)[0] for p in policy.mcp.tools if not any(c in p.split("/", 1)[0] for c in "*?[")})
60
+ st = split_mcp_name(event, known)
61
+ if st is None:
62
+ return
63
+ server, tool = st
64
+ action, pat = resolve(policy, server, tool)
65
+ res.normalized["mcp"] = {"server": server, "tool": tool, "policy": pat or "default", "action": action}
66
+ why = f"{server}/{tool} ({'mcp.tools[' + repr(pat) + ']' if pat else 'mcp.default'}: {action})"
67
+ if action == "block":
68
+ res.add(policy, R_MCP_BLOCKED, path="tool_call.name", subject=why)
69
+ elif action == "ask":
70
+ res.add(policy, R_MCP_ASK, path="tool_call.name", subject=why)
@@ -0,0 +1,50 @@
1
+ """Memory poisoning: writes that persist into every future agent session.
2
+
3
+ Agent instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, Copilot instructions, Kiro steering, ...) and
4
+ MCP memory servers are read back into the model's context in later sessions, so an agent that was tricked into
5
+ writing to them keeps an injected instruction alive indefinitely. Writing them is normal when the user asks, so:
6
+
7
+ memory.instruction_file_write any write to an instruction file is recorded (flag)
8
+ memory.injected_instructions the written content reads as instructions aimed at the agent (ask)
9
+ flow.memory_poisoning the session already took in untrusted content (see flow.py) (ask)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fnmatch import fnmatchcase
15
+
16
+ from ..judges.local_probe import heuristic_score
17
+ from ..policy import Policy
18
+ from . import DetResult, rule
19
+
20
+ R_MEMORY_WRITE = rule("memory.instruction_file_write", "security.memory_poisoning", "medium", "flag")
21
+ R_MEMORY_INJECTED = rule("memory.injected_instructions", "security.memory_poisoning", "high", "ask")
22
+
23
+ INSTRUCTION_NAMES = ("CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
24
+ ".clinerules", "copilot-instructions.md", "CONVENTIONS.md")
25
+ INSTRUCTION_DIRS = ("/.kiro/steering/", "/.cursor/rules/", "/.github/instructions/", "/.clinerules/", "/.windsurf/rules/")
26
+ MEMORY_TOOLS = ("*memory*/create*", "*memory*/add*", "*memory*/save*", "*memory*/store*", "*memory*/update*",
27
+ "*memory*/write*", "*memory*/remember*", "*/remember*", "*/save_memory*", "*/add_observations*")
28
+
29
+
30
+ def is_instruction_file(path: str) -> bool:
31
+ name = path.rsplit("/", 1)[-1]
32
+ return name in INSTRUCTION_NAMES or any(d in path for d in INSTRUCTION_DIRS)
33
+
34
+
35
+ def is_memory_tool(target: str | None) -> bool:
36
+ return bool(target) and any(fnmatchcase(target, p) for p in MEMORY_TOOLS)
37
+
38
+
39
+ def check_instruction_write(path: str, content: str | None, res: DetResult, policy: Policy, where: str) -> bool:
40
+ """Record an instruction-file write and ask when its content reads as injected instructions. True if it matched."""
41
+ if not is_instruction_file(path):
42
+ return False
43
+ res.add(policy, R_MEMORY_WRITE, path=where, subject=path)
44
+ if content:
45
+ score, hits = heuristic_score(content, assume_addressed=False)
46
+ if score >= 0.6:
47
+ res.add(policy, R_MEMORY_INJECTED, path="tool_call.arguments.content",
48
+ subject=f"{path}: content reads as instructions to the agent ({score:.2f}: {', '.join(hits[:4])})")
49
+ res.normalized["memory_write"] = path
50
+ return True