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.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. 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}