agentforge-py 0.2.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 (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,143 @@
1
+ """`RegressionVsBaseline` — deterministic grader against a locked baseline.
2
+
3
+ The baseline file is a JSONL (one JSON object per line) where each
4
+ entry is keyed by `task`:
5
+
6
+ {"task": "Summarise PR #42", "expected": "PR #42 adds X..."}
7
+ {"task": "List failing tests", "expected": ["test_a", "test_b"]}
8
+
9
+ At construction the file is loaded into a `{task: expected_output}`
10
+ map. For each `evaluate` call the grader picks the baseline entry
11
+ matching `context["task"]` and compares `finding.output` against it.
12
+
13
+ Two modes pick the comparison:
14
+ - `mode="exact"` (default) — `output == expected`. Score 1.0 or 0.0.
15
+ - `mode="structural"` — output and expected are both dicts;
16
+ score = fraction of matching keys (case-sensitive on both keys
17
+ and values).
18
+
19
+ Result labels:
20
+ - `"improved"` — exact-match in exact mode; perfect structural match.
21
+ - `"regressed"` — mismatch in either mode.
22
+ - `"no_baseline"` — no entry in the baseline file matches the task.
23
+ Score is NaN; the evaluator does not claim regression in this case.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import math
30
+ from pathlib import Path
31
+ from typing import Any, ClassVar, Literal
32
+
33
+ from agentforge_core.contracts.evaluator import EvalResult, Evaluator
34
+
35
+ _Mode = Literal["exact", "structural"]
36
+
37
+
38
+ class RegressionVsBaseline(Evaluator):
39
+ """Score the current run vs a locked baseline file."""
40
+
41
+ name: ClassVar[str] = "regression_vs_baseline"
42
+ cost_estimate_usd: ClassVar[float] = 0.0
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ baseline_path: str | Path,
48
+ mode: _Mode = "exact",
49
+ ) -> None:
50
+ if mode not in ("exact", "structural"):
51
+ raise ValueError(f"mode must be 'exact' or 'structural'; got {mode!r}")
52
+ self._mode = mode
53
+ self._baselines: dict[str, Any] = self._load(Path(baseline_path))
54
+
55
+ @staticmethod
56
+ def _load(path: Path) -> dict[str, Any]:
57
+ if not path.exists():
58
+ raise FileNotFoundError(f"baseline file not found: {path}")
59
+ out: dict[str, Any] = {}
60
+ with path.open(encoding="utf-8") as fh:
61
+ for lineno, raw in enumerate(fh, start=1):
62
+ line = raw.strip()
63
+ if not line:
64
+ continue
65
+ try:
66
+ entry = json.loads(line)
67
+ except json.JSONDecodeError as exc:
68
+ raise ValueError(
69
+ f"baseline {path}:{lineno} is not valid JSON: {exc.msg}"
70
+ ) from exc
71
+ if not isinstance(entry, dict) or "task" not in entry or "expected" not in entry:
72
+ raise ValueError(
73
+ f"baseline {path}:{lineno} must have 'task' and 'expected' keys"
74
+ )
75
+ out[entry["task"]] = entry["expected"]
76
+ if not out:
77
+ raise ValueError(f"baseline file {path} is empty")
78
+ return out
79
+
80
+ async def evaluate(self, finding: Any, context: dict[str, Any]) -> EvalResult:
81
+ task = context.get("task")
82
+ if not isinstance(task, str) or task not in self._baselines:
83
+ return EvalResult(
84
+ evaluator=self.name,
85
+ score=math.nan,
86
+ label="no_baseline",
87
+ reasoning=f"no baseline entry for task {task!r}",
88
+ )
89
+
90
+ expected = self._baselines[task]
91
+ output = finding.output if hasattr(finding, "output") else finding
92
+
93
+ if self._mode == "exact":
94
+ matches = output == expected
95
+ return EvalResult(
96
+ evaluator=self.name,
97
+ score=1.0 if matches else 0.0,
98
+ label="improved" if matches else "regressed",
99
+ reasoning="exact match" if matches else "output differs from baseline",
100
+ raw={"expected": expected, "actual": output},
101
+ )
102
+
103
+ # Structural mode.
104
+ if not isinstance(expected, dict) or not isinstance(output, dict):
105
+ return EvalResult(
106
+ evaluator=self.name,
107
+ score=0.0,
108
+ label="regressed",
109
+ reasoning=(
110
+ f"structural mode requires dict output and dict baseline; "
111
+ f"got output={type(output).__name__}, expected={type(expected).__name__}"
112
+ ),
113
+ )
114
+ return self._compare_structural(output, expected)
115
+
116
+ def _compare_structural(self, output: dict[str, Any], expected: dict[str, Any]) -> EvalResult:
117
+ all_keys = expected.keys() | output.keys()
118
+ matching = [
119
+ k for k in all_keys if k in expected and k in output and output[k] == expected[k]
120
+ ]
121
+ missing = sorted(k for k in expected if k not in output)
122
+ extra = sorted(k for k in output if k not in expected)
123
+ mismatched = sorted(k for k in expected if k in output and output[k] != expected[k])
124
+ score = len(matching) / len(all_keys) if all_keys else 1.0
125
+ label = "improved" if score == 1.0 else "regressed"
126
+ return EvalResult(
127
+ evaluator=self.name,
128
+ score=score,
129
+ label=label,
130
+ reasoning=(
131
+ f"structural match {len(matching)}/{len(all_keys)} keys "
132
+ f"(missing={missing}, extra={extra}, mismatched={mismatched})"
133
+ ),
134
+ raw={
135
+ "matched_keys": sorted(matching),
136
+ "missing_keys": missing,
137
+ "extra_keys": extra,
138
+ "mismatched_keys": mismatched,
139
+ },
140
+ )
141
+
142
+
143
+ __all__ = ["RegressionVsBaseline"]
agentforge/findings.py ADDED
@@ -0,0 +1,166 @@
1
+ """Finding variants and helper value types (feat-008).
2
+
3
+ The `Finding` Protocol itself lives in `agentforge_core.contracts.finding`
4
+ (shipped under feat-001). This module ships the four built-in variants
5
+ plus the `Patch` and `Span` helpers two of them embed.
6
+
7
+ Variants are **frozen Pydantic v2 models** (per ADR-0014, deviating
8
+ from spec §4.2's `@dataclass` sketch). The external shape is the same;
9
+ the model framework gives us validation on construction, declarative
10
+ schema, and `model_dump` / `model_validate` round-trip — important
11
+ because findings cross persistence (`Claim.payload`), transport
12
+ (A2A / MCP / pipeline aggregation), and LLM-output (structured-output
13
+ emission) boundaries.
14
+
15
+ Each variant satisfies the `Finding` Protocol structurally — no
16
+ inheritance from the Protocol is required. The Protocol is
17
+ `runtime_checkable`, so `isinstance(x, Finding)` works.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Self
23
+
24
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
25
+
26
+
27
+ class Patch(BaseModel):
28
+ """A structured diff embedded inside a `PatchFinding`.
29
+
30
+ Attributes:
31
+ file: Path to the file the diff applies against, relative to
32
+ repo root (e.g. `"src/foo.py"`).
33
+ diff: Unified-diff text. Must be a complete, applyable hunk
34
+ (or set of hunks against a single file).
35
+ hunk_count: Number of `@@` headers in `diff`. Cached at
36
+ construction; renderers may use it for summary stats.
37
+ """
38
+
39
+ model_config = ConfigDict(frozen=True, strict=True)
40
+
41
+ file: str = Field(min_length=1)
42
+ diff: str = Field(min_length=1)
43
+ hunk_count: int = Field(default=1, ge=1)
44
+
45
+
46
+ class Span(BaseModel):
47
+ """A single source-range citation inside a `MultiSpanFinding`.
48
+
49
+ Attributes:
50
+ file: Path the span is in, relative to repo root.
51
+ start_line: 1-indexed first line of the span.
52
+ end_line: 1-indexed last line of the span (inclusive). Must
53
+ be `>= start_line`.
54
+ excerpt: The text on those lines (optional; useful for
55
+ renderers that can't read the source file themselves,
56
+ e.g. when findings are persisted and rendered later).
57
+ """
58
+
59
+ model_config = ConfigDict(frozen=True, strict=True)
60
+
61
+ file: str = Field(min_length=1)
62
+ start_line: int = Field(ge=1)
63
+ end_line: int = Field(ge=1)
64
+ excerpt: str = ""
65
+
66
+ @field_validator("end_line")
67
+ @classmethod
68
+ def _end_after_start(cls, end_line: int, info: Any) -> int:
69
+ start = info.data.get("start_line")
70
+ if start is not None and end_line < start:
71
+ raise ValueError(f"end_line ({end_line}) must be >= start_line ({start})")
72
+ return end_line
73
+
74
+
75
+ class _FindingBase(BaseModel):
76
+ """Internal base — shared config + `to_dict` / `from_dict` plumbing.
77
+
78
+ Not part of the public surface. Each variant subclasses this to
79
+ inherit the round-trip helpers. Subclasses MUST declare the
80
+ Protocol-required attributes (`severity`, `category`, `message`)
81
+ themselves so `isinstance(x, Finding)` resolves correctly.
82
+ """
83
+
84
+ model_config = ConfigDict(frozen=True, strict=True)
85
+
86
+ def to_dict(self) -> dict[str, Any]:
87
+ """JSON-compatible serialisation. Round-trips via `from_dict`."""
88
+ return self.model_dump(mode="json")
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> Self:
92
+ """Reconstruct a typed variant from a `to_dict()` payload."""
93
+ return cls.model_validate(data)
94
+
95
+
96
+ class SimpleFinding(_FindingBase):
97
+ """The default variant — a severity-tagged issue or observation.
98
+
99
+ Use for code-review-style output, lint hits, audit notes — anything
100
+ that maps to "one finding = one location + one recommendation".
101
+ """
102
+
103
+ severity: str = Field(min_length=1)
104
+ category: str = Field(min_length=1)
105
+ message: str = Field(min_length=1)
106
+ recommendation: str = ""
107
+ file: str = ""
108
+ line: int | None = None
109
+ rule_id: str = ""
110
+ metadata: dict[str, Any] = Field(default_factory=dict)
111
+
112
+
113
+ class PatchFinding(_FindingBase):
114
+ """Finding that ships a structured patch the consumer can apply.
115
+
116
+ Use for refactor bots, codemod agents, and auto-fix suggestions.
117
+ `confidence` is a model-supplied estimate in `[0, 1]`; downstream
118
+ automation typically gates application on it.
119
+ """
120
+
121
+ severity: str = Field(min_length=1)
122
+ category: str = Field(min_length=1)
123
+ message: str = Field(min_length=1)
124
+ patch: Patch
125
+ rationale: str = Field(min_length=1)
126
+ confidence: float = Field(ge=0.0, le=1.0)
127
+
128
+
129
+ class NarrativeFinding(_FindingBase):
130
+ """Long-form prose answer with citations.
131
+
132
+ Use for docs-Q&A, research summaries, explanatory output. `body`
133
+ is markdown; `references` is a flat list of pointer strings (free
134
+ form — typical shapes are `"path:line"`, URLs, or section anchors).
135
+ """
136
+
137
+ severity: str = Field(min_length=1)
138
+ category: str = Field(min_length=1)
139
+ message: str = Field(min_length=1)
140
+ body: str = Field(min_length=1)
141
+ references: list[str] = Field(default_factory=list)
142
+
143
+
144
+ class MultiSpanFinding(_FindingBase):
145
+ """One logical issue manifested across multiple source locations.
146
+
147
+ Use for cross-file findings like "hard-coded secret present in N
148
+ files" or "this deprecated API is used in N places". `spans` lists
149
+ every site; a renderer typically produces one block per span.
150
+ """
151
+
152
+ severity: str = Field(min_length=1)
153
+ category: str = Field(min_length=1)
154
+ message: str = Field(min_length=1)
155
+ spans: list[Span] = Field(min_length=1)
156
+ recommendation: str = ""
157
+
158
+
159
+ __all__ = [
160
+ "MultiSpanFinding",
161
+ "NarrativeFinding",
162
+ "Patch",
163
+ "PatchFinding",
164
+ "SimpleFinding",
165
+ "Span",
166
+ ]
@@ -0,0 +1,32 @@
1
+ """Built-in basic guardrail validators (feat-018).
2
+
3
+ Four validators ship in the runtime package as the default tier:
4
+
5
+ - `PromptInjectionBasic` (`prompt_injection_basic`) — regex-based
6
+ pattern matching for the common prompt-injection phrases.
7
+ - `PIIRedactBasic` (`pii_redact_basic`) — regex-based PII
8
+ detection + redaction (email / phone / SSN / credit-card /
9
+ IPv4). Output validator; sets `redacted_content`.
10
+ - `CapabilityCheck` (`capability_check`) — denies tools that
11
+ declare a `destructive` capability unless explicitly
12
+ allowlisted.
13
+ - `Allowlist` (`allowlist`) — bare-name allowlist for tool gates.
14
+
15
+ All four register themselves with the global Resolver under the
16
+ matching category (`guardrails.input` / `guardrails.output` /
17
+ `guardrails.tool_gates`) at import time.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from agentforge.guardrails.allowlist import Allowlist
23
+ from agentforge.guardrails.capability_check import CapabilityCheck
24
+ from agentforge.guardrails.pii_redact_basic import PIIRedactBasic
25
+ from agentforge.guardrails.prompt_injection_basic import PromptInjectionBasic
26
+
27
+ __all__ = [
28
+ "Allowlist",
29
+ "CapabilityCheck",
30
+ "PIIRedactBasic",
31
+ "PromptInjectionBasic",
32
+ ]
@@ -0,0 +1,49 @@
1
+ """`allowlist` — bare-name allowlist for tool dispatch (feat-018).
2
+
3
+ If `allowed` is set, only the tools whose names appear in it can
4
+ run. Pairs naturally with `capability_check` to add a second
5
+ restrictive layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, ClassVar
11
+
12
+ from agentforge_core.contracts.guardrails import ToolCallGate
13
+ from agentforge_core.contracts.tool import Tool
14
+ from agentforge_core.resolver import register
15
+ from agentforge_core.values.guardrails import ValidationResult
16
+
17
+
18
+ @register("guardrails.tool_gates", "allowlist")
19
+ class Allowlist(ToolCallGate):
20
+ """Only tools whose name appears in `allowed` can run."""
21
+
22
+ name: ClassVar[str] = "allowlist"
23
+ description: ClassVar[str] = (
24
+ "Permits only tools whose names appear in `allowed`. Everything else is blocked."
25
+ )
26
+ cost_estimate_ms: ClassVar[int] = 0
27
+
28
+ def __init__(self, *, allowed: list[str] | None = None) -> None:
29
+ self._allowed = set(allowed or ())
30
+
31
+ async def authorize(
32
+ self,
33
+ tool_name: str,
34
+ tool: Tool,
35
+ args: dict[str, Any],
36
+ context: dict[str, Any],
37
+ ) -> ValidationResult:
38
+ del tool, args, context
39
+ if tool_name in self._allowed:
40
+ return ValidationResult.ok()
41
+ return ValidationResult(
42
+ passed=False,
43
+ score=0.0,
44
+ violations=("not_in_allowlist",),
45
+ metadata={"tool": tool_name, "allowed": sorted(self._allowed)},
46
+ )
47
+
48
+
49
+ __all__ = ["Allowlist"]
@@ -0,0 +1,58 @@
1
+ """`capability_check` — denies tools tagged `destructive` unless
2
+ explicitly allowlisted (feat-018).
3
+
4
+ Tools declare their capabilities via the `Tool.capabilities`
5
+ ClassVar (feat-004). Any tool that includes `"destructive"` in
6
+ its capability set is blocked by this gate by default; the
7
+ agent's config can opt-in specific tools via the
8
+ `destructive_allow` list.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, ClassVar
14
+
15
+ from agentforge_core.contracts.guardrails import ToolCallGate
16
+ from agentforge_core.contracts.tool import Tool
17
+ from agentforge_core.resolver import register
18
+ from agentforge_core.values.guardrails import ValidationResult
19
+
20
+ _DESTRUCTIVE = "destructive"
21
+
22
+
23
+ @register("guardrails.tool_gates", "capability_check")
24
+ class CapabilityCheck(ToolCallGate):
25
+ """Block `destructive` tools unless explicitly allowlisted."""
26
+
27
+ name: ClassVar[str] = "capability_check"
28
+ description: ClassVar[str] = (
29
+ "Denies any tool whose capabilities include 'destructive' unless "
30
+ "the tool name appears in `destructive_allow`."
31
+ )
32
+ cost_estimate_ms: ClassVar[int] = 0
33
+
34
+ def __init__(self, *, destructive_allow: list[str] | None = None) -> None:
35
+ self._allow = set(destructive_allow or ())
36
+
37
+ async def authorize(
38
+ self,
39
+ tool_name: str,
40
+ tool: Tool,
41
+ args: dict[str, Any],
42
+ context: dict[str, Any],
43
+ ) -> ValidationResult:
44
+ del args, context
45
+ caps = set(type(tool).capabilities)
46
+ if _DESTRUCTIVE not in caps:
47
+ return ValidationResult.ok()
48
+ if tool_name in self._allow:
49
+ return ValidationResult.ok()
50
+ return ValidationResult(
51
+ passed=False,
52
+ score=0.0,
53
+ violations=("destructive_not_allowlisted",),
54
+ metadata={"tool": tool_name, "capabilities": sorted(caps)},
55
+ )
56
+
57
+
58
+ __all__ = ["CapabilityCheck"]