qaas-python 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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/guardrails.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""The §8.1 write-permission matrix, enforced in code.
|
|
2
|
+
|
|
3
|
+
Every agent gets a `can_use_tool` callback built from its policy. The callback
|
|
4
|
+
sees the tool name and its arguments before the tool runs, which is the only
|
|
5
|
+
place a limit like "FORGE may write, but only under qa/repro" can actually be
|
|
6
|
+
imposed. A prompt asking an agent not to do something is a request; this is a
|
|
7
|
+
decision.
|
|
8
|
+
|
|
9
|
+
Enforcement runs in the **PreToolUse hook**, not in `can_use_tool`. This is not
|
|
10
|
+
a stylistic choice and it is easy to get wrong: an `allowed_tools` entry that
|
|
11
|
+
names a whole tool auto-approves it *before* `can_use_tool` is consulted, so a
|
|
12
|
+
policy implemented only in that callback is silently never applied. The SDK warns
|
|
13
|
+
about this shadowing, and an early version of this file had exactly that bug —
|
|
14
|
+
FORGE's sandbox check was dead code. The hook sees every call regardless.
|
|
15
|
+
|
|
16
|
+
`can_use_tool` is kept as a second layer, for anything that falls outside the
|
|
17
|
+
allowlist and so reaches the callback normally.
|
|
18
|
+
|
|
19
|
+
Three belts, then:
|
|
20
|
+
|
|
21
|
+
* The PreToolUse hook — every built-in tool call, gated on this agent's policy.
|
|
22
|
+
* `can_use_tool` — the same decision, for calls not auto-approved.
|
|
23
|
+
* The MCP servers — their own domain rules (the tracker refuses an agent that
|
|
24
|
+
may not file; vcs refuses a branch outside the agent's patterns).
|
|
25
|
+
|
|
26
|
+
Denials return a reason rather than killing the turn: an agent that learns it
|
|
27
|
+
cannot write to a path should adapt, and the reason is what lets it. Every
|
|
28
|
+
denial lands in the run ledger, which is the audit trail §8 asks for.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import fnmatch
|
|
34
|
+
import re
|
|
35
|
+
import shlex
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from claude_agent_sdk import (
|
|
41
|
+
PermissionResultAllow,
|
|
42
|
+
PermissionResultDeny,
|
|
43
|
+
ToolPermissionContext,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from qaas.mcp.context import ToolContext
|
|
47
|
+
|
|
48
|
+
# Harness plumbing granted to every agent, independent of its config. These are
|
|
49
|
+
# not capability grants: ToolSearch only loads the schemas of servers the agent
|
|
50
|
+
# already has, Skill only loads instructions, and neither can reach anything the
|
|
51
|
+
# allowlist does not already permit. `build_allowed_tools` adds the same set, and
|
|
52
|
+
# both read this constant so the allowlist and the guardrail cannot drift apart —
|
|
53
|
+
# a mismatch here silently disables every skill in the system.
|
|
54
|
+
ALWAYS_GRANTED = frozenset({"ToolSearch", "Skill", "TodoWrite", "Task", "Agent"})
|
|
55
|
+
|
|
56
|
+
# Tools that read. Always safe, for every agent.
|
|
57
|
+
READ_TOOLS = {"Read", "Grep", "Glob", "NotebookRead"} | set(ALWAYS_GRANTED)
|
|
58
|
+
|
|
59
|
+
# Harness plumbing, not capability. These grant an agent nothing it was not
|
|
60
|
+
# already granted — ToolSearch only loads the schema of a tool that is already
|
|
61
|
+
# on its allowlist, and Skill only opens a skill file. Denying ToolSearch is
|
|
62
|
+
# worse than useless: MCP tools arrive deferred, so an agent that cannot call it
|
|
63
|
+
# cannot reach the servers it was given, and burns its whole turn budget
|
|
64
|
+
# discovering that. This system did exactly that once.
|
|
65
|
+
HARNESS_TOOLS = {"ToolSearch", "TodoWrite", "Task", "Agent", "Skill", "SlashCommand"}
|
|
66
|
+
|
|
67
|
+
# Tools that write to the filesystem. Gated on policy.write_paths.
|
|
68
|
+
WRITE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
|
69
|
+
|
|
70
|
+
# Bash command prefixes that are never allowed, whatever the agent.
|
|
71
|
+
# Merging to main, force-pushing and recursive deletes are outside every
|
|
72
|
+
# agent's remit in this system: merge is always human (§8.4), and nothing here
|
|
73
|
+
# needs to delete a tree.
|
|
74
|
+
FORBIDDEN_BASH = [
|
|
75
|
+
(r"\bgit\s+push\b.*(--force|-f\b)", "force-push is never permitted"),
|
|
76
|
+
(r"\bgit\s+push\b.*\b(main|master)\b", "pushing to main is never permitted"),
|
|
77
|
+
(r"\bgit\s+merge\b", "merging is a human decision (§8.4)"),
|
|
78
|
+
(r"\bgit\s+reset\s+--hard\b", "hard reset discards work outside the sandbox"),
|
|
79
|
+
(r"\bgit\s+checkout\s+(main|master)\b", "agents work on their own branches only"),
|
|
80
|
+
(r"\brm\s+-[a-zA-Z]*[rf]", "recursive or forced delete is not permitted"),
|
|
81
|
+
(r"\bsudo\b", "privilege escalation is not permitted"),
|
|
82
|
+
(r"\b(shutdown|reboot|mkfs|dd)\b", "destructive system command"),
|
|
83
|
+
(r">\s*/dev/(sd|nvme|disk)", "writing to a block device"),
|
|
84
|
+
(r"\bdocker\s+system\s+prune", "prune would destroy state other runs depend on"),
|
|
85
|
+
(r"\bgh\s+pr\s+merge\b", "merging a pull request is a human decision (§8.4)"),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Bash that mutates git state. Gated on the agent having branch patterns at all.
|
|
89
|
+
GIT_WRITE = re.compile(r"\bgit\s+(commit|push|branch|checkout\s+-b|switch\s+-c|tag|apply|am|rebase)\b")
|
|
90
|
+
|
|
91
|
+
# Shell constructs that rewrite a file in place. `>` is not a word character, so
|
|
92
|
+
# this deliberately does not use \b anchors — an earlier version did and silently
|
|
93
|
+
# matched nothing.
|
|
94
|
+
_MUTATES_FILE = re.compile(r"(>>?|\btee\b|\bsed\s+-i|\btruncate\b|\bdd\b)")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Decision:
|
|
99
|
+
allowed: bool
|
|
100
|
+
reason: str = ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Guardrail:
|
|
104
|
+
"""One agent's enforcement of its own policy."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, ctx: ToolContext):
|
|
107
|
+
self.ctx = ctx
|
|
108
|
+
self.agent = ctx.agent
|
|
109
|
+
self.policy = ctx.agent.policy
|
|
110
|
+
# Every write path in a policy is relative to the *application under
|
|
111
|
+
# test*, never to the qaas project. This was `ctx.repo_root`, filled
|
|
112
|
+
# from `Path.cwd()`, which anchored the whole allowlist on wherever the
|
|
113
|
+
# operator happened to be standing -- harmless only while the target was
|
|
114
|
+
# a subdirectory of the qaas checkout. With `qaas run --repo <url>` the
|
|
115
|
+
# target is a clone under `.qaas/targets/`, and an allowlist anchored on
|
|
116
|
+
# the cwd would deny every legitimate write and permit a sandbox that
|
|
117
|
+
# sits inside qaas's own source.
|
|
118
|
+
self.root = ctx.target_root.resolve()
|
|
119
|
+
self._allowed_roots = [
|
|
120
|
+
(self.root / p).resolve() for p in self.policy.write_paths
|
|
121
|
+
]
|
|
122
|
+
# Every MCP server the agent declared, as an allowlist prefix.
|
|
123
|
+
self._mcp_prefixes = tuple(f"mcp__{s}__" for s in self.agent.mcp_servers)
|
|
124
|
+
|
|
125
|
+
# -- entry point ------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async def can_use_tool(
|
|
128
|
+
self,
|
|
129
|
+
tool_name: str,
|
|
130
|
+
input_data: dict[str, Any],
|
|
131
|
+
context: ToolPermissionContext,
|
|
132
|
+
) -> PermissionResultAllow | PermissionResultDeny:
|
|
133
|
+
"""Second layer. Reached only for calls the allowlist did not auto-approve."""
|
|
134
|
+
decision = self.check(tool_name, input_data)
|
|
135
|
+
if decision.allowed:
|
|
136
|
+
return PermissionResultAllow(updated_input=input_data)
|
|
137
|
+
self._record(tool_name, input_data, decision.reason, via="can_use_tool")
|
|
138
|
+
return PermissionResultDeny(message=decision.reason)
|
|
139
|
+
|
|
140
|
+
async def pre_tool_use(
|
|
141
|
+
self,
|
|
142
|
+
payload: Any,
|
|
143
|
+
tool_use_id: str | None,
|
|
144
|
+
context: Any,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
"""Primary enforcement. Runs for every tool call, shadowing or not."""
|
|
147
|
+
tool_name = _hook_field(payload, "tool_name") or ""
|
|
148
|
+
input_data = _hook_field(payload, "tool_input") or {}
|
|
149
|
+
if not isinstance(input_data, dict):
|
|
150
|
+
input_data = {}
|
|
151
|
+
|
|
152
|
+
decision = self.check(tool_name, input_data)
|
|
153
|
+
# A refused call produces TWO ledger entries, and that is deliberate.
|
|
154
|
+
# `tool_call` is the universal record -- every call this agent made, in
|
|
155
|
+
# order, allowed or not -- and it is what a timeline reads. `denial`
|
|
156
|
+
# below carries the reason and a summary of the arguments, and is the
|
|
157
|
+
# only place tool arguments are recorded at all.
|
|
158
|
+
#
|
|
159
|
+
# It looks like double-counting and has been reported as such. Dropping
|
|
160
|
+
# either one loses something real: without the `tool_call` the refusal
|
|
161
|
+
# vanishes from the call sequence, and without the `denial` nobody can
|
|
162
|
+
# say why. A reader tallying refusals should count `denial`, not both.
|
|
163
|
+
self.ctx.store.log(
|
|
164
|
+
"tool_call",
|
|
165
|
+
agent=self.agent.name,
|
|
166
|
+
tool=tool_name,
|
|
167
|
+
tool_use_id=tool_use_id,
|
|
168
|
+
allowed=decision.allowed,
|
|
169
|
+
)
|
|
170
|
+
if decision.allowed:
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
self._record(tool_name, input_data, decision.reason, via="hook")
|
|
174
|
+
return {
|
|
175
|
+
"hookSpecificOutput": {
|
|
176
|
+
"hookEventName": "PreToolUse",
|
|
177
|
+
"permissionDecision": "deny",
|
|
178
|
+
"permissionDecisionReason": decision.reason,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def _record(self, tool_name: str, input_data: dict[str, Any], reason: str, *, via: str) -> None:
|
|
183
|
+
self.ctx.store.log(
|
|
184
|
+
"denial",
|
|
185
|
+
agent=self.agent.name,
|
|
186
|
+
tool=tool_name,
|
|
187
|
+
reason=reason,
|
|
188
|
+
via=via,
|
|
189
|
+
args=_summarise(input_data),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def check(self, tool_name: str, input_data: dict[str, Any]) -> Decision:
|
|
193
|
+
"""Pure policy evaluation. Separated from the callback so it is testable."""
|
|
194
|
+
if tool_name.startswith("mcp__"):
|
|
195
|
+
return self._check_mcp(tool_name)
|
|
196
|
+
if tool_name in READ_TOOLS:
|
|
197
|
+
return self._check_declared(tool_name)
|
|
198
|
+
if tool_name in WRITE_TOOLS:
|
|
199
|
+
# Policy before allowlist: "you are read-only" is the true reason and
|
|
200
|
+
# the useful one. "not in your allowlist" would be technically correct
|
|
201
|
+
# and would send the agent looking for the wrong fix.
|
|
202
|
+
if not self._allowed_roots:
|
|
203
|
+
return Decision(
|
|
204
|
+
False,
|
|
205
|
+
f"{self.agent.name} is read-only. Report what you found; "
|
|
206
|
+
"fixing is another agent's job (§2: the finder never fixes).",
|
|
207
|
+
)
|
|
208
|
+
declared = self._check_declared(tool_name)
|
|
209
|
+
return declared if not declared.allowed else self._check_write(input_data)
|
|
210
|
+
if tool_name == "Bash":
|
|
211
|
+
declared = self._check_declared(tool_name)
|
|
212
|
+
return declared if not declared.allowed else self._check_bash(input_data)
|
|
213
|
+
if tool_name in {"WebFetch", "WebSearch"}:
|
|
214
|
+
return Decision(
|
|
215
|
+
False,
|
|
216
|
+
f"{self.agent.name} has no network research remit. "
|
|
217
|
+
"Findings come from the code and the running app, not the web.",
|
|
218
|
+
)
|
|
219
|
+
return self._check_declared(tool_name)
|
|
220
|
+
|
|
221
|
+
# -- individual gates -------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _check_declared(self, tool_name: str) -> Decision:
|
|
224
|
+
"""Defence in depth: the tool must be one this agent declared.
|
|
225
|
+
|
|
226
|
+
The SDK is already told the allowlist, so reaching here means something
|
|
227
|
+
upstream drifted. Better to deny and log than to trust the setup.
|
|
228
|
+
"""
|
|
229
|
+
if tool_name in self.agent.builtin_tools or tool_name in HARNESS_TOOLS:
|
|
230
|
+
return Decision(True)
|
|
231
|
+
return Decision(
|
|
232
|
+
False,
|
|
233
|
+
f"{tool_name} is not in {self.agent.name}'s tool allowlist "
|
|
234
|
+
f"({', '.join(self.agent.builtin_tools) or 'none'}).",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _check_mcp(self, tool_name: str) -> Decision:
|
|
238
|
+
if tool_name.startswith(self._mcp_prefixes):
|
|
239
|
+
return Decision(True)
|
|
240
|
+
server = tool_name.split("__")[1] if "__" in tool_name else "?"
|
|
241
|
+
return Decision(
|
|
242
|
+
False,
|
|
243
|
+
f"{self.agent.name} is not connected to the '{server}' server. "
|
|
244
|
+
f"Its servers are: {', '.join(self.agent.mcp_servers) or 'none'}.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _check_write(self, input_data: dict[str, Any]) -> Decision:
|
|
248
|
+
raw = input_data.get("file_path") or input_data.get("path") or input_data.get("notebook_path")
|
|
249
|
+
if not raw:
|
|
250
|
+
return Decision(False, "write refused: no file path in the call")
|
|
251
|
+
if not self._allowed_roots:
|
|
252
|
+
return Decision(
|
|
253
|
+
False,
|
|
254
|
+
f"{self.agent.name} is read-only. Report what you found; "
|
|
255
|
+
"fixing is another agent's job (§2: the finder never fixes).",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
target = Path(raw)
|
|
259
|
+
resolved = (target if target.is_absolute() else self.root / target).resolve()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
relative = resolved.relative_to(self.root).as_posix()
|
|
263
|
+
except ValueError:
|
|
264
|
+
relative = resolved.as_posix()
|
|
265
|
+
|
|
266
|
+
# The autonomy envelope (§8.2) comes first. A path inside the sandbox but
|
|
267
|
+
# in a forbidden class must still be refused, and the reason must name
|
|
268
|
+
# the class so the agent escalates rather than looking for a way round.
|
|
269
|
+
forbidden = self._forbidden_class(relative)
|
|
270
|
+
if forbidden:
|
|
271
|
+
return Decision(
|
|
272
|
+
False,
|
|
273
|
+
f"{relative} is outside {self.agent.name}'s autonomy envelope: it is "
|
|
274
|
+
f"{forbidden}. Changes here need human approval (§8.2). Describe the "
|
|
275
|
+
"change you would make and escalate instead of making it.",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
protected = self._protected_path(relative)
|
|
279
|
+
if protected:
|
|
280
|
+
return Decision(
|
|
281
|
+
False,
|
|
282
|
+
f"{relative} is the test that defines success for this ticket and may "
|
|
283
|
+
"not be edited (§10: a fixer that edits the test patches the symptom). "
|
|
284
|
+
"If you believe the test itself is wrong, that is an escalation.",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for allowed in self._allowed_roots:
|
|
288
|
+
if resolved == allowed or resolved.is_relative_to(allowed):
|
|
289
|
+
return self._check_diff_budget(relative)
|
|
290
|
+
return Decision(
|
|
291
|
+
False,
|
|
292
|
+
f"write refused: {resolved} is outside {self.agent.name}'s sandbox "
|
|
293
|
+
f"({', '.join(self.policy.write_paths)}).",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _forbidden_class(self, relative: str) -> str | None:
|
|
297
|
+
"""Which §8.2 class this path falls into, if any."""
|
|
298
|
+
for pattern in self.policy.forbidden_paths:
|
|
299
|
+
if fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(Path(relative).name, pattern):
|
|
300
|
+
return _describe_forbidden(pattern)
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
def _protected_path(self, relative: str) -> bool:
|
|
304
|
+
return any(
|
|
305
|
+
fnmatch.fnmatch(relative, p) or relative.endswith(p)
|
|
306
|
+
for p in self.policy.protected_paths
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _check_diff_budget(self, relative: str) -> Decision:
|
|
310
|
+
"""Cap how much one agent may change in a single run (§8.2).
|
|
311
|
+
|
|
312
|
+
Counted per distinct file touched, not per call: an agent editing the
|
|
313
|
+
same file six times has made one file's worth of change, and counting
|
|
314
|
+
calls would refuse a perfectly ordinary iteration.
|
|
315
|
+
"""
|
|
316
|
+
max_files = self.policy.max_diff_files
|
|
317
|
+
if max_files is None:
|
|
318
|
+
return Decision(True)
|
|
319
|
+
|
|
320
|
+
touched = self.ctx.touched_files
|
|
321
|
+
if relative in touched:
|
|
322
|
+
return Decision(True)
|
|
323
|
+
if len(touched) >= max_files:
|
|
324
|
+
return Decision(
|
|
325
|
+
False,
|
|
326
|
+
f"{self.agent.name} has already changed {len(touched)} files, which is "
|
|
327
|
+
f"its limit of {max_files} (§8.2). A fix this wide is outside the "
|
|
328
|
+
"autonomy envelope: stop, and escalate with what you have found. "
|
|
329
|
+
f"Already touched: {', '.join(sorted(touched))}.",
|
|
330
|
+
)
|
|
331
|
+
touched.add(relative)
|
|
332
|
+
return Decision(True)
|
|
333
|
+
|
|
334
|
+
def _check_bash(self, input_data: dict[str, Any]) -> Decision:
|
|
335
|
+
command = str(input_data.get("command", ""))
|
|
336
|
+
if not command.strip():
|
|
337
|
+
return Decision(False, "empty command")
|
|
338
|
+
|
|
339
|
+
for pattern, why in FORBIDDEN_BASH:
|
|
340
|
+
if re.search(pattern, command):
|
|
341
|
+
return Decision(False, f"command refused: {why}")
|
|
342
|
+
|
|
343
|
+
if GIT_WRITE.search(command) and not self.policy.branch_patterns:
|
|
344
|
+
return Decision(
|
|
345
|
+
False,
|
|
346
|
+
f"{self.agent.name} may not modify git state. "
|
|
347
|
+
"It has no branch patterns in its policy.",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if self.policy.branch_patterns:
|
|
351
|
+
branch = _branch_from_command(command)
|
|
352
|
+
if branch and not any(
|
|
353
|
+
fnmatch.fnmatch(branch, pat) for pat in self.policy.branch_patterns
|
|
354
|
+
):
|
|
355
|
+
return Decision(
|
|
356
|
+
False,
|
|
357
|
+
f"branch '{branch}' is outside {self.agent.name}'s patterns "
|
|
358
|
+
f"({', '.join(self.policy.branch_patterns)}).",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if self.policy.protected_paths:
|
|
362
|
+
for protected in self.policy.protected_paths:
|
|
363
|
+
if protected in command and _MUTATES_FILE.search(command):
|
|
364
|
+
return Decision(
|
|
365
|
+
False,
|
|
366
|
+
f"'{protected}' is protected: it defines what a fix must achieve "
|
|
367
|
+
"and may not be edited (§10, symptom fixes).",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return Decision(True)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _branch_from_command(command: str) -> str | None:
|
|
374
|
+
"""Best-effort branch name out of a git command, for policy matching."""
|
|
375
|
+
try:
|
|
376
|
+
parts = shlex.split(command)
|
|
377
|
+
except ValueError:
|
|
378
|
+
return None
|
|
379
|
+
for i, token in enumerate(parts):
|
|
380
|
+
if token in {"-b", "-c"} and i + 1 < len(parts):
|
|
381
|
+
return parts[i + 1]
|
|
382
|
+
if token in {"branch", "switch"} and i + 1 < len(parts):
|
|
383
|
+
candidate = parts[i + 1]
|
|
384
|
+
if not candidate.startswith("-"):
|
|
385
|
+
return candidate
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# Human-readable names for the forbidden classes, so a denial explains itself.
|
|
390
|
+
_FORBIDDEN_DESCRIPTIONS = [
|
|
391
|
+
("migration", "a database migration"),
|
|
392
|
+
("auth", "authentication or authorization code"),
|
|
393
|
+
("payment", "a payment path"),
|
|
394
|
+
("billing", "a billing path"),
|
|
395
|
+
("secret", "secret material"),
|
|
396
|
+
("infra", "infrastructure configuration"),
|
|
397
|
+
("terraform", "infrastructure configuration"),
|
|
398
|
+
(".tf", "infrastructure configuration"),
|
|
399
|
+
("docker", "container or deployment configuration"),
|
|
400
|
+
("k8s", "container or deployment configuration"),
|
|
401
|
+
("kube", "container or deployment configuration"),
|
|
402
|
+
(".github", "CI configuration"),
|
|
403
|
+
("workflow", "CI configuration"),
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _describe_forbidden(pattern: str) -> str:
|
|
408
|
+
lowered = pattern.lower()
|
|
409
|
+
for needle, description in _FORBIDDEN_DESCRIPTIONS:
|
|
410
|
+
if needle in lowered:
|
|
411
|
+
return description
|
|
412
|
+
return f"matched by the forbidden pattern `{pattern}`"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _hook_field(payload: Any, name: str) -> Any:
|
|
416
|
+
"""Hook payloads arrive as dicts or dataclasses depending on SDK version."""
|
|
417
|
+
if isinstance(payload, dict):
|
|
418
|
+
return payload.get(name)
|
|
419
|
+
return getattr(payload, name, None)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _summarise(input_data: dict[str, Any], limit: int = 200) -> dict[str, Any]:
|
|
423
|
+
"""Ledger-sized view of a tool call: enough to audit, not a content dump."""
|
|
424
|
+
out: dict[str, Any] = {}
|
|
425
|
+
for key, value in input_data.items():
|
|
426
|
+
if key in {"content", "new_string", "old_string"}:
|
|
427
|
+
out[key] = f"<{len(str(value))} chars>"
|
|
428
|
+
else:
|
|
429
|
+
text = str(value)
|
|
430
|
+
out[key] = text if len(text) <= limit else text[:limit] + "…"
|
|
431
|
+
return out
|
qaas/mcp/__init__.py
ADDED
|
File without changes
|
qaas/mcp/context.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Shared state the in-process MCP servers close over.
|
|
2
|
+
|
|
3
|
+
Every tool call lands in this process, so the servers can reach the run store,
|
|
4
|
+
the config and the target app directly. That is the point of building them
|
|
5
|
+
in-process: validation, guardrails and persistence happen where the state
|
|
6
|
+
already lives, with no serialisation boundary to reason about.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from qaas.config import AgentSpec, SystemConfig
|
|
16
|
+
from qaas.store import RunStore, SystemMapStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ToolContext:
|
|
21
|
+
"""One agent's view of the run. Rebuilt per agent invocation."""
|
|
22
|
+
|
|
23
|
+
store: RunStore
|
|
24
|
+
maps: SystemMapStore
|
|
25
|
+
config: SystemConfig
|
|
26
|
+
agent: AgentSpec
|
|
27
|
+
|
|
28
|
+
#: The application under test, on disk. Named `repo_root` once, and that
|
|
29
|
+
#: name was the bug: it read as "the qaas checkout", it was filled with
|
|
30
|
+
#: `Path.cwd()`, and every consumer -- the write-path allowlist, the test
|
|
31
|
+
#: runner's cwd, the vcs sandbox, the SDK subprocess cwd -- actually wanted
|
|
32
|
+
#: the target. That only coincided while the target sat inside the qaas
|
|
33
|
+
#: checkout, which is true of the bundled demo and of nothing else.
|
|
34
|
+
#: Comes from `SystemConfig.target_root()`, i.e. the profile.
|
|
35
|
+
target_root: Path
|
|
36
|
+
map_version: str | None = None
|
|
37
|
+
counters: dict[str, int] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
#: Files this agent has modified in this invocation. Backs the §8.2 diff
|
|
40
|
+
#: budget, which is counted per distinct file rather than per tool call.
|
|
41
|
+
touched_files: set[str] = field(default_factory=set)
|
|
42
|
+
|
|
43
|
+
def bump(self, key: str) -> int:
|
|
44
|
+
self.counters[key] = self.counters.get(key, 0) + 1
|
|
45
|
+
return self.counters[key]
|
|
46
|
+
|
|
47
|
+
def count(self, key: str) -> int:
|
|
48
|
+
return self.counters.get(key, 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ok(text: str, **structured: Any) -> dict[str, Any]:
|
|
52
|
+
"""A successful MCP tool result."""
|
|
53
|
+
result: dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
|
54
|
+
if structured:
|
|
55
|
+
result["structuredContent"] = structured
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def err(text: str) -> dict[str, Any]:
|
|
60
|
+
"""A failed MCP tool result.
|
|
61
|
+
|
|
62
|
+
Tool errors are returned, not raised: the agent should read the reason and
|
|
63
|
+
correct itself rather than have the turn die.
|
|
64
|
+
"""
|
|
65
|
+
return {"content": [{"type": "text", "text": text}], "isError": True}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def handlers(tools: list) -> dict[str, Any]:
|
|
69
|
+
"""Map tool name -> handler. Used by tests to exercise a server directly."""
|
|
70
|
+
return {t.name: t.handler for t in tools}
|