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.
- interlockd/__init__.py +3 -0
- interlockd/__main__.py +7 -0
- interlockd/aibom.py +136 -0
- interlockd/api.py +307 -0
- interlockd/auth.py +38 -0
- interlockd/calibration.py +260 -0
- interlockd/cli.py +1920 -0
- interlockd/compose.py +84 -0
- interlockd/config.py +80 -0
- interlockd/corpus.py +505 -0
- interlockd/data/packs/_corpus.yaml +7 -0
- interlockd/data/packs/egress.jsonl +38 -0
- interlockd/data/packs/fileops.jsonl +20 -0
- interlockd/data/packs/git.jsonl +51 -0
- interlockd/data/packs/packages.jsonl +24 -0
- interlockd/data/packs/persistence.jsonl +31 -0
- interlockd/data/packs/secrets.jsonl +22 -0
- interlockd/data/packs/shell.jsonl +75 -0
- interlockd/det/__init__.py +53 -0
- interlockd/det/describe.py +66 -0
- interlockd/det/engine.py +153 -0
- interlockd/det/flow.py +106 -0
- interlockd/det/mandate.py +108 -0
- interlockd/det/mcp.py +70 -0
- interlockd/det/memory.py +50 -0
- interlockd/det/packs.py +531 -0
- interlockd/det/shellparse.py +438 -0
- interlockd/egress.py +124 -0
- interlockd/gateway.py +350 -0
- interlockd/ids.py +25 -0
- interlockd/insights.py +316 -0
- interlockd/integrations/__init__.py +2 -0
- interlockd/integrations/client.py +85 -0
- interlockd/integrations/langchain.py +59 -0
- interlockd/integrations/openai_agents.py +72 -0
- interlockd/integrations/strands.py +69 -0
- interlockd/judges/__init__.py +107 -0
- interlockd/judges/guardrails.py +94 -0
- interlockd/judges/local_probe.py +192 -0
- interlockd/judges/projector.py +82 -0
- interlockd/judges/strands_decider.py +160 -0
- interlockd/ledger.py +62 -0
- interlockd/mcp_http.py +216 -0
- interlockd/mcp_proxy.py +315 -0
- interlockd/mcp_server.py +211 -0
- interlockd/models.py +98 -0
- interlockd/normalize/__init__.py +1 -0
- interlockd/normalize/claude_code.py +89 -0
- interlockd/normalize/cursor.py +59 -0
- interlockd/normalize/gemini_cli.py +55 -0
- interlockd/normalize/kiro.py +65 -0
- interlockd/normalize/more.py +98 -0
- interlockd/ogr.py +542 -0
- interlockd/onboard.py +300 -0
- interlockd/otel.py +139 -0
- interlockd/paths.py +63 -0
- interlockd/pipeline.py +370 -0
- interlockd/policy/__init__.py +220 -0
- interlockd/policy_lint.py +76 -0
- interlockd/render.py +142 -0
- interlockd/sandbox.py +87 -0
- interlockd/scan.py +405 -0
- interlockd/schema/verdict.schema.json +68 -0
- interlockd/selftest.py +101 -0
- interlockd/service.py +247 -0
- interlockd/shims/claude-code/interlock-hook.sh +113 -0
- interlockd/shims/kiro/interlock-hook.mjs +101 -0
- interlockd/shims/kiro/interlock-hook.sh +82 -0
- interlockd/shims/opencode/interlock.js +60 -0
- interlockd/store/__init__.py +22 -0
- interlockd/store/drainer.py +74 -0
- interlockd/store/dynamodb.py +144 -0
- interlockd/store/memory.py +28 -0
- interlockd/store/spool.py +159 -0
- interlockd/testkit.py +401 -0
- interlockd/tiers.py +95 -0
- interlockd/transcript/__init__.py +1 -0
- interlockd/transcript/claude_code.py +73 -0
- interlockd/transcript/kiro.py +102 -0
- interlockd-0.1.0.dist-info/METADATA +125 -0
- interlockd-0.1.0.dist-info/RECORD +84 -0
- interlockd-0.1.0.dist-info/WHEEL +4 -0
- interlockd-0.1.0.dist-info/entry_points.txt +2 -0
- interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
interlockd/det/engine.py
ADDED
|
@@ -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)
|
interlockd/det/memory.py
ADDED
|
@@ -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
|