harnessweaver-cli 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. harnessweaver_cli/__init__.py +7 -0
  2. harnessweaver_cli/_load.py +119 -0
  3. harnessweaver_cli/_yaml.py +49 -0
  4. harnessweaver_cli/add.py +238 -0
  5. harnessweaver_cli/dev.py +141 -0
  6. harnessweaver_cli/doctor.py +185 -0
  7. harnessweaver_cli/evals.py +276 -0
  8. harnessweaver_cli/gateway.py +301 -0
  9. harnessweaver_cli/main.py +62 -0
  10. harnessweaver_cli/plugins.py +105 -0
  11. harnessweaver_cli/policy.py +143 -0
  12. harnessweaver_cli/py.typed +0 -0
  13. harnessweaver_cli/replay.py +75 -0
  14. harnessweaver_cli/scaffold.py +158 -0
  15. harnessweaver_cli/sidecar.py +48 -0
  16. harnessweaver_cli/spec.py +160 -0
  17. harnessweaver_cli/templates/_dev/compose.yaml +183 -0
  18. harnessweaver_cli/templates/_dev/otel-collector.yaml +25 -0
  19. harnessweaver_cli/templates/_plugin/README.md +26 -0
  20. harnessweaver_cli/templates/_plugin/pyproject.toml +26 -0
  21. harnessweaver_cli/templates/_plugin/src/__ns___harness/__init__.py +20 -0
  22. harnessweaver_cli/templates/_plugin/src/__ns___harness/guards.py +48 -0
  23. harnessweaver_cli/templates/_plugin/src/__ns___harness/py.typed +0 -0
  24. harnessweaver_cli/templates/_plugin/tests/test_guards.py +26 -0
  25. harnessweaver_cli/templates/agent/README.md +18 -0
  26. harnessweaver_cli/templates/agent/app/__init__.py +1 -0
  27. harnessweaver_cli/templates/agent/app/agent.py +12 -0
  28. harnessweaver_cli/templates/agent/app/main.py +19 -0
  29. harnessweaver_cli/templates/agent/app/tools.py +28 -0
  30. harnessweaver_cli/templates/agent/evals/redteam.yaml +17 -0
  31. harnessweaver_cli/templates/agent/harness.offline.yaml +16 -0
  32. harnessweaver_cli/templates/agent/harness.yaml +38 -0
  33. harnessweaver_cli/templates/agent/pyproject.toml +13 -0
  34. harnessweaver_cli/templates/agent/tests/test_agent.py +54 -0
  35. harnessweaver_cli/templates/agent/tools/lookup_order.tool.yaml +15 -0
  36. harnessweaver_cli/templates/agent/tools/refund_payment.tool.yaml +17 -0
  37. harnessweaver_cli/templates/chat/README.md +15 -0
  38. harnessweaver_cli/templates/chat/app/__init__.py +1 -0
  39. harnessweaver_cli/templates/chat/app/chat.py +32 -0
  40. harnessweaver_cli/templates/chat/app/main.py +33 -0
  41. harnessweaver_cli/templates/chat/evals/golden.yaml +10 -0
  42. harnessweaver_cli/templates/chat/harness.offline.yaml +12 -0
  43. harnessweaver_cli/templates/chat/harness.yaml +39 -0
  44. harnessweaver_cli/templates/chat/pyproject.toml +13 -0
  45. harnessweaver_cli/templates/chat/tests/test_chat.py +63 -0
  46. harnessweaver_cli/templates/extraction/README.md +16 -0
  47. harnessweaver_cli/templates/extraction/app/__init__.py +1 -0
  48. harnessweaver_cli/templates/extraction/app/main.py +21 -0
  49. harnessweaver_cli/templates/extraction/app/models.py +20 -0
  50. harnessweaver_cli/templates/extraction/evals/golden.yaml +12 -0
  51. harnessweaver_cli/templates/extraction/harness.offline.yaml +18 -0
  52. harnessweaver_cli/templates/extraction/harness.yaml +33 -0
  53. harnessweaver_cli/templates/extraction/prompts/extract@1.md +7 -0
  54. harnessweaver_cli/templates/extraction/pyproject.toml +13 -0
  55. harnessweaver_cli/templates/extraction/tests/test_extract.py +56 -0
  56. harnessweaver_cli/templates/rag/README.md +17 -0
  57. harnessweaver_cli/templates/rag/app/__init__.py +1 -0
  58. harnessweaver_cli/templates/rag/app/flow.py +29 -0
  59. harnessweaver_cli/templates/rag/app/main.py +19 -0
  60. harnessweaver_cli/templates/rag/data/kb.yaml +13 -0
  61. harnessweaver_cli/templates/rag/evals/golden.yaml +14 -0
  62. harnessweaver_cli/templates/rag/harness.offline.yaml +13 -0
  63. harnessweaver_cli/templates/rag/harness.yaml +50 -0
  64. harnessweaver_cli/templates/rag/prompts/answer@1.md +9 -0
  65. harnessweaver_cli/templates/rag/pyproject.toml +13 -0
  66. harnessweaver_cli/templates/rag/tests/test_answer.py +50 -0
  67. harnessweaver_cli/templates/workflow/README.md +16 -0
  68. harnessweaver_cli/templates/workflow/app/__init__.py +1 -0
  69. harnessweaver_cli/templates/workflow/app/main.py +20 -0
  70. harnessweaver_cli/templates/workflow/app/steps.py +49 -0
  71. harnessweaver_cli/templates/workflow/evals/golden.yaml +9 -0
  72. harnessweaver_cli/templates/workflow/harness.offline.yaml +12 -0
  73. harnessweaver_cli/templates/workflow/harness.yaml +39 -0
  74. harnessweaver_cli/templates/workflow/prompts/classify@1.md +4 -0
  75. harnessweaver_cli/templates/workflow/prompts/reply@1.md +5 -0
  76. harnessweaver_cli/templates/workflow/prompts/summarize@1.md +3 -0
  77. harnessweaver_cli/templates/workflow/pyproject.toml +13 -0
  78. harnessweaver_cli/templates/workflow/tests/test_pipeline.py +32 -0
  79. harnessweaver_cli/tools.py +72 -0
  80. harnessweaver_cli-0.7.1.dist-info/METADATA +83 -0
  81. harnessweaver_cli-0.7.1.dist-info/RECORD +84 -0
  82. harnessweaver_cli-0.7.1.dist-info/WHEEL +4 -0
  83. harnessweaver_cli-0.7.1.dist-info/entry_points.txt +2 -0
  84. harnessweaver_cli-0.7.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,301 @@
