damage-control-dev 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 (74) hide show
  1. damage_control/__init__.py +9 -0
  2. damage_control/__main__.py +7 -0
  3. damage_control/adapter.py +179 -0
  4. damage_control/audit.py +432 -0
  5. damage_control/breakglass.py +155 -0
  6. damage_control/claude_code.py +148 -0
  7. damage_control/cli.py +173 -0
  8. damage_control/engine.py +10656 -0
  9. damage_control/groups.py +114 -0
  10. damage_control/harness_config.py +205 -0
  11. damage_control/hook.py +54 -0
  12. damage_control/install.py +618 -0
  13. damage_control/packs.py +219 -0
  14. damage_control/policy.py +323 -0
  15. damage_control/profile.py +177 -0
  16. damage_control/rules/aws.json +85 -0
  17. damage_control/rules/aws.yaml +76 -0
  18. damage_control/rules/cloud-hosting.json +162 -0
  19. damage_control/rules/cloud-hosting.yaml +163 -0
  20. damage_control/rules/containers.json +67 -0
  21. damage_control/rules/containers.yaml +69 -0
  22. damage_control/rules/control-plane.json +7 -0
  23. damage_control/rules/control-plane.yaml +21 -0
  24. damage_control/rules/core.json +332 -0
  25. damage_control/rules/core.yaml +441 -0
  26. damage_control/rules/databases.json +117 -0
  27. damage_control/rules/databases.yaml +123 -0
  28. damage_control/rules/firebase.json +34 -0
  29. damage_control/rules/firebase.yaml +30 -0
  30. damage_control/rules/gcp.json +60 -0
  31. damage_control/rules/gcp.yaml +57 -0
  32. damage_control/rules/git.json +119 -0
  33. damage_control/rules/git.yaml +120 -0
  34. damage_control/rules/infrastructure.json +69 -0
  35. damage_control/rules/infrastructure.yaml +66 -0
  36. damage_control/rules/mcp-tools.json +11 -0
  37. damage_control/rules/mcp-tools.yaml +47 -0
  38. damage_control/rules/outbound.json +40 -0
  39. damage_control/rules/outbound.yaml +58 -0
  40. damage_control/rules/payloads.json +120 -0
  41. damage_control/rules/payloads.yaml +155 -0
  42. damage_control/rules/publish.json +46 -0
  43. damage_control/rules/publish.yaml +46 -0
  44. damage_control/rules/registry.json +10 -0
  45. damage_control/rules/registry.yaml +11 -0
  46. damage_control/rules/remote.json +31 -0
  47. damage_control/rules/remote.yaml +53 -0
  48. damage_control/rules/tmux.json +17 -0
  49. damage_control/rules/tmux.yaml +16 -0
  50. damage_control/ruleset.py +94 -0
  51. damage_control/tooldefs/aws.json +140 -0
  52. damage_control/tooldefs/aws.yaml +119 -0
  53. damage_control/tooldefs/docker.json +146 -0
  54. damage_control/tooldefs/docker.yaml +123 -0
  55. damage_control/tooldefs/gcp.json +186 -0
  56. damage_control/tooldefs/gcp.yaml +158 -0
  57. damage_control/tooldefs/gh.json +172 -0
  58. damage_control/tooldefs/gh.yaml +145 -0
  59. damage_control/tooldefs/git.json +166 -0
  60. damage_control/tooldefs/git.yaml +143 -0
  61. damage_control/tooldefs/kubectl.json +101 -0
  62. damage_control/tooldefs/kubectl.yaml +85 -0
  63. damage_control/tooldefs/npm.json +77 -0
  64. damage_control/tooldefs/npm.yaml +65 -0
  65. damage_control/tooldefs/terraform.json +68 -0
  66. damage_control/tooldefs/terraform.yaml +57 -0
  67. damage_control/tooldefs/uv.json +112 -0
  68. damage_control/tooldefs/uv.yaml +97 -0
  69. damage_control_dev-0.1.0.dist-info/METADATA +15 -0
  70. damage_control_dev-0.1.0.dist-info/RECORD +74 -0
  71. damage_control_dev-0.1.0.dist-info/WHEEL +4 -0
  72. damage_control_dev-0.1.0.dist-info/entry_points.txt +2 -0
  73. damage_control_dev-0.1.0.dist-info/licenses/LICENSE +201 -0
  74. damage_control_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,219 @@
