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.
Files changed (40) hide show
  1. agentltl_coding/__init__.py +21 -0
  2. agentltl_coding/cli.py +482 -0
  3. agentltl_coding/edit.py +67 -0
  4. agentltl_coding/guard.py +461 -0
  5. agentltl_coding/harness.py +110 -0
  6. agentltl_coding/library/ask-before-creating-credentials.yaml +9 -0
  7. agentltl_coding/library/ask-before-discarding-work.yaml +12 -0
  8. agentltl_coding/library/ask-before-infra-changes.yaml +9 -0
  9. agentltl_coding/library/ask-before-installing.yaml +12 -0
  10. agentltl_coding/library/ask-before-reading-secret-stores.yaml +35 -0
  11. agentltl_coding/library/ask-before-recursive-delete.yaml +7 -0
  12. agentltl_coding/library/devops-secrets.yaml +10 -0
  13. agentltl_coding/library/no-claude-coauthor.yaml +11 -0
  14. agentltl_coding/library/no-codex-coauthor.yaml +11 -0
  15. agentltl_coding/library/no-copilot-coauthor.yaml +11 -0
  16. agentltl_coding/library/no-env-dumps.yaml +18 -0
  17. agentltl_coding/library/no-force-push.yaml +7 -0
  18. agentltl_coding/library/no-leaky-debug.yaml +13 -0
  19. agentltl_coding/library/no-push-to-main.yaml +7 -0
  20. agentltl_coding/library/no-secrets-in-commands.yaml +21 -0
  21. agentltl_coding/library/no-skipping-secret-scans.yaml +9 -0
  22. agentltl_coding/library/no-vibe-coauthor.yaml +11 -0
  23. agentltl_coding/library/protect-env-files.yaml +9 -0
  24. agentltl_coding/library/protect-secret-files.yaml +53 -0
  25. agentltl_coding/library/read-before-overwrite.yaml +11 -0
  26. agentltl_coding/library/subagents-on-sonnet.yaml +12 -0
  27. agentltl_coding/library/tests-before-finishing.yaml +13 -0
  28. agentltl_coding/library/tests-before-push.yaml +14 -0
  29. agentltl_coding/memory.py +390 -0
  30. agentltl_coding/pattern.py +291 -0
  31. agentltl_coding/rules.py +920 -0
  32. agentltl_coding/scan.py +54 -0
  33. agentltl_coding/session.py +149 -0
  34. agentltl_coding/store.py +123 -0
  35. agentltl_coding-0.2.4.dist-info/METADATA +129 -0
  36. agentltl_coding-0.2.4.dist-info/RECORD +40 -0
  37. agentltl_coding-0.2.4.dist-info/WHEEL +5 -0
  38. agentltl_coding-0.2.4.dist-info/entry_points.txt +2 -0
  39. agentltl_coding-0.2.4.dist-info/licenses/LICENSE +21 -0
  40. agentltl_coding-0.2.4.dist-info/top_level.txt +1 -0
@@ -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
@@ -0,0 +1,7 @@
1
+ summary: Ask before any recursive delete (rm -r, rm -rf).
2
+ tags: [files]
3
+ rules:
4
+ - id: ask-before-recursive-delete
5
+ never: {tool: rm, with: {recursive: true}}
6
+ why: A recursive delete cannot be undone.
7
+ mode: ask