1
+ """``harnessweaver gateway litellm|mcp``: gateway configs from the same spec (ADR-0039).
2
+
3
+ An app that calls models and tools straight through a gateway, with no SDK,
4
+ still gets the spec's model aliases and its tool allow-list. A gateway can't see
5
+ a run, though: what it has read, who approves, which tenant it is for. So a tool
6
+ is let through only when its manifest asks for nothing that needs the run
7
+ (approvals, egress and the trifecta, tenants, rate limits); the others are
8
+ withheld and run only through the harness (in process, or harnessd). Policy
9
+ rules, budgets and guards stay with the harness too.
10
+ """
11
+
12
+ import math
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Annotated, Any
16
+
17
+ import typer
18
+ import yaml
19
+
20
+ from harnessweaver.core.errors import HarnessError
21
+ from harnessweaver.core.gateway import AllowedTool, GatewayMetadata, ToolAllowList, WithheldTool
22
+ from harnessweaver.core.manifests import ToolManifest, definition_digest
23
+ from harnessweaver.modules.tools import load_manifests
24
+ from harnessweaver.spec import EffectiveSpec, load_spec
25
+ from harnessweaver.spec.models import HarnessSpec
26
+ from harnessweaver_cli._load import fail, spec_path
27
+ from harnessweaver_cli.spec import Env, Path_, ProfileDirs
28
+
29
+ __all__ = [
30
+ "gateway_app",
31
+ "gateway_tools",
32
+ "litellm_config",
33
+ "render_allow_list",
34
+ "render_litellm",
35
+ "upstream_env",
36
+ ]
37
+
38
+ gateway_app = typer.Typer(
39
+ help="Gateway configs from the spec: LiteLLM Proxy, and a tool allow-list for MCP gateways.",
40
+ no_args_is_help=True,
41
+ )
42
+
43
+ Out = Annotated[
44
+ Path | None, typer.Option("--out", "-o", help="Write to this file instead of printing.")
45
+ ]
46
+
47
+ RETRIES = 2
48
+ """LiteLLM's retries: the gateway owns them, and SDK clients have theirs off (R5)."""
49
+
50
+
51
+ def upstream_env(model: str) -> str:
52
+ """The variable that names the provider model behind a gateway model name, such as
53
+ ``HARNESSWEAVER_UPSTREAM_FAST_CHAT`` for ``fast-chat``. The spec names only the
54
+ gateway's models; which provider serves each is the gateway owner's choice."""
55
+ return "HARNESSWEAVER_UPSTREAM_" + re.sub(r"[^A-Za-z0-9]", "_", model).upper()
56
+
57
+
58
+ def _withheld(manifest: ToolManifest, spec: HarnessSpec, approved: set[str]) -> str | None:
59
+ """Why only the harness may run this tool, or ``None`` when a gateway may.
60
+
61
+ Each reason is a manifest control that needs the run, so a gateway that
62
+ allowed the tool would skip it.
63
+ """
64
+ body = manifest.spec
65
+ if not body.enabled:
66
+ return "it is disabled"
67
+ digest = definition_digest(manifest.name, body.description, body.input_schema)
68
+ if body.definition_digest is not None and body.definition_digest != digest:
69
+ return "its pinned digest doesn't match its definition (S2)"
70
+ if (
71
+ body.approval is not None
72
+ or spec.tools.approval_by_risk.get(body.risk) == "required"
73
+ or manifest.name in approved
74
+ ):
75
+ return "it can need a person's approval, which only the harness can wait for"
76
+ if body.egress:
77
+ return "it has egress, and the trifecta check needs what the run has seen (S3)"
78
+ if body.tenants:
79
+ return "it is enabled for some tenants only"
80
+ if body.rate_limit is not None:
81
+ return "its rate limits are counted by the harness"
82
+ return None
83
+
84
+
85
+ def _manifests(effective: EffectiveSpec) -> list[ToolManifest]:
86
+ base = effective.path.parent if effective.path is not None else Path.cwd()
87
+ found = load_manifests(effective.spec.tools.manifests, base)
88
+ return sorted(found, key=lambda manifest: manifest.name)
89
+
90
+
91
+ def gateway_tools(effective: EffectiveSpec) -> ToolAllowList:
92
+ """The tools a gateway may let through, and the ones it must withhold (ADR-0039).
93
+
94
+ Raises:
95
+ SpecError: For a manifest that doesn't load.
96
+ """
97
+ spec = effective.spec
98
+ approved = {
99
+ tool for task in spec.tasks.values() if task.approvals for tool in task.approvals.tools
100
+ }
101
+ allowed: list[AllowedTool] = []
102
+ withheld: list[WithheldTool] = []
103
+ for manifest in _manifests(effective):
104
+ reason = _withheld(manifest, spec, approved)
105
+ if reason is not None:
106
+ withheld.append(WithheldTool(tool=manifest.name, reason=reason))
107
+ continue
108
+ body = manifest.spec
109
+ allowed.append(
110
+ AllowedTool(
111
+ tool=manifest.name,
112
+ transport=body.transport,
113
+ server=body.server,
114
+ # An unpinned manifest is pinned to what it says now; doctor warns about it.
115
+ digest=body.definition_digest
116
+ or definition_digest(manifest.name, body.description, body.input_schema),
117
+ scopes=body.scopes,
118
+ )
119
+ )
120
+ return ToolAllowList(
121
+ metadata=GatewayMetadata(name=spec.metadata.name),
122
+ tools=tuple(allowed),
123
+ withheld=tuple(withheld),
124
+ )
125
+
126
+
127
+ def _param_patterns(manifest: ToolManifest) -> dict[str, str]:
128
+ """Regexes for the top-level string arguments the schema constrains: its ``pattern``,
129
+ or its ``enum`` as alternatives. Other constraints stay with the governor (step 2)."""
130
+ properties = manifest.spec.input_schema.get("properties")
131
+ patterns: dict[str, str] = {}
132
+ if not isinstance(properties, dict):
133
+ return patterns
134
+ for name, schema in sorted(properties.items()):
135
+ if not isinstance(schema, dict):
136
+ continue
137
+ pattern, enum = schema.get("pattern"), schema.get("enum")
138
+ if isinstance(pattern, str):
139
+ patterns[name] = pattern
140
+ elif isinstance(enum, list) and enum and all(isinstance(v, str) for v in enum):
141
+ patterns[name] = "^(?:" + "|".join(re.escape(str(v)) for v in enum) + ")$"
142
+ return patterns
143
+
144
+
145
+ def _timeout(effective: EffectiveSpec) -> int | None:
146
+ """The longest deadline a run can have, in whole seconds: the gateway shouldn't keep
147
+ a request going after the harness has given up on it."""
148
+ spec = effective.spec
149
+ deadlines = [spec.budgets.per_run.deadline] + [
150
+ effective.run_budget(task).deadline for task in spec.tasks
151
+ ]
152
+ found = [deadline.total_seconds() for deadline in deadlines if deadline is not None]
153
+ return math.ceil(max(found)) if found else None
154
+
155
+
156
+ def litellm_config(effective: EffectiveSpec, tools: ToolAllowList) -> dict[str, Any]:
157
+ """A LiteLLM Proxy config: the gateway's models, the spec's aliases, retries and a
158
+ timeout, content kept out of its logs, and a tool-permission guardrail on every
159
+ request that allows only the allow-list's tools."""
160
+ spec = effective.spec
161
+ models = spec.models
162
+ allowed_aliases = models.allowed_aliases
163
+ aliases = {
164
+ alias: target
165
+ for alias, target in sorted(models.aliases.items())
166
+ if allowed_aliases is None or alias in allowed_aliases
167
+ }
168
+ names = set(aliases.values())
169
+ if models.default is not None:
170
+ names.add(models.aliases.get(models.default, models.default))
171
+ router: dict[str, Any] = {"num_retries": RETRIES}
172
+ if aliases:
173
+ router["model_group_alias"] = aliases
174
+ timeout = _timeout(effective)
175
+ if timeout is not None:
176
+ router["timeout"] = timeout
177
+ observability = spec.observability
178
+ settings: dict[str, Any] = {"turn_off_message_logging": not observability.capture_content}
179
+ config: dict[str, Any] = {
180
+ "model_list": [
181
+ {"model_name": name, "litellm_params": {"model": f"os.environ/{upstream_env(name)}"}}
182
+ for name in sorted(names)
183
+ ],
184
+ "router_settings": router,
185
+ "litellm_settings": settings,
186
+ }
187
+ if observability.exporter == "otlp":
188
+ settings["callbacks"] = ["otel"]
189
+ config["environment_variables"] = {
190
+ "OTEL_EXPORTER": "otlp_http",
191
+ "OTEL_ENDPOINT": observability.endpoint or "http://localhost:4318",
192
+ }
193
+ manifests = {manifest.name: manifest for manifest in _manifests(effective)}
194
+ rules: list[dict[str, Any]] = []
195
+ for tool in tools.tools:
196
+ rule: dict[str, Any] = {
197
+ "id": f"allow-{tool.tool}",
198
+ "tool_name": f"^{re.escape(tool.tool)}$",
199
+ "decision": "allow",
200
+ }
201
+ patterns = _param_patterns(manifests[tool.tool])
202
+ if patterns:
203
+ rule["allowed_param_patterns"] = patterns
204
+ rules.append(rule)
205
+ rules += [
206
+ {"id": f"withhold-{w.tool}", "tool_name": f"^{re.escape(w.tool)}$", "decision": "deny"}
207
+ for w in tools.withheld
208
+ ]
209
+ config["guardrails"] = [
210
+ {
211
+ "guardrail_name": "harnessweaver-tools",
212
+ "litellm_params": {
213
+ "guardrail": "tool_permission",
214
+ "mode": "post_call",
215
+ "default_on": True,
216
+ "rules": rules,
217
+ "default_action": "deny", # no tool runs without a manifest (S2)
218
+ "on_disallowed_action": "block",
219
+ },
220
+ }
221
+ ]
222
+ config["general_settings"] = {"master_key": "os.environ/LITELLM_MASTER_KEY"}
223
+ return config
224
+
225
+
226
+ def _dump(data: Any) -> str:
227
+ return yaml.safe_dump(data, sort_keys=False, default_flow_style=False, width=100)
228
+
229
+
230
+ def _withheld_lines(tools: ToolAllowList) -> list[str]:
231
+ if not tools.withheld:
232
+ return []
233
+ lines = ["Tools only the harness runs (denied here; run them through harnessd):"]
234
+ return lines + [f" {w.tool}: {w.reason}" for w in tools.withheld]
235
+
236
+
237
+ def _comment(lines: list[str]) -> str:
238
+ return "".join(f"# {line}".rstrip() + "\n" for line in lines)
239
+
240
+
241
+ def render_litellm(effective: EffectiveSpec) -> str:
242
+ """The LiteLLM Proxy config as YAML, with a header saying what to set and what is
243
+ withheld."""
244
+ tools = gateway_tools(effective)
245
+ names = [entry["model_name"] for entry in litellm_config(effective, tools)["model_list"]]
246
+ header = [
247
+ f"LiteLLM Proxy config for {effective.spec.metadata.name}, generated by",
248
+ "`harnessweaver gateway litellm` from the spec and its tool manifests (ADR-0039).",
249
+ "Regenerate it when they change. In the gateway's environment (S1), set",
250
+ "LITELLM_MASTER_KEY, the provider keys, and the provider model behind each name:",
251
+ *[f" {upstream_env(name)}" for name in names],
252
+ "Policy rules, budgets, guards and approvals stay with the harness.",
253
+ *_withheld_lines(tools),
254
+ ]
255
+ return _comment(header) + _dump(litellm_config(effective, tools))
256
+
257
+
258
+ def render_allow_list(effective: EffectiveSpec) -> str:
259
+ """The neutral tool allow-list as YAML (schema ``tool-allow-list``)."""
260
+ tools = gateway_tools(effective)
261
+ header = [
262
+ f"Tool allow-list for {effective.spec.metadata.name}, generated by",
263
+ "`harnessweaver gateway mcp` (ADR-0039; schema tool-allow-list). An MCP gateway",
264
+ "allows these tools only, pins each digest, and requires its scopes.",
265
+ ]
266
+ return _comment(header) + _dump(tools.model_dump(mode="json", by_alias=True))
267
+
268
+
269
+ def _emit(text: str, out: Path | None) -> None:
270
+ if out is None:
271
+ typer.echo(text, nl=False)
272
+ return
273
+ out.parent.mkdir(parents=True, exist_ok=True)
274
+ out.write_text(text, encoding="utf-8")
275
+ typer.echo(f"wrote {out}")
276
+
277
+
278
+ @gateway_app.command("litellm")
279
+ def litellm(
280
+ path: Path_ = None, env: Env = None, profile_dir: ProfileDirs = None, out: Out = None
281
+ ) -> None:
282
+ """A LiteLLM Proxy config: model aliases, router settings and tool permissions."""
283
+ try:
284
+ effective = load_spec(spec_path(path), env=env, profile_dirs=profile_dir or [])
285
+ text = render_litellm(effective)
286
+ except HarnessError as exc:
287
+ raise fail(exc) from None
288
+ _emit(text, out)
289
+
290
+
291
+ @gateway_app.command("mcp")
292
+ def mcp(
293
+ path: Path_ = None, env: Env = None, profile_dir: ProfileDirs = None, out: Out = None
294
+ ) -> None:
295
+ """A neutral tool allow-list for an MCP gateway: tool, server, digest and scopes."""
296
+ try:
297
+ effective = load_spec(spec_path(path), env=env, profile_dirs=profile_dir or [])
298
+ text = render_allow_list(effective)
299
+ except HarnessError as exc:
300
+ raise fail(exc) from None
301
+ _emit(text, out)
@@ -0,0 +1,62 @@
1
+ """Entry point for the ``harnessweaver`` command."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ import harnessweaver
8
+ from harnessweaver_cli import __version__
9
+ from harnessweaver_cli.add import add_app
10
+ from harnessweaver_cli.dev import dev
11
+ from harnessweaver_cli.doctor import doctor
12
+ from harnessweaver_cli.evals import eval_app
13
+ from harnessweaver_cli.gateway import gateway_app
14
+ from harnessweaver_cli.plugins import plugin_app
15
+ from harnessweaver_cli.policy import policy_app
16
+ from harnessweaver_cli.replay import replay
17
+ from harnessweaver_cli.scaffold import init, run_tests
18
+ from harnessweaver_cli.sidecar import sidecar
19
+ from harnessweaver_cli.spec import contracts_app, hooks, spec_app
20
+ from harnessweaver_cli.tools import tools_app
21
+
22
+ app = typer.Typer(
23
+ name="harnessweaver",
24
+ help="HarnessWeaver developer tooling: weave, check and test your AI harness.",
25
+ no_args_is_help=True,
26
+ add_completion=False,
27
+ )
28
+ app.command()(init)
29
+ app.add_typer(spec_app, name="spec")
30
+ app.command()(doctor)
31
+ app.command()(hooks)
32
+ app.command(
33
+ name="test", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
34
+ )(run_tests)
35
+ app.add_typer(contracts_app, name="contracts")
36
+ app.add_typer(eval_app, name="eval")
37
+ app.add_typer(policy_app, name="policy")
38
+ app.command()(replay)
39
+ app.add_typer(add_app, name="add")
40
+ app.add_typer(tools_app, name="tools")
41
+ app.add_typer(plugin_app, name="plugin")
42
+ app.command()(sidecar)
43
+ app.add_typer(gateway_app, name="gateway")
44
+ app.command()(dev)
45
+
46
+
47
+ def _print_version(value: bool) -> None:
48
+ if value:
49
+ typer.echo(f"harnessweaver-cli {__version__} (harnessweaver {harnessweaver.__version__})")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def main(
55
+ version: Annotated[
56
+ bool,
57
+ typer.Option(
58
+ "--version", callback=_print_version, is_eager=True, help="Show versions and exit."
59
+ ),
60
+ ] = False,
61
+ ) -> None:
62
+ """HarnessWeaver developer tooling."""
@@ -0,0 +1,105 @@
1
+ """``harnessweaver plugin new`` and ``harnessweaver plugin list`` (DESIGNS No. 13).
2
+
3
+ ``plugin new`` scaffolds a standalone plugin package, meant to live in its own
4
+ repository: it builds on the public extension surface only and passes the
5
+ conformance suite as written. ``plugin list`` shows what is installed.
6
+ """
7
+
8
+ import json
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ from rich import box
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from harnessweaver import CONTRACT_VERSION, RESERVED_NAMESPACES
19
+ from harnessweaver.core.errors import HarnessError
20
+ from harnessweaver.core.plugins.names import NAMESPACE_PATTERN
21
+ from harnessweaver.core.registry import PluginCatalog
22
+ from harnessweaver_cli._load import fail
23
+ from harnessweaver_cli.scaffold import copy_tree
24
+ from harnessweaver_cli.spec import Format
25
+
26
+ __all__ = ["plugin_app"]
27
+
28
+ plugin_app = typer.Typer(help="Create and inspect plugins.", no_args_is_help=True)
29
+
30
+
31
+ @plugin_app.command("new")
32
+ def new(
33
+ namespace: Annotated[str, typer.Argument(help="The plugin's namespace, such as acme.")],
34
+ directory: Annotated[
35
+ Path | None, typer.Argument(help="Where to create it (default: ./<namespace>-harness).")
36
+ ] = None,
37
+ force: Annotated[bool, typer.Option("--force", help="Overwrite existing files.")] = False,
38
+ ) -> None:
39
+ """Scaffold a standalone plugin package: entry point, ``hw.Plugin``, a sample guard
40
+ and tests that also run the conformance suite (T5)."""
41
+ if not NAMESPACE_PATTERN.match(namespace):
42
+ raise fail(
43
+ f"invalid namespace {namespace!r}: use lowercase letters, digits and underscores, "
44
+ "starting with a letter"
45
+ )
46
+ if namespace in RESERVED_NAMESPACES:
47
+ raise fail(f"namespace {namespace!r} is reserved for the built-in plugin")
48
+ dist = f"{namespace.replace('_', '-')}-harness"
49
+ target = directory or Path(dist)
50
+ root = resources.files("harnessweaver_cli").joinpath("templates", "_plugin")
51
+ written = copy_tree(root, target, {"__dist__": dist, "__ns__": namespace}, force=force)
52
+ typer.echo(f"created {len(written)} files in {target} for the plugin {namespace!r} ({dist})")
53
+ typer.echo("next:")
54
+ typer.echo(f" cd {target}")
55
+ typer.echo(" uv sync && uv run pytest # its tests and the conformance suite")
56
+
57
+
58
+ def _row(catalog: PluginCatalog, namespace: str, sources: tuple[Any, ...]) -> dict[str, Any]:
59
+ """What ``plugin list`` shows for one namespace. Importing a plugin does no I/O (S8)."""
60
+ source = sources[0]
61
+ row: dict[str, Any] = {
62
+ "namespace": namespace,
63
+ "distribution": source.distribution,
64
+ "target": source.target,
65
+ }
66
+ try:
67
+ plugin = catalog.load(namespace).plugin
68
+ except HarnessError as exc: # several claim it, or it doesn't import
69
+ row.update(version=None, contract=None, compatible=False, problem=exc.message)
70
+ return row
71
+ row.update(
72
+ version=plugin.version,
73
+ contract=plugin.contract,
74
+ compatible=plugin.contract_range.contains(CONTRACT_VERSION),
75
+ contributions=len(plugin.contributions()),
76
+ problem=None,
77
+ )
78
+ return row
79
+
80
+
81
+ @plugin_app.command("list")
82
+ def list_plugins(output: Format = "text") -> None:
83
+ """Installed plugins: namespace, distribution, version, contract range and whether it
84
+ is compatible with this harnessweaver (V5)."""
85
+ catalog = PluginCatalog.default()
86
+ rows = [_row(catalog, ns, sources) for ns, sources in catalog.sources().items()]
87
+ if output == "json":
88
+ typer.echo(json.dumps({"plugins": rows, "problems": catalog.problems()}, indent=2))
89
+ return
90
+ table = Table(box=box.SIMPLE, show_header=True)
91
+ for column in ("namespace", "distribution", "version", "contract", "compatible"):
92
+ table.add_column(column)
93
+ for row in rows:
94
+ compatible = "yes" if row["compatible"] else f"no: {row['problem'] or 'contract range'}"
95
+ table.add_row(
96
+ row["namespace"],
97
+ row["distribution"] or "-",
98
+ row["version"] or "-",
99
+ row["contract"] or "-",
100
+ compatible,
101
+ )
102
+ Console(width=120, highlight=False).print(table)
103
+ for problem in catalog.problems():
104
+ typer.echo(f"warning: {problem}")
105
+ typer.echo(f"harnessweaver contract {CONTRACT_VERSION}")
@@ -0,0 +1,143 @@
1
+ """``harnessweaver policy test``: table-driven tests of policy rules and tool approvals.
2
+
3
+ Each row names a tool call, who makes it and what the governor must decide::
4
+
5
+ cases:
6
+ - id: small-refunds-pass
7
+ tool: refund_payment
8
+ args: { amount_usd: 20 }
9
+ principal: { id: alice, roles: [support] }
10
+ expect: allow
11
+ - id: big-refunds-need-finance
12
+ tool: refund_payment
13
+ args: { amount_usd: 900 }
14
+ expect: require_approval
15
+ approver_role: finance
16
+
17
+ The governor's steps 1 to 7 decide each row as a dry run (``Harness.authorize``):
18
+ the spec's policy engine, manifests, approval rules, the trifecta and the judge,
19
+ with no rate limit used up and no tool executed. Exits 1 when a row's verdict
20
+ differs and 2 when the tests can't run.
21
+ """
22
+
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Annotated
26
+
27
+ import anyio
28
+ import typer
29
+ from pydantic import ValidationError
30
+
31
+ from harnessweaver import Harness
32
+ from harnessweaver.core.errors import HarnessError, SpecError
33
+ from harnessweaver.core.policy import PolicyCase, PolicyTests
34
+ from harnessweaver.core.tools import ToolVerdict
35
+ from harnessweaver.modules.state import MemoryStateStore
36
+ from harnessweaver.spec.yaml import load_yaml_file
37
+ from harnessweaver_cli._load import fail, load_harness
38
+ from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
39
+
40
+ __all__ = ["policy_app"]
41
+
42
+ policy_app = typer.Typer(help="Test policy rules and tool approvals.", no_args_is_help=True)
43
+
44
+ CANT_RUN = 2
45
+ DEFAULT_CASES = Path("policy_tests.yaml")
46
+
47
+
48
+ def _read(path: Path) -> PolicyTests:
49
+ if not path.is_file():
50
+ raise SpecError(f"no policy tests at {path}")
51
+ try:
52
+ return PolicyTests.model_validate(load_yaml_file(path))
53
+ except ValidationError as exc:
54
+ problems = "; ".join(
55
+ f"{'.'.join(str(p) for p in e['loc']) or '(file)'}: {e['msg']}" for e in exc.errors()
56
+ )
57
+ raise SpecError(f"{path}: {problems}") from None
58
+
59
+
60
+ def _mismatch(case: PolicyCase, verdict: ToolVerdict) -> str | None:
61
+ wanted = [
62
+ ("decision", case.expect, verdict.decision),
63
+ ("code", case.code, verdict.code),
64
+ ("step", case.step, verdict.step),
65
+ ("approver_role", case.approver_role, verdict.approver_role),
66
+ ]
67
+ wrong = [
68
+ f"{name} {got!r}, not {want!r}"
69
+ for name, want, got in wanted
70
+ if want is not None and want != got
71
+ ]
72
+ return "; ".join(wrong) or None
73
+
74
+
75
+ async def _check(harness: Harness, tests: PolicyTests) -> list[tuple[PolicyCase, ToolVerdict]]:
76
+ return [
77
+ (
78
+ case,
79
+ await harness.authorize(
80
+ case.tool,
81
+ case.args,
82
+ principal=case.principal,
83
+ tenant=case.tenant,
84
+ task=case.task,
85
+ labels=case.labels,
86
+ ),
87
+ )
88
+ for case in tests.cases
89
+ ]
90
+
91
+
92
+ @policy_app.command("test")
93
+ def run_policy_tests(
94
+ path: Path_ = None,
95
+ env: Env = None,
96
+ profile_dir: ProfileDirs = None,
97
+ cases: Annotated[
98
+ list[Path] | None,
99
+ typer.Option(
100
+ "--cases", "-c", help="Test files (default: policy_tests.yaml next to the spec)."
101
+ ),
102
+ ] = None,
103
+ output: Format = "text",
104
+ ) -> None:
105
+ """Check each row's tool call against the governor, without running anything.
106
+
107
+ Exits 1 when a verdict differs from the row's expectation; 2 when the tests
108
+ can't run.
109
+ """
110
+ harness = load_harness(path, env, profile_dir or [], state=MemoryStateStore(), code=CANT_RUN)
111
+ base = harness.spec.path.parent if harness.spec.path is not None else Path.cwd()
112
+ files = cases or [base / DEFAULT_CASES]
113
+ results: list[tuple[Path, PolicyCase, ToolVerdict]] = []
114
+ try:
115
+ for file in files:
116
+ checked = anyio.run(_check, harness, _read(file))
117
+ results += [(file, case, verdict) for case, verdict in checked]
118
+ except HarnessError as exc:
119
+ raise fail(exc, CANT_RUN) from None
120
+ failures = [(f, c, v) for f, c, v in results if _mismatch(c, v) is not None]
121
+ if output == "json":
122
+ body = [
123
+ {
124
+ "file": str(file),
125
+ "id": case.id,
126
+ "ok": _mismatch(case, verdict) is None,
127
+ "expect": case.expect,
128
+ "verdict": verdict.model_dump(mode="json"),
129
+ }
130
+ for file, case, verdict in results
131
+ ]
132
+ typer.echo(json.dumps(body, indent=2))
133
+ else:
134
+ for _, case, verdict in results:
135
+ problem = _mismatch(case, verdict)
136
+ shown = f"{verdict.decision} at step {verdict.step}"
137
+ if problem is None:
138
+ typer.echo(f"ok {case.id}: {shown}")
139
+ else:
140
+ typer.echo(f"FAIL {case.id}: {problem} ({verdict.reason})")
141
+ typer.echo(f"{len(results) - len(failures)} of {len(results)} policy tests passed")
142
+ if failures:
143
+ raise typer.Exit(code=1)
File without changes
@@ -0,0 +1,75 @@
1
+ """``harnessweaver replay <run_id>``: run a recorded run again against the current code
2
+ and spec (ADR-0035).
3
+
4
+ The run must have been recorded (``state.record: true``). Its model answers and
5
+ tool results come from the recording, so nothing calls out and no tool runs;
6
+ the replay's own runs stay in memory. Exits 1 when the status or the output
7
+ changed, and 2 when the run can't be replayed.
8
+ """
9
+
10
+ import json
11
+
12
+ import anyio
13
+ import typer
14
+
15
+ from harnessweaver import Harness
16
+ from harnessweaver.core.errors import HarnessError
17
+ from harnessweaver.core.replay import ReplayReport
18
+ from harnessweaver_cli._load import fail, load_harness
19
+ from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
20
+
21
+ __all__ = ["replay"]
22
+
23
+ CANT_RUN = 2
24
+
25
+
26
+ async def _replay(harness: Harness, run_id: str) -> ReplayReport:
27
+ try:
28
+ return await harness.replay(run_id)
29
+ finally:
30
+ await harness.aclose()
31
+
32
+
33
+ def _print(report: ReplayReport) -> None:
34
+ before, after = report.original, report.replayed
35
+ typer.echo(
36
+ f"{'changed' if report.differs else 'same'}: run {report.run_id} ({report.task}) "
37
+ f"ended {before.status.value}; the replay ended {after.status.value}"
38
+ )
39
+ typer.echo(f" output: {'same' if report.same_output else 'different'}")
40
+ typer.echo(
41
+ f" model answers used: {report.model_answers_used} of {report.model_answers}"
42
+ + (f"; {report.changed_requests} request(s) changed" if report.changed_requests else "")
43
+ )
44
+ if report.missing_tool_results:
45
+ typer.echo(f" tool calls not in the recording: {report.missing_tool_results}")
46
+ for change in report.changes:
47
+ sign = "+" if change.change == "added" else "-"
48
+ where = change.hook.value if change.hook is not None else "-"
49
+ what = change.code or (change.outcome.value if change.outcome else "")
50
+ typer.echo(f" {sign} {where} {change.source} {what}: {change.reason or ''}".rstrip(": "))
51
+
52
+
53
+ def replay(
54
+ run_id: str = typer.Argument(help="The recorded run."),
55
+ path: Path_ = None,
56
+ env: Env = None,
57
+ profile_dir: ProfileDirs = None,
58
+ output: Format = "text",
59
+ ) -> None:
60
+ """Replay a recorded run against the current code and spec, and show what changed.
61
+
62
+ Exits 1 when the status or output changed; 2 when the run can't be replayed.
63
+ """
64
+ harness = load_harness(path, env, profile_dir or [], code=CANT_RUN)
65
+ try:
66
+ report = anyio.run(_replay, harness, run_id)
67
+ except HarnessError as exc:
68
+ raise fail(exc, CANT_RUN) from None
69
+ if output == "json":
70
+ body = {**report.model_dump(mode="json"), "differs": report.differs}
71
+ typer.echo(json.dumps(body, indent=2))
72
+ else:
73
+ _print(report)
74
+ if report.differs:
75
+ raise typer.Exit(code=1)