1
+ """Pack/group state in the protected ``~/.agentwire/damagecontrol.yml`` (#1141).
2
+
3
+ ``packs:`` lists the enabled vendor packs; ``disabled_groups:`` lists opted-out
4
+ default groups. A missing ``packs:`` key means *undecided* (a pre-packs install):
5
+ heal migrates it by recording every pack that already has files installed as
6
+ enabled, so upgrading changes no decision. Once the key exists it is
7
+ authoritative, even when empty.
8
+
9
+ The hook reads the same keys at run time (``engine.load_safety_config``) and skips the
10
+ bundled files of whatever is off (``groups.skipped_stems``): the bundle is immutable, so
11
+ "pack off" is "not loaded", never "not installed". An absent ``packs:`` key loads only the
12
+ host's default packs (#1209 M5).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Set
20
+
21
+ from damage_control.groups import Entry, default_packs, registry
22
+
23
+ PACKS_KEY = "packs"
24
+ GROUPS_KEY = "disabled_groups"
25
+
26
+
27
+ def read_state(path: Path) -> Dict[str, Any]:
28
+ """``{"packs": [...] | None, "disabled_groups": [...], "broken": str}``.
29
+
30
+ ``packs`` None = undecided. ``broken`` is a non-empty message when the file
31
+ exists but does not parse: the hook fails toward protection (every group and
32
+ pack on), and this file is never migrated over or written to,
33
+ so a typo cannot get overwritten.
34
+ """
35
+ import yaml
36
+
37
+ data: Any = {}
38
+ broken = ""
39
+ try:
40
+ data = yaml.safe_load(path.read_text()) or {}
41
+ except FileNotFoundError:
42
+ data = {}
43
+ except (OSError, ValueError, yaml.YAMLError) as e:
44
+ data, broken = {}, f"{path.name} does not parse ({type(e).__name__}); packs/groups treated as all on"
45
+ if not isinstance(data, dict):
46
+ data, broken = {}, broken or f"{path.name} is not a mapping; packs/groups treated as all on"
47
+ packs = data.get(PACKS_KEY)
48
+ groups = data.get(GROUPS_KEY)
49
+ return {
50
+ PACKS_KEY: [str(p) for p in packs] if isinstance(packs, list) else None,
51
+ GROUPS_KEY: [str(g) for g in groups] if isinstance(groups, list) else [],
52
+ "broken": broken,
53
+ }
54
+
55
+
56
+ def _render(key: str, values: List[str]) -> List[str]:
57
+ if not values:
58
+ return [f"{key}: []"]
59
+ return [f"{key}:"] + [f" - {v}" for v in sorted(values)]
60
+
61
+
62
+ def _replace_key(lines: List[str], key: str, values: List[str]) -> List[str]:
63
+ """Replace (or append) one top-level block, leaving every other line alone."""
64
+ out: List[str] = []
65
+ i, replaced = 0, False
66
+ while i < len(lines):
67
+ if re.match(rf"^{re.escape(key)}\s*:", lines[i]):
68
+ i += 1
69
+ while i < len(lines) and lines[i].strip() and lines[i][0] in " \t-":
70
+ i += 1
71
+ out.extend(_render(key, values))
72
+ replaced = True
73
+ continue
74
+ out.append(lines[i])
75
+ i += 1
76
+ if not replaced:
77
+ if out and out[-1].strip():
78
+ out.append("")
79
+ out.extend(_render(key, values))
80
+ return out
81
+
82
+
83
+ def write_state(path: Path, packs: Optional[List[str]] = None,
84
+ disabled_groups: Optional[List[str]] = None) -> None:
85
+ """Write the given keys into the policy file, preserving everything else."""
86
+ try:
87
+ lines = path.read_text().splitlines()
88
+ except OSError:
89
+ lines = []
90
+ if packs is not None:
91
+ lines = _replace_key(lines, PACKS_KEY, packs)
92
+ if disabled_groups is not None:
93
+ lines = _replace_key(lines, GROUPS_KEY, disabled_groups)
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ tmp = path.with_name(path.name + ".tmp")
96
+ tmp.write_text("\n".join(lines) + "\n")
97
+ tmp.replace(path)
98
+
99
+
100
+ def entry_active(entry: Entry, state: Dict[str, Any]) -> bool:
101
+ """Whether the entry's files belong on this machine."""
102
+ if state.get("broken"):
103
+ return True
104
+ if entry.kind == "pack":
105
+ return entry.name in (state[PACKS_KEY] or [])
106
+ return entry.kernel or entry.name not in state[GROUPS_KEY]
107
+
108
+
109
+ def migrate_packs(state: Dict[str, Any], installed: Dict[str, Set[str]]) -> List[str]:
110
+ """Packs to record as enabled for a pre-packs install, or ``[]``.
111
+
112
+ Only while ``state["packs"]`` is None. A fresh machine starts with every pack
113
+ off except the host's defaults (``default_packs()``). On an
114
+ existing machine (something is already installed)
115
+ machine EVERY pack is enabled, not just those with files present: before
116
+ packs, heal reinstalled any missing bundled file, so a partial install
117
+ (a deleted rules file, a pack with no tooldef) must come out of an upgrade
118
+ with the whole pack, never silently without it.
119
+ """
120
+ if state[PACKS_KEY] is not None or state.get("broken"):
121
+ return []
122
+ if not any(installed.get(section) for section in ("rules", "tooldefs")):
123
+ return sorted(default_packs())
124
+ return sorted(e.name for e in registry().values() if e.kind == "pack")
125
+
126
+
127
+ def effective_state(path: Path) -> Dict[str, Any]:
128
+ """The state the hook acts on: an undecided policy (no ``packs:`` key) loads the host's default packs.
129
+
130
+ Read-only. ``heal`` is what records a decision; until it does, readers must show exactly
131
+ the packs the hook is enforcing.
132
+ """
133
+ state = read_state(path)
134
+ if state[PACKS_KEY] is None and not state["broken"]:
135
+ state[PACKS_KEY] = sorted(default_packs())
136
+ return state
137
+
138
+
139
+ def read_flag(path: Path, key: str) -> Optional[bool]:
140
+ """A top-level ``key: true|false`` in the policy file, or None. Plain-text read: no PyYAML needed."""
141
+ try:
142
+ text = path.read_text()
143
+ except OSError:
144
+ return None
145
+ m = re.search(rf"^{re.escape(key)}\s*:\s*(true|false)\s*(?:#.*)?$", text, re.MULTILINE | re.IGNORECASE)
146
+ return None if m is None else m.group(1).lower() == "true"
147
+
148
+
149
+ def write_flag(path: Path, key: str, value: bool) -> None:
150
+ """Set a top-level boolean in the policy file, preserving every other line. Atomic."""
151
+ try:
152
+ lines = path.read_text().splitlines()
153
+ except OSError:
154
+ lines = []
155
+ rendered = f"{key}: {'true' if value else 'false'}"
156
+ pattern = re.compile(rf"^{re.escape(key)}\s*:")
157
+ if any(pattern.match(line) for line in lines):
158
+ lines = [rendered if pattern.match(line) else line for line in lines]
159
+ else:
160
+ lines.append(rendered)
161
+ path.parent.mkdir(parents=True, exist_ok=True)
162
+ tmp = path.with_name(path.name + ".tmp")
163
+ tmp.write_text("\n".join(lines) + "\n")
164
+ tmp.replace(path)
165
+
166
+
167
+ def _file_source(overlay: Optional[Path], name: str) -> str:
168
+ """``bundled``, or ``overridden`` when the host's overlay holds a file of the same name."""
169
+ return "overridden" if overlay and any((overlay / name).with_suffix(ext).exists() for ext in (".yaml", ".json")) else "bundled"
170
+
171
+
172
+ def overview(policy: Path, rules_overlay: Optional[Path], tooldefs_overlay: Optional[Path]) -> List[Dict[str, Any]]:
173
+ """One row per registry entry: kind, whether it is on, its files, and whether to suggest it."""
174
+ import shutil
175
+
176
+ state = effective_state(policy)
177
+ rows = []
178
+ for e in registry().values():
179
+ files = [
180
+ {"section": section, "file": f, "source": _file_source(overlay, f)}
181
+ for section, overlay in (("rules", rules_overlay), ("tooldefs", tooldefs_overlay))
182
+ for f in e.files(section)
183
+ ]
184
+ found = [b for b in e.binaries if shutil.which(b)]
185
+ enabled = entry_active(e, state)
186
+ rows.append({
187
+ "name": e.name, "kind": e.kind, "description": e.description,
188
+ "kernel": e.kernel, "enabled": enabled, "files": files,
189
+ "binaries_found": found,
190
+ "suggest_enable": e.kind == "pack" and not enabled and bool(found),
191
+ })
192
+ return rows
193
+
194
+
195
+ class MembershipError(ValueError):
196
+ """A refused pack/group change; the message says why."""
197
+
198
+
199
+ def set_enabled(policy: Path, kind: str, name: str, enable: bool) -> None:
200
+ """Enable or disable one pack/group in ``policy``. Raises :class:`MembershipError` when refused.
201
+
202
+ The caller owns the human-only gate. Writing is refused over an unparseable policy file.
203
+ """
204
+ entry = registry().get(name)
205
+ if entry is None or entry.kind != kind:
206
+ known = ", ".join(sorted(n for n, x in registry().items() if x.kind == kind))
207
+ other = f" (that is a {entry.kind})" if entry else ""
208
+ raise MembershipError(f"unknown {kind} {name!r}{other}; known: {known}")
209
+ if entry.kernel and not enable:
210
+ raise MembershipError(f"{name} is part of the always-on kernel and cannot be disabled")
211
+ state = read_state(policy)
212
+ if state["broken"]:
213
+ raise MembershipError(f"refusing to write: {state['broken']}; fix the file by hand first")
214
+ if kind == "pack":
215
+ current = set(effective_state(policy)[PACKS_KEY] or [])
216
+ write_state(policy, packs=sorted(current | {name} if enable else current - {name}))
217
+ else:
218
+ current = set(state[GROUPS_KEY])
219
+ write_state(policy, disabled_groups=sorted(current - {name} if enable else current | {name}))
@@ -0,0 +1,323 @@
1
+ """The policy ladder, lifted out of the five hook ``main()``s (#1209 M3, section 4.1).
2
+
3
+ ``Guard.decide(ToolCall, Context) -> Verdict`` is the one place that turns an engine
4
+ finding into allow / ask / block: bypass demotion, the unverifiable carve-out, the
5
+ unattended fail-closed path with its grants, the kill switch, and the MCP screening.
6
+ It is pure — it does no I/O and never exits. The caller supplies facts (``ToolCall``,
7
+ ``Context``) and renders the ``Verdict`` (``adapter.py``).
8
+
9
+ The engine functions the ladder needs are reached through ``engine``, an object that
10
+ exposes them as attributes (the ``_core`` module in-process, the hook's own namespace
11
+ when inlined). Taking it as an argument keeps this file free of imports from the
12
+ package, so it concatenates into a standalone hook, and lets the ladder be tested
13
+ against a stub engine — the bypass x unverifiable x unattended x grant matrix needs no
14
+ rule files and no subprocess.
15
+
16
+ Control-plane and protected-path checks run BEFORE the kill switch; nothing the
17
+ profile or the context supplies can loosen them.
18
+ """
19
+
20
+ import re
21
+ from typing import Any, Dict, NamedTuple, Optional, Tuple
22
+
23
+ BYPASS = "bypass"
24
+ DEFAULT = "default"
25
+
26
+ ALLOW = "allow"
27
+ ASK = "ask"
28
+ BLOCK = "block"
29
+
30
+ # Rule id of the ambiguous-command fallback; an operand-position substitution is
31
+ # allowed unattended, a verb-concealing one (``unverifiable``) never is (#934/#925).
32
+ AMBIGUOUS_COMMAND_RULE = "core.ambiguous-command"
33
+
34
+ # Bridge for installs whose rules dir predates ``mcp-tools.yaml`` (#923): a stale user
35
+ # rules dir wins over the bundled copy and carries no ``mcpToolPolicies``, so per-tool
36
+ # classification loads empty. Until the heal installs the missing file, control-plane
37
+ # screening must not degrade below the #1036 posture — consulted ONLY when zero policies
38
+ # loaded. With policies present, the same shape lives as DATA (the ``mcp.writeish-name``
39
+ # entry in mcp-tools.yaml) and this constant is inert.
40
+ _WRITEISH_BRIDGE_RE = re.compile(
41
+ r"(write|edit|delete|remove|move|rename|create|append|patch|mkdir|"
42
+ r"rmdir|copy|upload|chmod|prune|kill|purge)",
43
+ re.IGNORECASE,
44
+ )
45
+
46
+ # Per path-kind: the phrase in the block message.
47
+ _PATH_VERB = {"read": "read of", "write": "write to", "edit": "edit to"}
48
+
49
+
50
+ class ToolCall(NamedTuple):
51
+ kind: str # shell | read | write | edit | mcp
52
+ tool_name: str
53
+ command: str = "" # shell: the command line
54
+ paths: Tuple[str, ...] = () # read / write / edit: the target path
55
+ tool_input: Optional[Dict[str, Any]] = None # mcp: the call's arguments
56
+ cwd: Optional[str] = None # shell: the directory the harness reports
57
+
58
+
59
+ class Context(NamedTuple):
60
+ mode: str = DEFAULT # "bypass" = the human opted out of ask-escalation friction
61
+ unattended: bool = False # no human present (scheduler dispatch)
62
+
63
+
64
+ class Verdict(NamedTuple):
65
+ decision: str # allow | ask | block
66
+ reason: str = ""
67
+ rule_id: Optional[str] = None
68
+ unverifiable: bool = False
69
+ disabled: bool = False
70
+ escape: bool = False
71
+ allowed_by: Optional[str] = None
72
+ # What the caller must do beyond the decision itself. All data: the ladder stays pure.
73
+ audit: Optional[Tuple[str, Tuple[Any, ...], Dict[str, Any]]] = None # (event, args, kwargs)
74
+ stderr: Tuple[str, ...] = ()
75
+ notify: Optional[Tuple[str, str, Optional[str]]] = None # (subject, reason, rule_id)
76
+
77
+
78
+ def _audit(event: str, *args: Any, **kwargs: Any) -> Tuple[str, Tuple[Any, ...], Dict[str, Any]]:
79
+ return (event, args, kwargs)
80
+
81
+
82
+ def _allowed(tool: str, subject: str, **kwargs: Any) -> Verdict:
83
+ return Verdict(ALLOW, audit=_audit("allowed", tool, subject, user_approved=False, **kwargs))
84
+
85
+
86
+ def _iter_string_values(obj: Any):
87
+ """Every string in ``obj``, iteratively (no recursion limit to crash on)."""
88
+ stack = [obj]
89
+ while stack:
90
+ cur = stack.pop()
91
+ if isinstance(cur, str):
92
+ yield cur
93
+ elif isinstance(cur, dict):
94
+ stack.extend(cur.values())
95
+ elif isinstance(cur, (list, tuple)):
96
+ stack.extend(cur)
97
+
98
+
99
+ def _pathlike_args(tool_input: Dict[str, Any]):
100
+ """String argument values that plausibly name a filesystem path.
101
+
102
+ Whitespace-bearing strings are prose, ``://`` marks a URL; what remains is
103
+ screened. A missed candidate degrades to today's behaviour (unscreened),
104
+ never to a false block.
105
+ """
106
+ seen = set()
107
+ for s in _iter_string_values(tool_input):
108
+ if s in seen:
109
+ continue
110
+ seen.add(s)
111
+ if not s or " " in s or "\t" in s or "\n" in s or "://" in s:
112
+ continue
113
+ if "/" in s or s.startswith("~"):
114
+ yield s
115
+
116
+
117
+ class Guard:
118
+ """Decides tool calls against a loaded ``config`` using ``engine``'s checks."""
119
+
120
+ def __init__(self, config: Dict[str, Any], engine: Any) -> None:
121
+ self.config = config
122
+ self.e = engine
123
+
124
+ def decide(self, call: ToolCall, ctx: Context) -> Verdict:
125
+ if call.kind in _PATH_VERB:
126
+ return self._decide_path(call)
127
+ if call.kind == "shell":
128
+ return self._decide_command(call, ctx, call.command, synthesized=False)
129
+ if call.kind == "mcp":
130
+ return self._decide_mcp(call, ctx)
131
+ raise ValueError(f"unknown tool-call kind: {call.kind!r}")
132
+
133
+ # -- Read / Write / Edit ---------------------------------------------
134
+
135
+ def _decide_path(self, call: ToolCall) -> Verdict:
136
+ e, path = self.e, call.paths[0]
137
+ check = e.check_read_path if call.kind == "read" else e.check_path
138
+ # The protected control plane is enforced even when safety is disabled, so
139
+ # it must run BEFORE the kill-switch short-circuit (#466).
140
+ blocked, reason = check(path, self.config)
141
+ if blocked:
142
+ return Verdict(
143
+ BLOCK, reason,
144
+ audit=_audit("blocked", call.tool_name, path, reason),
145
+ stderr=(f"SECURITY: Blocked {_PATH_VERB[call.kind]} {reason}: {path}",),
146
+ )
147
+ if self.config["safety"].get("enabled", True) is False:
148
+ return Verdict(ALLOW, disabled=True, audit=_audit("disabled", call.tool_name, path))
149
+ return _allowed(call.tool_name, path)
150
+
151
+ # -- Shell commands, and the shell command an outbound MCP tool runs --------
152
+
153
+ def _decide_command(self, call: ToolCall, ctx: Context, command: str, synthesized: bool) -> Verdict:
154
+ """``synthesized``: ``command`` stands in for an MCP tool call — it names no directory."""
155
+ e, tool = self.e, call.tool_name
156
+ if synthesized:
157
+ result = e.check_command(command, self.config)
158
+ else:
159
+ result = e.check_command(command, self.config, cwd=call.cwd)
160
+ decision, reason = result["decision"], result["reason"]
161
+ rule_id, pattern = result.get("id"), result.get("pattern")
162
+ shown = f"Command: {command[:100]}{'...' if len(command) > 100 else ''}"
163
+
164
+ if decision == BLOCK:
165
+ return Verdict(
166
+ BLOCK, reason, rule_id,
167
+ audit=_audit("blocked", tool, command, reason, pattern=pattern, rule_id=rule_id),
168
+ stderr=(f"SECURITY: Blocked: {reason}", shown),
169
+ )
170
+ if decision == ASK and ctx.unattended:
171
+ return self._unattended_ask(call, command, result, shown, synthesized)
172
+ if decision == ASK and (ctx.mode != BYPASS or result.get("unverifiable")):
173
+ # ``unverifiable`` (#934): a verb-concealing shape — eval, base64-pipe, a
174
+ # substitution deciding what runs or feeding a wrapper's payload — keeps its
175
+ # ask even under bypass. Bypass means "don't stop me for things you
176
+ # recognize"; it never meant "don't stop me for things you can't see".
177
+ return Verdict(ASK, reason, rule_id, unverifiable=bool(result.get("unverifiable")),
178
+ audit=_audit("asked", tool, command, reason))
179
+ if result.get("escape"):
180
+ return Verdict(ALLOW, escape=True,
181
+ audit=_audit("escape", tool, command, result.get("escape_reason") or ""))
182
+ if result.get("disabled"):
183
+ return Verdict(ALLOW, disabled=True, audit=_audit("disabled", tool, command))
184
+ if synthesized:
185
+ return _allowed(tool, command)
186
+ return _allowed(tool, command, allowed_by=result.get("allowed_by"))
187
+
188
+ def _unattended_ask(self, call: ToolCall, command: str, result: Dict[str, Any],
189
+ shown: str, synthesized: bool) -> Verdict:
190
+ """No human present, so the interactive confirm is meaningless: fail closed.
191
+
192
+ BLOCK + notify the owner, unless the matched rule carries an unattended grant —
193
+ and, when that grant is path-scoped (#914), unless this command's target
194
+ directory is inside it.
195
+ """
196
+ e, tool = self.e, call.tool_name
197
+ reason, rule_id, pattern = result["reason"], result.get("id"), result.get("pattern")
198
+ if not synthesized and rule_id == AMBIGUOUS_COMMAND_RULE and not result.get("unverifiable"):
199
+ # #925 Part 3, against the #934 classifier: an operand-position substitution
200
+ # is the most common phrasing in shell and every rule has already scanned its
201
+ # masked form. Only VERB CONCEALMENT is non-redundant coverage, so the
202
+ # unattended refusal is scoped to ``unverifiable``.
203
+ return _allowed(tool, command)
204
+ grants = e.resolve_unattended_grants(self.config)
205
+ if synthesized:
206
+ # A synthesized command names no directory, so a path-scoped grant cannot be
207
+ # evaluated and must refuse (#914). Unscoped grants are unaffected.
208
+ granted, why = e.unattended_grant_allows(
209
+ rule_id, command, grants, e.safe_cwd(), pattern=pattern, scopeable=False,
210
+ )
211
+ else:
212
+ granted, why = e.unattended_grant_allows(
213
+ rule_id, command, grants, call.cwd or e.safe_cwd(), pattern=pattern,
214
+ )
215
+ if granted:
216
+ return _allowed(tool, command)
217
+ text = f"{reason} — {why}"
218
+ return Verdict(
219
+ BLOCK, reason, rule_id,
220
+ audit=_audit("blocked", tool, command, f"unattended: {text}", pattern=pattern, rule_id=rule_id),
221
+ stderr=(f"SECURITY: Blocked (unattended — no human to confirm): {reason}", shown,
222
+ f"Why: {why}") + self._grant_hint(rule_id, grants, scoped_form=not synthesized),
223
+ notify=(command, text, rule_id),
224
+ )
225
+
226
+ def _grant_hint(self, rule_id: Optional[str], grants: Dict[str, Any], scoped_form: bool) -> Tuple[str, ...]:
227
+ hint = self.e.PROFILE.unattended_grant_hint
228
+ if not (hint and rule_id and rule_id not in grants):
229
+ return ()
230
+ text = hint.format(rule_id=rule_id)
231
+ if scoped_form:
232
+ text += f" (optionally scoped: `- {{id: {rule_id}, paths: [<dir>/]}}`)"
233
+ return (text + ".",)
234
+
235
+ # -- MCP tools ----------------------------------------------------------
236
+
237
+ def _decide_mcp(self, call: ToolCall, ctx: Context) -> Verdict:
238
+ e, tool_input = self.e, call.tool_input or {}
239
+ command = e.synthesize_mcp_command(call.tool_name, tool_input)
240
+ if command:
241
+ return self._decide_command(call, ctx, command, synthesized=True)
242
+ # Not a gated outbound tool: run the per-tool policy ladder (#923) and screen
243
+ # path-valued arguments — an operation refused via Bash must not be permitted
244
+ # via a tool.
245
+ tool, config = call.tool_name, self.config
246
+ policy = e.check_mcp_tool(tool, config)
247
+ decision, reason, rule_id = policy["decision"], policy["reason"], policy.get("id")
248
+ if decision == BLOCK:
249
+ return Verdict(BLOCK, reason, rule_id, audit=_audit("blocked", tool, tool, reason),
250
+ stderr=(f"SECURITY: Blocked MCP tool: {reason}",))
251
+
252
+ # Path screening runs before any allow/ask verdict: a protected path argument
253
+ # blocks regardless of the tool's tier.
254
+ mutates = policy.get("mutates")
255
+ if mutates is None and not config.get("mcpToolPolicies"):
256
+ mutates = bool(_WRITEISH_BRIDGE_RE.search(tool.split("__")[-1]))
257
+ budget_exceeded = False
258
+ try:
259
+ with e.scan_deadline(e.SCAN_DEADLINE_SECONDS):
260
+ screened = self._screen_tool_paths(tool, tool_input, bool(mutates))
261
+ if screened is not None:
262
+ return screened
263
+ except e.ScanBudgetExceeded:
264
+ # Arguments too large to screen in time: fail closed (#1108) — an unverifiable
265
+ # ask, so bypass cannot demote it and unattended blocks. The kill switch turns
266
+ # the budget off with everything else; a protected control-plane path anywhere
267
+ # in the arguments is never exempted by the switch.
268
+ if not e.kill_switch_off(config) or any(
269
+ e.mentions_control_plane(s) for s in _iter_string_values(tool_input)
270
+ ):
271
+ budget_exceeded = True
272
+ decision, rule_id = ASK, "core.scan-budget"
273
+ reason = (
274
+ f"MCP tool arguments exceeded the {e.SCAN_DEADLINE_SECONDS:g}s screening "
275
+ "budget — too large to analyze safely; a human must confirm "
276
+ "(not demoted by bypass)"
277
+ )
278
+
279
+ if decision == ASK and ctx.unattended:
280
+ return self._unattended_mcp_ask(tool, reason, rule_id)
281
+ if decision == ASK and (ctx.mode != BYPASS or budget_exceeded):
282
+ return Verdict(ASK, reason, rule_id, unverifiable=budget_exceeded,
283
+ audit=_audit("asked", tool, tool, reason))
284
+ return Verdict(ALLOW)
285
+
286
+ def _unattended_mcp_ask(self, tool: str, reason: str, rule_id: Optional[str]) -> Verdict:
287
+ e = self.e
288
+ # A tool call names no directory, so scopeable=False — a path-scoped grant
289
+ # refuses rather than guessing (#914).
290
+ grants = e.resolve_unattended_grants(self.config)
291
+ granted, why = e.unattended_grant_allows(rule_id, tool, grants, e.safe_cwd(), scopeable=False)
292
+ if granted:
293
+ return _allowed(tool, tool)
294
+ text = f"{reason} — {why}"
295
+ return Verdict(
296
+ BLOCK, reason, rule_id,
297
+ audit=_audit("blocked", tool, tool, f"unattended: {text}", rule_id=rule_id),
298
+ stderr=(f"SECURITY: Blocked (unattended — no human to confirm): {reason}",
299
+ f"Tool: {tool}", f"Why: {why}")
300
+ + self._grant_hint(rule_id, grants, scoped_form=False),
301
+ notify=(tool, text, rule_id),
302
+ )
303
+
304
+ def _screen_tool_paths(self, tool: str, tool_input: Dict[str, Any], writeish: bool) -> Optional[Verdict]:
305
+ """A block Verdict when a path-valued argument violates the path ladders (#923).
306
+
307
+ Zero-access secrets block on MENTION for every tool (same semantics as
308
+ ``check_read_path`` — the path's mere presence in a call is the leak); the
309
+ protected control plane is readable by design, so it is only enforced when the
310
+ tool's POLICY says it mutates (``writeish``).
311
+ """
312
+ e = self.e
313
+ allowed = e.load_allowed_paths(self.config)
314
+ for p in _pathlike_args(tool_input):
315
+ blocked, reason = e.check_read_path(p, self.config)
316
+ if not blocked and writeish:
317
+ blocked, reason = e.check_protected_path(p, allowed)
318
+ if blocked:
319
+ return Verdict(
320
+ BLOCK, reason, audit=_audit("blocked", tool, p, reason),
321
+ stderr=(f"SECURITY: Blocked MCP tool argument: {reason}: {p}",),
322
+ )
323
+ return None