agentltl-coding 0.2.4__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.
- agentltl_coding/__init__.py +21 -0
- agentltl_coding/cli.py +482 -0
- agentltl_coding/edit.py +67 -0
- agentltl_coding/guard.py +461 -0
- agentltl_coding/harness.py +110 -0
- agentltl_coding/library/ask-before-creating-credentials.yaml +9 -0
- agentltl_coding/library/ask-before-discarding-work.yaml +12 -0
- agentltl_coding/library/ask-before-infra-changes.yaml +9 -0
- agentltl_coding/library/ask-before-installing.yaml +12 -0
- agentltl_coding/library/ask-before-reading-secret-stores.yaml +35 -0
- agentltl_coding/library/ask-before-recursive-delete.yaml +7 -0
- agentltl_coding/library/devops-secrets.yaml +10 -0
- agentltl_coding/library/no-claude-coauthor.yaml +11 -0
- agentltl_coding/library/no-codex-coauthor.yaml +11 -0
- agentltl_coding/library/no-copilot-coauthor.yaml +11 -0
- agentltl_coding/library/no-env-dumps.yaml +18 -0
- agentltl_coding/library/no-force-push.yaml +7 -0
- agentltl_coding/library/no-leaky-debug.yaml +13 -0
- agentltl_coding/library/no-push-to-main.yaml +7 -0
- agentltl_coding/library/no-secrets-in-commands.yaml +21 -0
- agentltl_coding/library/no-skipping-secret-scans.yaml +9 -0
- agentltl_coding/library/no-vibe-coauthor.yaml +11 -0
- agentltl_coding/library/protect-env-files.yaml +9 -0
- agentltl_coding/library/protect-secret-files.yaml +53 -0
- agentltl_coding/library/read-before-overwrite.yaml +11 -0
- agentltl_coding/library/subagents-on-sonnet.yaml +12 -0
- agentltl_coding/library/tests-before-finishing.yaml +13 -0
- agentltl_coding/library/tests-before-push.yaml +14 -0
- agentltl_coding/memory.py +390 -0
- agentltl_coding/pattern.py +291 -0
- agentltl_coding/rules.py +920 -0
- agentltl_coding/scan.py +54 -0
- agentltl_coding/session.py +149 -0
- agentltl_coding/store.py +123 -0
- agentltl_coding-0.2.4.dist-info/METADATA +129 -0
- agentltl_coding-0.2.4.dist-info/RECORD +40 -0
- agentltl_coding-0.2.4.dist-info/WHEEL +5 -0
- agentltl_coding-0.2.4.dist-info/entry_points.txt +2 -0
- agentltl_coding-0.2.4.dist-info/licenses/LICENSE +21 -0
- agentltl_coding-0.2.4.dist-info/top_level.txt +1 -0
agentltl_coding/guard.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/guard.py – decide one coding-agent tool call against the rules.
|
|
3
|
+
|
|
4
|
+
guard = Guard(ruleset, Paths(cwd, root))
|
|
5
|
+
guard.restore(session_state, project_state) # traces and enforcer state
|
|
6
|
+
verdict = guard.decide("Bash", {"command": "git push"}, auto=False)
|
|
7
|
+
verdict.action # "none" | "deny" | "ask" | "stop"
|
|
8
|
+
...after the call ran...
|
|
9
|
+
guard.record("Bash", {"command": "pytest"}, "toolu_1", "3 passed", status=0)
|
|
10
|
+
session_state, project_state = guard.dump(), guard.dump_project()
|
|
11
|
+
|
|
12
|
+
Each rule reads one memory (its ``scope``): the session trace (calls made in this session)
|
|
13
|
+
or the project trace (every call made in this project, across sessions). Both record every
|
|
14
|
+
call that runs; each scope has its own AgentLTL enforcer, and the stricter verdict wins.
|
|
15
|
+
|
|
16
|
+
Shell tools (see :class:`agentltl_coding.Harness`) are translated by cli-to-tools into the
|
|
17
|
+
structured calls of the command line (``git commit -m x && git push`` → ``git_commit``,
|
|
18
|
+
``git_push``) and checked all or nothing. Every other tool is checked under its own name
|
|
19
|
+
with its input as arguments.
|
|
20
|
+
|
|
21
|
+
The guard never approves anything: "none" means it has no objection and the harness's own
|
|
22
|
+
permission flow still decides.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Dict, List, NamedTuple, Optional
|
|
29
|
+
|
|
30
|
+
from cli_to_tools import SpecRegistry, ToolCall, TranslationError, Translator
|
|
31
|
+
from cli_to_tools.agentltl import ShellEnforcer
|
|
32
|
+
|
|
33
|
+
from .harness import current as _harness
|
|
34
|
+
from .pattern import PATH_KEYS, Paths, normalize_paths
|
|
35
|
+
from .rules import MODES, SCOPES, Rule, RuleSet
|
|
36
|
+
|
|
37
|
+
_MAX_TRACE = 5000
|
|
38
|
+
_MAX_LOG = 200
|
|
39
|
+
_MAX_STRING = 2000
|
|
40
|
+
_RANK = {"none": 0, "ask": 1, "deny": 2, "stop": 3}
|
|
41
|
+
|
|
42
|
+
_TAILS = {
|
|
43
|
+
"block": "This rule cannot be overridden by you. Do something that satisfies it instead, "
|
|
44
|
+
"or explain the situation to the user.",
|
|
45
|
+
"warn": "This is a warning. If you are sure the call is right, you may override the rule by "
|
|
46
|
+
"repeating exactly the same call as your next action; otherwise comply.",
|
|
47
|
+
"retry": "Change your approach to satisfy the rule. If you keep getting blocked, the user "
|
|
48
|
+
"will be asked to decide.",
|
|
49
|
+
"stop": "This rule stops the session: every tool call is refused until the user replies. "
|
|
50
|
+
"Don't try another command. Tell the user what you were trying to do and why, then "
|
|
51
|
+
"end your turn.",
|
|
52
|
+
}
|
|
53
|
+
# A `stop` rule that only *may* match (a file known at run time) refuses the call instead.
|
|
54
|
+
_MAYBE_STOP = ("This rule stops the session when it is broken. This call was refused because its "
|
|
55
|
+
"files can't be known in advance: run the command that finds them first, then "
|
|
56
|
+
"name them explicitly. If one of them is a file this rule protects, don't touch "
|
|
57
|
+
"it: tell the user.")
|
|
58
|
+
# Told to the agent alongside a prompt the user answers (mode: ask, or retry escalating).
|
|
59
|
+
_ASKED = ("AGENTLTL rule '{name}' asked the user to approve this call. If they decline, do not "
|
|
60
|
+
"retry it or work around the rule; ask the user what they want instead.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Verdict:
|
|
65
|
+
"""What the hook should tell Claude Code.
|
|
66
|
+
|
|
67
|
+
action: "none" (no objection), "deny", "ask" (the user decides) or "stop" (deny and halt).
|
|
68
|
+
reason: Shown with deny/ask/stop. context: extra text for Claude, whatever the action.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
action: str = "none"
|
|
72
|
+
reason: str = ""
|
|
73
|
+
context: str = ""
|
|
74
|
+
rule: Optional[str] = None
|
|
75
|
+
calls: List[Dict[str, Any]] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Call(NamedTuple):
|
|
79
|
+
name: str
|
|
80
|
+
args: Dict[str, Any]
|
|
81
|
+
raw: Dict[str, Any] = {} # the recorded entry, for a call that ran
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def translator_for(ruleset: RuleSet, paths: Optional[Paths] = None) -> Translator:
|
|
85
|
+
"""cli-to-tools with every bundled pack, the rule files' own ``tools:`` specs (which
|
|
86
|
+
extend the bundled spec of the same command unless they say ``extend: false``), and the
|
|
87
|
+
shell effects file rules need (redirections, paths after ``cd``, run-time-only files)."""
|
|
88
|
+
registry = SpecRegistry()
|
|
89
|
+
if ruleset.tool_specs:
|
|
90
|
+
registry.load_dict(ruleset.tool_specs, extend=True)
|
|
91
|
+
return Translator(registry, shell_effects=True, paths=paths)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# runners whose subcommands cli-to-tools does not split into tools (`make test` -> make, argv)
|
|
95
|
+
_RUNNERS = ("make", "npm", "yarn", "pnpm", "npx", "cargo", "go", "uv", "poetry", "just", "bun")
|
|
96
|
+
_ANY_TOOL_ARGS = ("*", "redirect_to", "overwrite_to", "redirect_from", "extra_args")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lint(ruleset: RuleSet, registry: Optional[SpecRegistry] = None) -> List[str]:
|
|
100
|
+
"""Warnings for targets that can never match: unknown tools or argument names.
|
|
101
|
+
|
|
102
|
+
The rule file is valid without them; these catch the rule that silently never fires
|
|
103
|
+
(``with: {repository: origin}`` on ``git_push``, whose argument is ``remote``) or, for
|
|
104
|
+
``require``, fires on every call.
|
|
105
|
+
"""
|
|
106
|
+
registry = registry or translator_for(ruleset).registry
|
|
107
|
+
props = {s["name"]: s.get("parameters", {}).get("properties", {})
|
|
108
|
+
for s in registry.tool_schemas()}
|
|
109
|
+
schemas = {name: set(p) for name, p in props.items()}
|
|
110
|
+
out: List[str] = []
|
|
111
|
+
for rule in ruleset.rules:
|
|
112
|
+
if rule.kind in ("ltl", "formula"):
|
|
113
|
+
for tool in rule.tools:
|
|
114
|
+
if tool[:1].isupper() or tool.startswith("mcp__") or "*" in tool:
|
|
115
|
+
continue
|
|
116
|
+
if tool in schemas or registry.get(tool) is not None:
|
|
117
|
+
continue
|
|
118
|
+
import difflib
|
|
119
|
+
close = difflib.get_close_matches(tool, list(schemas), n=1)
|
|
120
|
+
hint = f" Did you mean '{close[0]}'?" if close else ""
|
|
121
|
+
if "_" in tool and registry.get(tool.split("_", 1)[0]) is not None:
|
|
122
|
+
out.append(f"{rule.id}: '{tool}' is not a declared subcommand, so only "
|
|
123
|
+
f"`{tool.replace('_', ' ', 1)}` would produce it.{hint}")
|
|
124
|
+
else:
|
|
125
|
+
out.append(f"{rule.id}: no command translates to '{tool}', so the formula "
|
|
126
|
+
f"never sees it.{hint}")
|
|
127
|
+
continue
|
|
128
|
+
for target in _flatten(rule.targets):
|
|
129
|
+
keys = set(target.with_) | set(target.where) | set(target.variables)
|
|
130
|
+
for tool in target.tools:
|
|
131
|
+
if tool[:1].isupper() or tool.startswith("mcp__") or "*" in tool:
|
|
132
|
+
continue
|
|
133
|
+
if tool in schemas:
|
|
134
|
+
unknown = sorted(k for k in keys - set(_ANY_TOOL_ARGS) if k not in schemas[tool])
|
|
135
|
+
if unknown:
|
|
136
|
+
out.append(f"{rule.id}: {tool} has no argument(s) {unknown}; it has "
|
|
137
|
+
f"{sorted(schemas[tool])}")
|
|
138
|
+
continue
|
|
139
|
+
prefix = tool.split("_", 1)[0]
|
|
140
|
+
if "_" in tool and registry.get(prefix) is not None:
|
|
141
|
+
# a subcommand the spec does not declare (`git notes` -> git_notes) keeps
|
|
142
|
+
# its words in `argv`
|
|
143
|
+
unknown = sorted(keys - set(_ANY_TOOL_ARGS) - {"argv"})
|
|
144
|
+
if unknown:
|
|
145
|
+
out.append(f"{rule.id}: {tool} is not a declared subcommand, so its "
|
|
146
|
+
f"only argument is 'argv'; {unknown} never match.")
|
|
147
|
+
elif "_" in tool and prefix in _RUNNERS:
|
|
148
|
+
out.append(f"{rule.id}: no command translates to '{tool}'. Check with "
|
|
149
|
+
f"`agentltl translate \"{tool.replace('_', ' ', 1)}\"`; "
|
|
150
|
+
f"a command without a spec is '{prefix}' with an 'argv' list, "
|
|
151
|
+
f"e.g. {{tool: {prefix}, with: {{argv: ...}}}}")
|
|
152
|
+
elif registry.get(tool) is None:
|
|
153
|
+
unknown = sorted(keys - set(_ANY_TOOL_ARGS) - {"argv"})
|
|
154
|
+
if unknown:
|
|
155
|
+
out.append(f"{rule.id}: '{tool}' has no spec, so its only argument is "
|
|
156
|
+
f"'argv'; {unknown} never match. Add a spec under 'tools:'.")
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _flatten(targets: Any) -> List[Any]:
|
|
161
|
+
out: List[Any] = []
|
|
162
|
+
for t in targets:
|
|
163
|
+
out += _flatten(t.targets) if hasattr(t, "targets") else [t]
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_auto(permission_mode: Optional[str]) -> bool:
|
|
168
|
+
"""Whether the harness's *permission_mode* is one in which nobody answers a prompt."""
|
|
169
|
+
return permission_mode in _harness().auto_modes
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Guard:
|
|
173
|
+
def __init__(self, ruleset: RuleSet, paths: Optional[Paths] = None) -> None:
|
|
174
|
+
from agentltl import ConstraintSeverity
|
|
175
|
+
|
|
176
|
+
import os
|
|
177
|
+
self.ruleset = ruleset
|
|
178
|
+
self.paths = paths or Paths(os.getcwd())
|
|
179
|
+
self.translator = translator_for(ruleset, self.paths)
|
|
180
|
+
s = ruleset.settings
|
|
181
|
+
self.enforcers = {
|
|
182
|
+
scope: ShellEnforcer(
|
|
183
|
+
ruleset.constraints(scope), ruleset.severities(scope),
|
|
184
|
+
default_severity=ConstraintSeverity.PERSISTENT_BLOCK,
|
|
185
|
+
max_soft_attempts=s.retries, soft_block_mode=s.retry_counting,
|
|
186
|
+
escalate_to=ConstraintSeverity.ASK, nudge_max=s.report,
|
|
187
|
+
max_termination_nudges=s.finish_retries,
|
|
188
|
+
translator=self.translator, shell_tools=_harness().shell_tools,
|
|
189
|
+
)
|
|
190
|
+
for scope in SCOPES
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def shell_tools(self) -> Dict[str, str]:
|
|
195
|
+
return _harness().shell_tools
|
|
196
|
+
|
|
197
|
+
# ── state ─────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def restore(self, session: Optional[Dict[str, Any]],
|
|
200
|
+
project: Optional[Dict[str, Any]] = None) -> None:
|
|
201
|
+
for scope, state in (("session", session), ("project", project)):
|
|
202
|
+
self.enforcers[scope].from_state(_upgrade(state or {}))
|
|
203
|
+
|
|
204
|
+
def dump(self, scope: str = "session") -> Dict[str, Any]:
|
|
205
|
+
return self.enforcers[scope].to_state(max_trace=_MAX_TRACE, max_log=_MAX_LOG)
|
|
206
|
+
|
|
207
|
+
def dump_project(self) -> Dict[str, Any]:
|
|
208
|
+
return self.dump("project")
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def trace(self) -> List[Dict[str, Any]]:
|
|
212
|
+
return self.enforcers["session"].trace
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def project_trace(self) -> List[Dict[str, Any]]:
|
|
216
|
+
return self.enforcers["project"].trace
|
|
217
|
+
|
|
218
|
+
# ── deciding ──────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def translate(self, tool_name: str, tool_input: Dict[str, Any]) -> List[ToolCall]:
|
|
221
|
+
"""The structured calls a tool call (canonical names) stands for.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
TranslationError: For a shell command line cli-to-tools cannot analyse.
|
|
225
|
+
"""
|
|
226
|
+
if tool_name in self.shell_tools:
|
|
227
|
+
return self.translator.translate((tool_input or {}).get(self.shell_tools[tool_name]) or "")
|
|
228
|
+
return [ToolCall(tool_name, self._input(tool_name, tool_input), "", {})]
|
|
229
|
+
|
|
230
|
+
def _input(self, tool_name: str, tool_input: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
231
|
+
"""The arguments rules see: path arguments of non-shell tools made absolute."""
|
|
232
|
+
tool_input = dict(tool_input or {})
|
|
233
|
+
return tool_input if tool_name in self.shell_tools else normalize_paths(
|
|
234
|
+
tool_input, self.paths, PATH_KEYS)
|
|
235
|
+
|
|
236
|
+
def decide(self, tool_name: str, tool_input: Dict[str, Any], *, auto: bool = False) -> Verdict:
|
|
237
|
+
if not self.ruleset.rules:
|
|
238
|
+
return Verdict()
|
|
239
|
+
tool_name, tool_input = _harness().canonical(tool_name, tool_input)
|
|
240
|
+
try:
|
|
241
|
+
calls = self.translate(tool_name, tool_input)
|
|
242
|
+
except TranslationError as exc:
|
|
243
|
+
return self._unparseable(tool_input.get("command", ""), exc, auto)
|
|
244
|
+
shown = [{"tool_name": c.name, "arguments": c.args} for c in calls]
|
|
245
|
+
verdicts = [self._decide_in(scope, tool_name, tool_input, calls, shown)
|
|
246
|
+
for scope in SCOPES if self.ruleset.scoped(scope)]
|
|
247
|
+
verdict = max(verdicts, key=lambda v: _RANK[v.action])
|
|
248
|
+
verdict.context = "\n".join(v.context for v in verdicts if v.context)
|
|
249
|
+
return verdict
|
|
250
|
+
|
|
251
|
+
def _decide_in(self, scope: str, tool_name: str, tool_input: Dict[str, Any],
|
|
252
|
+
calls: List[ToolCall], shown: List[Dict[str, Any]]) -> Verdict:
|
|
253
|
+
enf = self.enforcers[scope]
|
|
254
|
+
enf.begin_generation() # each call the harness reports is its own turn
|
|
255
|
+
d = enf.check(tool_name, self._input(tool_name, tool_input))
|
|
256
|
+
notes = self._tolerated(d.notes, enf, calls)
|
|
257
|
+
if d.allowed:
|
|
258
|
+
return Verdict("none", context=notes, calls=shown)
|
|
259
|
+
v = d.violation
|
|
260
|
+
name = v.constraint_name if v else ""
|
|
261
|
+
rule = self.ruleset.get(name)
|
|
262
|
+
index = d.index if d.index is not None else len(calls) - 1
|
|
263
|
+
detail = self._explain(rule, enf, calls, index) or _plain(v.detail if v else "")
|
|
264
|
+
segment = (calls[index].meta or {}).get("source", "") if calls else ""
|
|
265
|
+
agent = _harness().Agent
|
|
266
|
+
if d.action == "stop":
|
|
267
|
+
if v is not None and v.uncertain:
|
|
268
|
+
# Only a possible match (a file known at run time): refuse the call, but don't
|
|
269
|
+
# stop the session over what naming the files would settle.
|
|
270
|
+
return Verdict("deny", self._message(rule, name, detail, segment, mode="block",
|
|
271
|
+
tail=_MAYBE_STOP), rule=name, calls=shown)
|
|
272
|
+
return Verdict("stop", self._message(rule, name, detail, segment, mode="stop"),
|
|
273
|
+
rule=name, calls=shown)
|
|
274
|
+
if d.action == "ask":
|
|
275
|
+
intro = (f"{agent} was refused {d.attempts - 1} time(s) by this rule and is trying "
|
|
276
|
+
"again." if d.escalated else "")
|
|
277
|
+
reason = self._ask_message(rule, name, detail, segment, intro=intro)
|
|
278
|
+
context = "\n".join(n for n in (_ASKED.format(name=name), notes) if n)
|
|
279
|
+
return Verdict("ask", reason, context=context, rule=name, calls=shown)
|
|
280
|
+
tail = None
|
|
281
|
+
if d.action == "retry":
|
|
282
|
+
tail = f"Attempt {d.attempts} of {self.ruleset.settings.retries}. " + _TAILS["retry"]
|
|
283
|
+
mode = rule.mode if rule else "block"
|
|
284
|
+
reason = self._message(rule, name, detail, segment, mode=mode, tail=tail)
|
|
285
|
+
also = self._also(d.violations[1:], enf, calls, index)
|
|
286
|
+
if also:
|
|
287
|
+
reason = reason + "\n" + also
|
|
288
|
+
return Verdict("deny", reason, context=notes, rule=name, calls=shown)
|
|
289
|
+
|
|
290
|
+
# ── finishing ─────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def finish(self) -> Verdict:
|
|
293
|
+
"""The agent is about to finish its turn. "block" (with the message to send it back)
|
|
294
|
+
while a ``finally`` rule is unmet, at most ``finish_retries`` times per turn."""
|
|
295
|
+
lines: List[str] = []
|
|
296
|
+
names: List[str] = []
|
|
297
|
+
for scope in SCOPES:
|
|
298
|
+
enf = self.enforcers[scope]
|
|
299
|
+
if not any(r.kind == "finally" for r in self.ruleset.scoped(scope)):
|
|
300
|
+
continue
|
|
301
|
+
d = enf.check_termination()
|
|
302
|
+
for v in d.violations:
|
|
303
|
+
rule = self.ruleset.get(v.constraint_name)
|
|
304
|
+
history = [_Call(e.get("tool_name", ""), e.get("arguments") or {}, e)
|
|
305
|
+
for e in enf.trace]
|
|
306
|
+
detail = (rule.explain(history) if rule and rule.explain else None) or v.detail
|
|
307
|
+
names.append(v.constraint_name)
|
|
308
|
+
line = f"- {v.constraint_name}: {detail}"
|
|
309
|
+
if rule and rule.why:
|
|
310
|
+
line += f" ({rule.why})"
|
|
311
|
+
if rule and rule.fix:
|
|
312
|
+
line += f" To comply: {rule.fix}"
|
|
313
|
+
lines.append(line)
|
|
314
|
+
if not lines:
|
|
315
|
+
return Verdict()
|
|
316
|
+
reason = ("[AGENTLTL] Before you finish, these rules need something done first:\n"
|
|
317
|
+
+ "\n".join(lines)
|
|
318
|
+
+ "\nDo it now, then finish. If a rule genuinely does not apply here, say "
|
|
319
|
+
"why in your answer.")
|
|
320
|
+
return Verdict("block", reason, rule=names[0])
|
|
321
|
+
|
|
322
|
+
def new_turn(self) -> None:
|
|
323
|
+
"""A new user message: ``finally`` rules may send the agent back again."""
|
|
324
|
+
for enf in self.enforcers.values():
|
|
325
|
+
enf.reset_termination_nudges()
|
|
326
|
+
|
|
327
|
+
def _history(self, enf: ShellEnforcer, calls: List[ToolCall], index: int) -> List[_Call]:
|
|
328
|
+
"""The calls up to the one at *index* of this command line, for explanations."""
|
|
329
|
+
past = [_Call(e.get("tool_name", ""), e.get("arguments") or {}, e) for e in enf.trace]
|
|
330
|
+
return past + [_Call(c.name, c.args) for c in calls[:index + 1]]
|
|
331
|
+
|
|
332
|
+
def _explain(self, rule: Optional[Rule], enf: ShellEnforcer, calls: List[ToolCall],
|
|
333
|
+
index: int) -> Optional[str]:
|
|
334
|
+
if rule is None or rule.explain is None or not calls:
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
return rule.explain(self._history(enf, calls, index))
|
|
338
|
+
except Exception: # an explanation must never turn a refusal into a crash
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def _also(self, others: List[Any], enf: ShellEnforcer, calls: List[ToolCall],
|
|
342
|
+
index: int) -> str:
|
|
343
|
+
"""Further rules the same call breaks, reported with the first (settings: report)."""
|
|
344
|
+
lines = []
|
|
345
|
+
for v in others:
|
|
346
|
+
rule = self.ruleset.get(v.constraint_name)
|
|
347
|
+
detail = self._explain(rule, enf, calls, index) or _plain(v.detail)
|
|
348
|
+
lines.append(f"This call also breaks rule '{v.constraint_name}': {detail}"
|
|
349
|
+
+ (f" To comply: {rule.fix}" if rule and rule.fix else ""))
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
def record(self, tool_name: str, tool_input: Dict[str, Any], tool_id: str,
|
|
353
|
+
result: Any, status: Optional[int] = None) -> None:
|
|
354
|
+
"""Add a call that has run to the session and project traces."""
|
|
355
|
+
tool_name, tool_input = _harness().canonical(tool_name, tool_input)
|
|
356
|
+
tool_input = _trim(self._input(tool_name, tool_input))
|
|
357
|
+
text = result if isinstance(result, str) else ("" if result is None else str(result))
|
|
358
|
+
text = text[:_MAX_STRING]
|
|
359
|
+
for enf in self.enforcers.values():
|
|
360
|
+
try:
|
|
361
|
+
enf.record_completed(tool_name, tool_input, tool_id, text, status=status)
|
|
362
|
+
except TranslationError:
|
|
363
|
+
# an unparseable command the user let through: kept under the tool's own name
|
|
364
|
+
entry = {"tool_name": tool_name, "arguments": tool_input, "id": tool_id,
|
|
365
|
+
"result": text}
|
|
366
|
+
if status is not None:
|
|
367
|
+
entry["status"] = status
|
|
368
|
+
enf.record_entry(entry)
|
|
369
|
+
|
|
370
|
+
# ── messages ──────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
def _unparseable(self, command: str, exc: Exception, auto: bool) -> Verdict:
|
|
373
|
+
s = self.ruleset.settings
|
|
374
|
+
how = s.unparseable_auto if auto else s.unparseable_interactive
|
|
375
|
+
if how == "allow":
|
|
376
|
+
return Verdict()
|
|
377
|
+
related = [r.id for r in self.ruleset.rules if _shell_side(r)]
|
|
378
|
+
text = (
|
|
379
|
+
"This command could not be checked against the project's AGENTLTL rules: "
|
|
380
|
+
f"{exc}. "
|
|
381
|
+
+ (f"Rules it could fall under: {', '.join(related)}. " if related else "")
|
|
382
|
+
+ "To have it checked, write it as plain commands joined with ;, &&, || or | "
|
|
383
|
+
"(no eval, background jobs, function definitions or commands named by variables)."
|
|
384
|
+
)
|
|
385
|
+
if how == "ask":
|
|
386
|
+
return Verdict("ask", "[AGENTLTL: not checked] " + text)
|
|
387
|
+
if how == "deny":
|
|
388
|
+
return Verdict("deny", "[AGENTLTL: not checked, denied] " + text)
|
|
389
|
+
return Verdict("none", context="[AGENTLTL: not checked] " + text)
|
|
390
|
+
|
|
391
|
+
def _tolerated(self, notes: List[Any], enf: ShellEnforcer, calls: List[ToolCall]) -> str:
|
|
392
|
+
lines = []
|
|
393
|
+
for v in notes:
|
|
394
|
+
rule = self.ruleset.get(v.constraint_name)
|
|
395
|
+
why = f" ({rule.why})" if rule and rule.why else ""
|
|
396
|
+
detail = self._explain(rule, enf, calls, len(calls) - 1) or _plain(v.detail)
|
|
397
|
+
lines.append(f"Note: this call breaks AGENTLTL rule '{v.constraint_name}'{why}: {detail}")
|
|
398
|
+
return "\n".join(dict.fromkeys(lines))
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _ask_message(rule: Optional[Rule], name: str, detail: str, segment: str,
|
|
402
|
+
intro: str = "") -> str:
|
|
403
|
+
"""The permission prompt the user reads: what the rule protects, and the question."""
|
|
404
|
+
lines = [f"AgentLTL: this call breaks the rule '{name}'.", intro]
|
|
405
|
+
if rule and rule.why:
|
|
406
|
+
lines.append(f"Why the rule exists: {rule.why}")
|
|
407
|
+
if detail:
|
|
408
|
+
lines.append(f"What breaks it: {detail}")
|
|
409
|
+
if segment:
|
|
410
|
+
lines.append(f"Command: {segment}")
|
|
411
|
+
lines.append(f"{_harness().Agent} cannot override this rule. Allow this call anyway?")
|
|
412
|
+
return "\n".join(line for line in lines if line)
|
|
413
|
+
|
|
414
|
+
def _message(self, rule: Optional[Rule], name: str, detail: str, segment: str, *,
|
|
415
|
+
mode: Optional[str] = None, tail: Optional[str] = None) -> str:
|
|
416
|
+
mode = mode or (rule.mode if rule else "block")
|
|
417
|
+
lines = [f"[AGENTLTL] Rule '{name}' blocked this call ({mode}). Nothing was executed."]
|
|
418
|
+
if rule and rule.why:
|
|
419
|
+
lines.append(f"Rule: {rule.why}")
|
|
420
|
+
if detail:
|
|
421
|
+
lines.append(f"Problem: {detail}")
|
|
422
|
+
if segment:
|
|
423
|
+
lines.append(f"Blocked at: {segment}")
|
|
424
|
+
if rule and rule.fix:
|
|
425
|
+
lines.append(f"To comply: {rule.fix}")
|
|
426
|
+
lines.append(tail or _TAILS.get(mode, ""))
|
|
427
|
+
return "\n".join(line for line in lines if line)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _plain(detail: str) -> str:
|
|
431
|
+
"""AgentLTL's explanation, tidied for a person to read."""
|
|
432
|
+
return str(detail or "").strip()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _upgrade(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
436
|
+
"""State saved before AgentLTL 0.2 (``trace`` plus private ``engine`` fields), in
|
|
437
|
+
:meth:`agentltl.Enforcer.to_state` form."""
|
|
438
|
+
if "engine" not in state and "trace" not in state:
|
|
439
|
+
return state
|
|
440
|
+
out = {name.lstrip("_"): value for name, value in (state.get("engine") or {}).items()
|
|
441
|
+
if name != "_blocked_command"}
|
|
442
|
+
out["completed_tool_calls"] = list(state.get("trace") or [])
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _shell_side(rule: Rule) -> bool:
|
|
447
|
+
"""Whether a rule mentions any tool a shell command can turn into."""
|
|
448
|
+
return any(not (t[:1].isupper() or t.startswith("mcp__")) for t in rule.tools) or not rule.tools
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _trim(value: Any) -> Any:
|
|
452
|
+
if isinstance(value, str):
|
|
453
|
+
return value if len(value) <= _MAX_STRING else value[:_MAX_STRING] + "…"
|
|
454
|
+
if isinstance(value, dict):
|
|
455
|
+
return {k: _trim(v) for k, v in value.items()}
|
|
456
|
+
if isinstance(value, list):
|
|
457
|
+
return [_trim(v) for v in value]
|
|
458
|
+
return value
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
__all__ = ["Guard", "Verdict", "is_auto", "lint", "translator_for", "MODES"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/harness.py – what differs between coding agents.
|
|
3
|
+
|
|
4
|
+
The rule language, the library, the guard and its state are the same for every coding
|
|
5
|
+
agent. A harness (the Claude Code plugin, the GitHub Copilot CLI plugin, ...) describes the
|
|
6
|
+
rest once, at start-up::
|
|
7
|
+
|
|
8
|
+
from agentltl_coding import Harness, configure
|
|
9
|
+
|
|
10
|
+
configure(Harness(name="claude-code", agent="Claude", user_dir="~/.claude",
|
|
11
|
+
shell_tools={"Bash": "command"}, builtins={"memory_first": MEMORY_FIRST}))
|
|
12
|
+
|
|
13
|
+
Rules name tools by their canonical names, Claude Code's (``Bash``, ``Write``, ``Edit``,
|
|
14
|
+
``Read``, ...), so the library works in every harness. A harness whose agent calls them
|
|
15
|
+
something else maps its names with ``tool_aliases``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
# native tool name -> (canonical tool name, {native argument: canonical argument})
|
|
25
|
+
Aliases = Dict[str, Tuple[str, Dict[str, str]]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Harness:
|
|
30
|
+
"""
|
|
31
|
+
Attributes:
|
|
32
|
+
name: Short name, used for the default state directory and the library's
|
|
33
|
+
``harnesses:`` lists.
|
|
34
|
+
agent: Who makes the calls, as messages name it ("Claude", "the agent").
|
|
35
|
+
user_dir: Where the user-level AGENTLTL.yaml lives (rules for every project).
|
|
36
|
+
shell_tools: Tool name -> the argument holding a shell command line. Those calls
|
|
37
|
+
are checked as the structured calls of the line (cli-to-tools). Names are
|
|
38
|
+
canonical (after ``tool_aliases``).
|
|
39
|
+
tool_aliases: The agent's own tool names mapped onto the canonical ones, with their
|
|
40
|
+
argument names (Copilot's ``create {path, file_text}`` is ``Write {file_path,
|
|
41
|
+
content}``). Arguments not listed keep their names.
|
|
42
|
+
builtins: Rules the harness switches on unless a setting of the same name is
|
|
43
|
+
false (``memory_first: false``) or the rule id is disabled or replaced.
|
|
44
|
+
state_dir: Where session and project state live; None: the default for *name*.
|
|
45
|
+
auto_modes: Permission modes in which nobody answers a prompt, so ``unparseable``
|
|
46
|
+
uses its ``auto`` setting.
|
|
47
|
+
project_env: An environment variable naming the project directory, if the agent
|
|
48
|
+
sets one; otherwise the project is the git root of the working directory.
|
|
49
|
+
skill: How the agent's user invokes one of the plugin's skills, ``{}`` standing for
|
|
50
|
+
its name ("/agentltl:{}" in Claude Code).
|
|
51
|
+
memory: Finds the memory files that apply (see :mod:`agentltl_coding.memory`):
|
|
52
|
+
``memory(root, user_only, home) -> [Source]``. None: the harness has no memory
|
|
53
|
+
import.
|
|
54
|
+
memory_note: What the session-start reminder says about the ``memory-first`` rule.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str = "agent"
|
|
58
|
+
agent: str = "the agent"
|
|
59
|
+
user_dir: str = "~/.agentltl"
|
|
60
|
+
shell_tools: Dict[str, str] = field(default_factory=lambda: {"Bash": "command",
|
|
61
|
+
"bash": "command"})
|
|
62
|
+
tool_aliases: Aliases = field(default_factory=dict)
|
|
63
|
+
builtins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
64
|
+
state_dir: Optional[str] = None
|
|
65
|
+
auto_modes: Tuple[str, ...] = ()
|
|
66
|
+
project_env: Optional[str] = None
|
|
67
|
+
skill: str = "/agentltl-{}"
|
|
68
|
+
memory: Optional[Callable[[str, bool, Optional[str]], List[Any]]] = None
|
|
69
|
+
memory_note: str = ""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def Agent(self) -> str:
|
|
73
|
+
"""*agent* at the start of a sentence."""
|
|
74
|
+
return self.agent[:1].upper() + self.agent[1:]
|
|
75
|
+
|
|
76
|
+
def user_file(self) -> str:
|
|
77
|
+
return os.path.join(os.path.expanduser(self.user_dir), "AGENTLTL.yaml")
|
|
78
|
+
|
|
79
|
+
def skill_name(self, name: str) -> str:
|
|
80
|
+
return self.skill.format(name)
|
|
81
|
+
|
|
82
|
+
def canonical(self, tool: str, tool_input: Optional[Dict[str, Any]]
|
|
83
|
+
) -> Tuple[str, Dict[str, Any]]:
|
|
84
|
+
"""*tool* and its input under the canonical names rules use."""
|
|
85
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
86
|
+
if tool not in self.tool_aliases:
|
|
87
|
+
return tool, tool_input
|
|
88
|
+
name, keys = self.tool_aliases[tool]
|
|
89
|
+
return name, {keys.get(k, k): v for k, v in tool_input.items()}
|
|
90
|
+
|
|
91
|
+
def matches(self, harnesses: Any) -> bool:
|
|
92
|
+
"""Whether a library entry's ``harnesses:`` list (None: every harness) includes this
|
|
93
|
+
one."""
|
|
94
|
+
return not harnesses or self.name in harnesses
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_current = Harness()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def configure(harness: Harness) -> None:
|
|
101
|
+
"""Set the harness for this process (call once, before loading rules)."""
|
|
102
|
+
global _current
|
|
103
|
+
_current = harness
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def current() -> Harness:
|
|
107
|
+
return _current
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["Aliases", "Harness", "configure", "current"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
summary: Ask before minting new credentials (access keys, service-account keys, tokens, service principals).
|
|
2
|
+
tags: [secrets, devops]
|
|
3
|
+
rules:
|
|
4
|
+
- id: ask-before-creating-credentials
|
|
5
|
+
never: [aws_iam_create_access_key, aws_iam_create_login_profile, aws_sts_get_session_token,
|
|
6
|
+
aws_sts_assume_role, gcloud_iam_service_accounts_keys_create, az_ad_sp_create_for_rbac,
|
|
7
|
+
az_ad_sp_credential_reset, vault_token_create, gh_auth_refresh]
|
|
8
|
+
why: New credentials are printed once and stay valid; the user should know they exist and decide where they go.
|
|
9
|
+
mode: ask
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
summary: Ask before git commands that throw away uncommitted or unpushed work.
|
|
2
|
+
tags: [git]
|
|
3
|
+
rules:
|
|
4
|
+
- id: ask-before-discarding-work
|
|
5
|
+
never:
|
|
6
|
+
- {tool: git_reset, with: {hard: true}}
|
|
7
|
+
- {tool: git_clean, with: {force: true}}
|
|
8
|
+
- {tool: git_branch, with: {force_delete: true}}
|
|
9
|
+
- {tool: [git_stash_drop, git_stash_clear]}
|
|
10
|
+
- {tool: [git_checkout, git_restore], where: {paths: "*"}}
|
|
11
|
+
why: These commands discard work that may not exist anywhere else.
|
|
12
|
+
mode: ask
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
summary: Ask before changing infrastructure (terraform apply/destroy, kubectl apply/delete/scale/drain, aws s3 rm).
|
|
2
|
+
tags: [cloud]
|
|
3
|
+
rules:
|
|
4
|
+
- id: ask-before-infra-changes
|
|
5
|
+
never: [terraform_apply, terraform_destroy, kubectl_apply, kubectl_delete, kubectl_scale,
|
|
6
|
+
kubectl_drain, kubectl_create, aws_s3_rm]
|
|
7
|
+
why: Infrastructure changes affect running systems and other people.
|
|
8
|
+
fix: Show the plan (terraform plan, kubectl diff) and let the user run it.
|
|
9
|
+
mode: ask
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
summary: Ask before installing or adding packages (pip, npm, yarn, pnpm, uv, poetry, cargo, apt, brew, gem, go).
|
|
2
|
+
tags: [dependencies]
|
|
3
|
+
rules:
|
|
4
|
+
- id: ask-before-installing
|
|
5
|
+
never:
|
|
6
|
+
- pip_install
|
|
7
|
+
- {tool: python, with: {module: pip, script: install}}
|
|
8
|
+
- {tool: [npm, pnpm, uv, gem, apt, apt-get, brew, go, pipx], with: {argv: install}}
|
|
9
|
+
- {tool: npm, with: {argv: i}}
|
|
10
|
+
- {tool: [yarn, pnpm, uv, poetry, cargo], with: {argv: add}}
|
|
11
|
+
why: New dependencies change the supply chain and the lockfile; the user decides.
|
|
12
|
+
mode: ask
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
summary: Ask before commands that print secrets from a store (kubectl secrets, vault, cloud secret managers, password managers, sops...).
|
|
2
|
+
tags: [secrets, devops]
|
|
3
|
+
rules:
|
|
4
|
+
- id: ask-before-reading-secret-stores
|
|
5
|
+
never:
|
|
6
|
+
- {tool: kubectl_get, where: {resource: ["secret", "secrets", "secret/*", "secrets/*"]}}
|
|
7
|
+
- {tool: kubectl_config_view, with: {raw: true}}
|
|
8
|
+
- [helm_get_values, helm_get_all]
|
|
9
|
+
- [terraform_output, terraform_show, terraform_state_pull, terraform_console]
|
|
10
|
+
- [aws_secretsmanager_get_secret_value, aws_secretsmanager_batch_get_secret_value,
|
|
11
|
+
aws_configure_get, aws_configure_export_credentials, aws_ecr_get_login_password, aws_kms_decrypt]
|
|
12
|
+
- {tool: [aws_ssm_get_parameter, aws_ssm_get_parameters, aws_ssm_get_parameters_by_path],
|
|
13
|
+
with: {with_decryption: true}}
|
|
14
|
+
- [gcloud_secrets_versions_access, gcloud_auth_print_access_token, gcloud_auth_print_identity_token,
|
|
15
|
+
gcloud_auth_application_default_print_access_token]
|
|
16
|
+
- [az_keyvault_secret_show, az_keyvault_secret_download, az_keyvault_key_download,
|
|
17
|
+
az_account_get_access_token, az_storage_account_keys_list,
|
|
18
|
+
az_storage_account_show_connection_string, az_acr_credential_show]
|
|
19
|
+
- [vault_kv_get, vault_read, vault_print_token, vault_token_lookup]
|
|
20
|
+
- [gh_auth_token]
|
|
21
|
+
- {tool: gh_auth_status, with: {show_token: true}}
|
|
22
|
+
- {tool: git_credential, with: {action: fill}}
|
|
23
|
+
- [heroku_config, heroku_config_get, heroku_auth_token, doppler_secrets, doppler_secrets_get,
|
|
24
|
+
doppler_secrets_download, vercel_env_pull]
|
|
25
|
+
- {tool: sops, with: {decrypt: true}}
|
|
26
|
+
- [ansible_vault_view, ansible_vault_decrypt]
|
|
27
|
+
- {tool: [gpg, age], with: {decrypt: true}}
|
|
28
|
+
- {tool: gpg, with: {export_secret_keys: true}}
|
|
29
|
+
- [op_read, op_item_get, bw_get, bw_export, pass_show, secret_tool_lookup,
|
|
30
|
+
security_find_generic_password, security_find_internet_password, security_dump_keychain]
|
|
31
|
+
- {tool: [openssl_rsa, openssl_pkey, openssl_ec], with: {text: true}}
|
|
32
|
+
- {tool: openssl_pkcs12, with: {nodes: true}}
|
|
33
|
+
why: These commands print secret values, which then sit in the conversation and the transcript.
|
|
34
|
+
fix: If the value is only needed by another command, pass it without printing it (a pipe, or an environment variable set by the tool), or ask the user.
|
|
35
|
+
mode: ask
|