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/scan.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/scan.py – spot credentials in what a tool call returned.
|
|
3
|
+
|
|
4
|
+
A rule can only stop a call before it runs; when a program prints a credential anyway
|
|
5
|
+
(`python app.py` logging its config, a test failure dumping the environment), the value is
|
|
6
|
+
already in the conversation. The harness's after-the-call hook scans the output for well-known credential
|
|
7
|
+
formats, the documented prefixes that secret scanners also use, and reports what kind was
|
|
8
|
+
seen, never the value, so the user can rotate it and the agent does not repeat it.
|
|
9
|
+
|
|
10
|
+
Only formats with a distinctive prefix or structure are matched, to keep false alarms rare.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, List, Tuple
|
|
18
|
+
|
|
19
|
+
_MAX_SCAN = 1_000_000
|
|
20
|
+
|
|
21
|
+
# (kind, pattern): documented prefixes; lengths keep ordinary words from matching.
|
|
22
|
+
_FORMATS = [
|
|
23
|
+
("AWS access key ID", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
24
|
+
("GitHub token", r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b"),
|
|
25
|
+
("GitLab token", r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
|
|
26
|
+
("Slack token", r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"),
|
|
27
|
+
("Stripe live key", r"\b[rs]k_live_[A-Za-z0-9]{20,}\b"),
|
|
28
|
+
("Google API key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
|
|
29
|
+
("Anthropic API key", r"\bsk-ant-[A-Za-z0-9_-]{20,}"),
|
|
30
|
+
("OpenAI API key", r"\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}"),
|
|
31
|
+
("npm token", r"\bnpm_[A-Za-z0-9]{36}\b"),
|
|
32
|
+
("private key", r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----"),
|
|
33
|
+
]
|
|
34
|
+
_COMPILED = [(kind, re.compile(pattern)) for kind, pattern in _FORMATS]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credential_kinds(value: Any) -> List[str]:
|
|
38
|
+
"""The kinds of credential that appear in *value* (a tool response), in a stable order."""
|
|
39
|
+
text = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
40
|
+
text = text[:_MAX_SCAN]
|
|
41
|
+
return [kind for kind, pattern in _COMPILED if pattern.search(text)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def report(tool: str, kinds: List[str]) -> Tuple[str, str]:
|
|
45
|
+
"""What to say after a call whose output has credentials of *kinds*: a notice for the
|
|
46
|
+
user, and an instruction for the agent. The harness puts each where its agent shows it."""
|
|
47
|
+
what = ", ".join(kinds)
|
|
48
|
+
user = (f"AgentLTL: the output of {tool} contains what looks like a credential ({what}). "
|
|
49
|
+
"If it is real, consider rotating it.")
|
|
50
|
+
agent = (f"The output of this {tool} call contains what looks like a credential ({what}). "
|
|
51
|
+
"Do not repeat, quote, copy or write that value anywhere: not in replies, files, "
|
|
52
|
+
"commands or commit messages. Tell the user it appeared, so they can rotate it, "
|
|
53
|
+
"and carry on without it.")
|
|
54
|
+
return user, agent
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/session.py – what a harness's hooks do, whatever their I/O.
|
|
3
|
+
|
|
4
|
+
Each agent calls its hooks with its own payloads and expects its own JSON back; what they
|
|
5
|
+
do in between is the same everywhere. A harness turns its payload into one of these calls
|
|
6
|
+
and the result into its answer::
|
|
7
|
+
|
|
8
|
+
s = Session(cwd, project, session_id)
|
|
9
|
+
if not s.files: ... # no AGENTLTL.yaml: stay out of the way
|
|
10
|
+
s.prompt() # the user wrote: lift a stop, new turn
|
|
11
|
+
s.start() # the reminder of the rules, or None
|
|
12
|
+
s.pre("Bash", {"command": "git push"}, auto=False) # a Verdict
|
|
13
|
+
s.post("Bash", {...}, "id", output, status=0) # (user notice, agent note) or None
|
|
14
|
+
s.finish() # a Verdict: "block" while `finally` is unmet
|
|
15
|
+
|
|
16
|
+
Tool names may be the agent's own: the guard maps them (``Harness.tool_aliases``).
|
|
17
|
+
:class:`~agentltl_coding.rules.RuleFileError` from a broken rule file reaches the caller,
|
|
18
|
+
which tells the user (see :func:`broken`).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from . import store
|
|
27
|
+
from .guard import Guard, Verdict
|
|
28
|
+
from .harness import current as _harness
|
|
29
|
+
from .pattern import Paths
|
|
30
|
+
from .rules import RuleSet, load, rule_files
|
|
31
|
+
|
|
32
|
+
STILL_STOPPED = (
|
|
33
|
+
"[AGENTLTL] Rule '{rule}' stopped this session: every tool call is refused until the user "
|
|
34
|
+
"replies. Nothing was executed. Don't try another command or a workaround. Tell the user "
|
|
35
|
+
"what you were trying to do, why, and what you need from them, then end your turn.")
|
|
36
|
+
_MAX_DECISIONS = 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Session:
|
|
40
|
+
def __init__(self, cwd: str, project: str, session_id: Optional[str]) -> None:
|
|
41
|
+
self.cwd, self.project, self.sid = cwd, project, session_id or "default"
|
|
42
|
+
self.files: List[str] = rule_files(cwd, project)
|
|
43
|
+
self._ruleset: Optional[RuleSet] = None
|
|
44
|
+
logging.getLogger("agentltl").setLevel(logging.ERROR)
|
|
45
|
+
logging.getLogger("cli_to_tools").setLevel(logging.ERROR)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def ruleset(self) -> RuleSet:
|
|
49
|
+
"""The compiled rules. Raises RuleFileError for a broken rule file."""
|
|
50
|
+
if self._ruleset is None:
|
|
51
|
+
self._ruleset = load(self.files, Paths(self.cwd, self.project))
|
|
52
|
+
return self._ruleset
|
|
53
|
+
|
|
54
|
+
# ── events ────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def prompt(self) -> None:
|
|
57
|
+
"""The user wrote: lift a stop (even if the rule file has since become unreadable),
|
|
58
|
+
and let ``finally`` rules send the agent back again in this new turn."""
|
|
59
|
+
with store.locked(self.sid) as state:
|
|
60
|
+
state.pop("stopped", None)
|
|
61
|
+
state["termination_nudges"] = 0
|
|
62
|
+
with store.locked_project(self.project) as project_state:
|
|
63
|
+
project_state["termination_nudges"] = 0
|
|
64
|
+
|
|
65
|
+
def start(self) -> Optional[str]:
|
|
66
|
+
"""What to tell the agent at the start of a session (None: settings.announce off)."""
|
|
67
|
+
return reminder(self.ruleset)
|
|
68
|
+
|
|
69
|
+
def pre(self, tool: str, tool_input: Dict[str, Any], *, auto: bool = False) -> Verdict:
|
|
70
|
+
"""Decide a call before it runs. After a ``stop``, every call is denied until
|
|
71
|
+
:meth:`prompt`."""
|
|
72
|
+
def decide(guard: Guard, state: Dict[str, Any]) -> Verdict:
|
|
73
|
+
if state.get("stopped"):
|
|
74
|
+
return Verdict("deny", STILL_STOPPED.format(**state["stopped"]),
|
|
75
|
+
rule=state["stopped"].get("rule"))
|
|
76
|
+
verdict = guard.decide(tool, tool_input, auto=auto)
|
|
77
|
+
if verdict.action == "stop":
|
|
78
|
+
state["stopped"] = {"rule": verdict.rule or "?"}
|
|
79
|
+
return verdict
|
|
80
|
+
|
|
81
|
+
return self._with_guard(decide, _harness().canonical(tool, tool_input))
|
|
82
|
+
|
|
83
|
+
def post(self, tool: str, tool_input: Dict[str, Any], tool_id: str, result: Any,
|
|
84
|
+
status: Optional[int] = None) -> Optional[Tuple[str, str]]:
|
|
85
|
+
"""Record a call that ran (status 0) or failed (1). Returns what to say when its output
|
|
86
|
+
holds a credential: (notice for the user, instruction for the agent)."""
|
|
87
|
+
self._with_guard(lambda guard, state: guard.record(tool, tool_input, tool_id, result,
|
|
88
|
+
status=status))
|
|
89
|
+
if not self.ruleset.settings.scan_output or result is None:
|
|
90
|
+
return None
|
|
91
|
+
from .scan import credential_kinds, report
|
|
92
|
+
kinds = credential_kinds(result)
|
|
93
|
+
return report(_harness().canonical(tool, {})[0], kinds) if kinds else None
|
|
94
|
+
|
|
95
|
+
def finish(self) -> Verdict:
|
|
96
|
+
"""The agent is about to end its turn: "block" with what is missing while a
|
|
97
|
+
``finally`` rule is unmet."""
|
|
98
|
+
return self._with_guard(lambda guard, state: guard.finish()) or Verdict()
|
|
99
|
+
|
|
100
|
+
# ── state ─────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _with_guard(self, fn: Any, call: Optional[Tuple[str, Dict[str, Any]]] = None) -> Any:
|
|
103
|
+
"""Run *fn(guard, session_state)* on the saved state and save it again. A verdict on
|
|
104
|
+
*call* that intervenes is logged for ``agentltl trace``."""
|
|
105
|
+
guard = Guard(self.ruleset, Paths(self.cwd, self.project))
|
|
106
|
+
with store.locked(self.sid) as state, store.locked_project(self.project) as project_state:
|
|
107
|
+
guard.restore(state, project_state)
|
|
108
|
+
out = fn(guard, state)
|
|
109
|
+
for old in ("trace", "engine"): # saved before AgentLTL 0.2
|
|
110
|
+
state.pop(old, None)
|
|
111
|
+
state.update(guard.dump())
|
|
112
|
+
state["project_dir"] = self.project
|
|
113
|
+
project_state.update(guard.dump_project())
|
|
114
|
+
if call and isinstance(out, Verdict) and out.action not in ("none", "block"):
|
|
115
|
+
state.setdefault("decisions", []).append(
|
|
116
|
+
{"tool": call[0], "input": call[1], "action": out.action, "rule": out.rule})
|
|
117
|
+
state["decisions"] = state["decisions"][-_MAX_DECISIONS:]
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def reminder(ruleset: RuleSet) -> Optional[str]:
|
|
122
|
+
"""The session-start reminder: the rules in force, one line each."""
|
|
123
|
+
if not ruleset.settings.announce:
|
|
124
|
+
return None
|
|
125
|
+
lines = [
|
|
126
|
+
f"This project enforces {len(ruleset.rules)} AGENTLTL rule(s) on every tool call, shell "
|
|
127
|
+
"commands included (each command line is checked as the sequence of commands it runs). "
|
|
128
|
+
"A call that breaks a rule is refused with the reason; follow it rather than working "
|
|
129
|
+
"around it. When a rule keeps you from doing what the user asked, or refuses a call, "
|
|
130
|
+
"tell the user in your reply and name the rule. Rules:",
|
|
131
|
+
]
|
|
132
|
+
note = _harness().memory_note
|
|
133
|
+
for r in ruleset.rules:
|
|
134
|
+
if r.id == "memory-first" and r.kind == "never" and note:
|
|
135
|
+
lines.append(f"- memory-first [{r.mode}]: {note}")
|
|
136
|
+
continue
|
|
137
|
+
why = f" — {r.why}" if r.why else ""
|
|
138
|
+
memory = ", whole project" if r.scope == "project" else ""
|
|
139
|
+
lines.append(f"- {r.id} [{r.mode}{memory}]: {r.summary}{why}")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def broken(problems: List[str]) -> str:
|
|
144
|
+
"""What to tell the agent (and through it the user) about a broken rule file."""
|
|
145
|
+
return ("AGENTLTL.yaml has errors, so NO AGENTLTL rules are being enforced until it is "
|
|
146
|
+
"fixed:\n" + "\n".join(f"- {p}" for p in problems))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["Session", "STILL_STOPPED", "broken", "reminder"]
|
agentltl_coding/store.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentltl_coding/store.py – per-session state on disk.
|
|
3
|
+
|
|
4
|
+
Each hook call is a fresh process, so traces and enforcer counters live on disk: the
|
|
5
|
+
session memory in ``<state dir>/<session id>.json``, the project memory (every call made in
|
|
6
|
+
a project, across sessions) in ``<state dir>/../projects/<hash of the project dir>.json``. A harness may run tool calls in parallel, so every
|
|
7
|
+
read-modify-write holds an exclusive lock on ``<file>.lock``.
|
|
8
|
+
|
|
9
|
+
State dir: ``$AGENTLTL_STATE`` (or ``$AGENTLTL_CC_STATE``), else the harness's ``state_dir``,
|
|
10
|
+
else ``sessions/`` next to the virtualenv the guard runs in (``<data>/venv-<pins>`` →
|
|
11
|
+
``<data>/sessions``, so a hook and the ``agentltl`` CLI agree without sharing an
|
|
12
|
+
environment), else ``~/.cache/agentltl-<harness>/sessions``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fcntl
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def state_dir() -> str:
|
|
29
|
+
from .harness import current
|
|
30
|
+
for var in ("AGENTLTL_STATE", "AGENTLTL_CC_STATE"):
|
|
31
|
+
if os.environ.get(var):
|
|
32
|
+
return os.environ[var]
|
|
33
|
+
if current().state_dir:
|
|
34
|
+
return os.path.expanduser(current().state_dir)
|
|
35
|
+
if re.fullmatch(r"venv(-\d+)?", os.path.basename(sys.prefix)):
|
|
36
|
+
return os.path.join(os.path.dirname(sys.prefix), "sessions")
|
|
37
|
+
return os.path.join(os.path.expanduser("~"), ".cache", f"agentltl-{current().name}", "sessions")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def session_path(session_id: str) -> str:
|
|
41
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id or "default")[:128]
|
|
42
|
+
return os.path.join(state_dir(), f"{safe}.json")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_path(project_dir: str) -> str:
|
|
46
|
+
key = hashlib.sha1(os.path.abspath(project_dir).encode()).hexdigest()[:16]
|
|
47
|
+
return os.path.join(os.path.dirname(state_dir()), "projects", f"{key}.json")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read(path: str) -> Dict[str, Any]:
|
|
51
|
+
try:
|
|
52
|
+
with open(path, encoding="utf-8") as fh:
|
|
53
|
+
return json.load(fh)
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read(session_id: str) -> Dict[str, Any]:
|
|
59
|
+
return _read(session_path(session_id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_project(project_dir: str) -> Dict[str, Any]:
|
|
63
|
+
return _read(project_path(project_dir))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextmanager
|
|
67
|
+
def _locked(path: str) -> Iterator[Dict[str, Any]]:
|
|
68
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
69
|
+
with open(path + ".lock", "w") as lock:
|
|
70
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
71
|
+
state = _read(path)
|
|
72
|
+
yield state
|
|
73
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
75
|
+
json.dump(state, fh, default=str)
|
|
76
|
+
os.replace(tmp, path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def locked(session_id: str) -> Iterator[Dict[str, Any]]:
|
|
81
|
+
"""Yield the session state for update; whatever it holds on exit is written back."""
|
|
82
|
+
with _locked(session_path(session_id)) as state:
|
|
83
|
+
yield state
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def locked_project(project_dir: str) -> Iterator[Dict[str, Any]]:
|
|
88
|
+
"""Like :func:`locked`, for the project memory. Take it after the session lock."""
|
|
89
|
+
with _locked(project_path(project_dir)) as state:
|
|
90
|
+
state["project_dir"] = os.path.abspath(project_dir)
|
|
91
|
+
yield state
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def latest_session(cwd: Optional[str] = None) -> Optional[str]:
|
|
95
|
+
"""The most recently updated session, optionally only one whose project contains *cwd*."""
|
|
96
|
+
folder = state_dir()
|
|
97
|
+
try:
|
|
98
|
+
names = [n for n in os.listdir(folder) if n.endswith(".json")]
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
names.sort(key=lambda n: os.path.getmtime(os.path.join(folder, n)), reverse=True)
|
|
102
|
+
for name in names:
|
|
103
|
+
sid = name[:-5]
|
|
104
|
+
project = read(sid).get("project_dir")
|
|
105
|
+
if cwd is None or (project and (cwd == project or cwd.startswith(project + os.sep))):
|
|
106
|
+
return sid
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reset(session_id: str) -> None:
|
|
111
|
+
with locked(session_id) as state:
|
|
112
|
+
keep = {k: state[k] for k in ("project_dir",) if k in state}
|
|
113
|
+
state.clear()
|
|
114
|
+
state.update(keep)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def reset_project(project_dir: str) -> None:
|
|
118
|
+
with locked_project(project_dir) as state:
|
|
119
|
+
state.clear()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__: List[str] = ["state_dir", "session_path", "project_path", "read", "read_project",
|
|
123
|
+
"locked", "locked_project", "latest_session", "reset", "reset_project"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentltl-coding
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: AgentLTL rules for coding agents: the AGENTLTL.yaml language, a rule library, and a guard any harness can call.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://agentltl.github.io
|
|
7
|
+
Project-URL: Source, https://github.com/AgentLTL/agentltl-coding
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: agentltl>=0.2
|
|
12
|
+
Requires-Dist: cli-to-tools>=0.2
|
|
13
|
+
Requires-Dist: pyyaml>=6.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# agentltl-coding
|
|
20
|
+
|
|
21
|
+
AgentLTL rules for coding agents, whatever the harness: the `AGENTLTL.yaml` rule language, a
|
|
22
|
+
library of tested rules, and a guard that decides each tool call before it runs.
|
|
23
|
+
|
|
24
|
+
- **Rules** in plain YAML, compiled to [AgentLTL](https://github.com/AgentLTL/AgentLTL)
|
|
25
|
+
formulas over the calls made so far:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
rules:
|
|
29
|
+
- id: tests-before-push
|
|
30
|
+
before: {first: pytest, then: git_push, since: [Edit, Write]}
|
|
31
|
+
why: CI is slow; run the tests locally first.
|
|
32
|
+
mode: warn
|
|
33
|
+
use: [no-force-push, protect-secret-files] # from the library
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Shell commands** are checked as the structured calls of their command line
|
|
37
|
+
([cli-to-tools](https://github.com/AgentLTL/cli-to-tools)): `git add . && git push -f` is
|
|
38
|
+
`git_add`, then `git_push {force: true}`, checked all or nothing, with redirections,
|
|
39
|
+
paths after `cd`, and files only known at run time accounted for.
|
|
40
|
+
- **Modes** map one to one onto AgentLTL severities: `block`, `warn` (the agent may insist),
|
|
41
|
+
`ask` (you decide), `retry` (then you decide), `stop`, `log`.
|
|
42
|
+
- **Memory:** each rule reads the calls of this session, or of the project across sessions.
|
|
43
|
+
|
|
44
|
+
Four harnesses are built on it: the
|
|
45
|
+
[Claude Code plugin](https://github.com/AgentLTL/agentltl-claude-code), the
|
|
46
|
+
[GitHub Copilot CLI plugin](https://github.com/AgentLTL/agentltl-copilot-cli), the
|
|
47
|
+
[Mistral Vibe plugin](https://github.com/AgentLTL/agentltl-mistral-vibe) and the
|
|
48
|
+
[Codex plugin](https://github.com/AgentLTL/agentltl-codex). The full rule
|
|
49
|
+
reference is on [agentltl.github.io](https://agentltl.github.io/rules/).
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Most people want a plugin for their agent, not this library: paste the prompt from the
|
|
54
|
+
plugin's README into your agent ([Claude Code](https://github.com/AgentLTL/agentltl-claude-code#installation),
|
|
55
|
+
[Codex](https://github.com/AgentLTL/agentltl-codex#installation),
|
|
56
|
+
[Copilot CLI](https://github.com/AgentLTL/agentltl-copilot-cli#installation),
|
|
57
|
+
[Mistral Vibe](https://github.com/AgentLTL/agentltl-mistral-vibe#installation)). To build a
|
|
58
|
+
harness for another agent, paste this into your coding agent:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
Install agentltl-coding (pip install "git+https://github.com/AgentLTL/agentltl-coding.git" in a virtual environment) and read its README at https://github.com/AgentLTL/agentltl-coding, then help me write a harness for my agent.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## A harness in a few lines
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from agentltl_coding import Harness, Session, configure
|
|
68
|
+
|
|
69
|
+
configure(Harness(name="my-agent", agent="the agent", user_dir="~/.my-agent",
|
|
70
|
+
shell_tools={"Bash": "command"},
|
|
71
|
+
tool_aliases={"shell": ("Bash", {"cmd": "command"}),
|
|
72
|
+
"write_file": ("Write", {"path": "file_path"})}))
|
|
73
|
+
|
|
74
|
+
s = Session(cwd, project_root, session_id) # one per hook call; state is on disk
|
|
75
|
+
if s.files: # an AGENTLTL.yaml applies here
|
|
76
|
+
verdict = s.pre("shell", {"cmd": "git push --force"}) # before the call
|
|
77
|
+
# verdict.action: "none" | "deny" | "ask" | "stop"; verdict.reason: the message
|
|
78
|
+
...
|
|
79
|
+
s.post("shell", {"cmd": "pytest"}, call_id, output, status=0) # after it ran
|
|
80
|
+
s.finish() # the agent wants to stop: "block" while a `finally` rule is unmet
|
|
81
|
+
s.prompt() # the user wrote: lift a `stop`
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`Harness` says what differs between agents:
|
|
85
|
+
|
|
86
|
+
- the agent's name in messages, and where the user's `AGENTLTL.yaml` lives;
|
|
87
|
+
- `tool_aliases`: its tool names mapped onto the canonical ones rules use (Claude Code's
|
|
88
|
+
`Bash`, `Write`, `Edit`, `Read`...), so the library works unchanged (Copilot's
|
|
89
|
+
`create {path, file_text}` is `Write {file_path, content}`);
|
|
90
|
+
- which tools take shell command lines, and the permission modes in which nobody answers a
|
|
91
|
+
prompt;
|
|
92
|
+
- built-in rules of its own (`memory-first`), and where its memory lives, for
|
|
93
|
+
`agentltl memory scan` (`agentltl_coding.memory`).
|
|
94
|
+
|
|
95
|
+
`Session` is what the hooks do: it keeps the session and project traces on disk, holds a
|
|
96
|
+
`stop` until the user replies, logs interventions, and scans call output for credentials.
|
|
97
|
+
`Guard` underneath decides one call. `agentltl_coding.cli.main(argv, extend=...)` is the
|
|
98
|
+
`agentltl` command (validate, check, translate, tools, library, use, memory...), to which a
|
|
99
|
+
harness can add commands. A library entry with `harnesses: [claude-code]` is for that agent
|
|
100
|
+
only; elsewhere `use:` of it switches nothing on, so one project file serves every agent.
|
|
101
|
+
|
|
102
|
+
## Testing a harness end to end
|
|
103
|
+
|
|
104
|
+
`e2e/` lets a plugin test itself in the real agent, without a model or an account:
|
|
105
|
+
|
|
106
|
+
- `e2e/scripted_model.py SCRIPT.json` serves the OpenAI chat-completions, OpenAI responses
|
|
107
|
+
and Anthropic messages formats (Mistral Vibe, Copilot CLI with a custom provider, Codex with
|
|
108
|
+
`wire_api = "responses"`, Claude Code with `ANTHROPIC_BASE_URL`) and answers each request with the next step of a script: tool calls,
|
|
109
|
+
then a final text.
|
|
110
|
+
- `e2e/check.py TRACE EXPECTED` checks that `agentltl trace` shows the expected refusals, in
|
|
111
|
+
order.
|
|
112
|
+
- `.github/workflows/agent-e2e.yml` is a reusable workflow: it runs the plugin's
|
|
113
|
+
`docker/e2e.sh` and, when that fails on a pull request (such as Dependabot's update of the
|
|
114
|
+
agent's version), says so on the pull request and mentions who to notify.
|
|
115
|
+
|
|
116
|
+
Each plugin pins the agent version it tests in `docker/` (a `package.json` or a
|
|
117
|
+
`requirements.txt`), so Dependabot proposes every new release as a pull request.
|
|
118
|
+
|
|
119
|
+
## Develop
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e ".[dev]" "agentltl @ git+https://github.com/AgentLTL/AgentLTL.git" \
|
|
123
|
+
"cli-to-tools @ git+https://github.com/AgentLTL/cli-to-tools.git"
|
|
124
|
+
pytest -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`tests/golden/verdicts.json` records what every packaged rule, example file and formula
|
|
128
|
+
rule decides, step by step; a change that alters a verdict regenerates it
|
|
129
|
+
(`AGENTLTL_GOLDEN_UPDATE=1 pytest tests/test_golden.py`) and shows up as a reviewed diff.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
agentltl_coding/__init__.py,sha256=-bsuqhM7lxy_x8-tdUwDhoh7RGtU5dUk28TGflB0i9A,991
|
|
2
|
+
agentltl_coding/cli.py,sha256=mtbKqiysDywC-hcBRpUL8VuhLbB1il8XEARPLd_QdwE,20686
|
|
3
|
+
agentltl_coding/edit.py,sha256=Fw_IYv-cn7OMKFbRPiHkMulh9E0g6IX5rP56TfqgajQ,2690
|
|
4
|
+
agentltl_coding/guard.py,sha256=tsLz09sGwaFa-cvdMGAefsu3wAg5eoAnbnz2ODeOHVI,23281
|
|
5
|
+
agentltl_coding/harness.py,sha256=xrI5p9N7k6F18DrK2KTHi9DmrYtUhoQmgiK1OYju-VU,4771
|
|
6
|
+
agentltl_coding/memory.py,sha256=ZtDfbcfsEERLgHjrNUpacmt5Y0RK22vkUmGe171tUHo,17820
|
|
7
|
+
agentltl_coding/pattern.py,sha256=eY8C7YSKQNxP41-ZjXBK8_Y-2enZTn00DPLvekYdifw,13234
|
|
8
|
+
agentltl_coding/rules.py,sha256=OtNCKLbf4nwcNKVBm0esOiZmwCNUOH-fDc2CR9Nfqts,41675
|
|
9
|
+
agentltl_coding/scan.py,sha256=owdbGriXg9zvpQdGflO3fL25E9xyIPQeL0eLq6PMz6g,2631
|
|
10
|
+
agentltl_coding/session.py,sha256=i2Kxl5Td7bE1E1B2UZ-0qh097wio0U-Ghbx8tGHOzJ8,7669
|
|
11
|
+
agentltl_coding/store.py,sha256=oDe7HwkUY3QaGU1iEvu_Zu4qVDG_5kpwREfMXC-G-xU,4367
|
|
12
|
+
agentltl_coding/library/ask-before-creating-credentials.yaml,sha256=Al2_JHoo-3JXORY0t8xGz5PtYPInzKaBw8-0YE7-Hag,582
|
|
13
|
+
agentltl_coding/library/ask-before-discarding-work.yaml,sha256=-tn4y_14UyyKb4AD3_hMp7RW_pzzi3YCTc-3KEAxSAI,492
|
|
14
|
+
agentltl_coding/library/ask-before-infra-changes.yaml,sha256=bKGPnrVtdnw6fhufBWw6O1o1vDwaG1LbHvhg_XN9HtE,484
|
|
15
|
+
agentltl_coding/library/ask-before-installing.yaml,sha256=_nHRBBy5jHqgpuz0MdzH5n3OAkDn_KBYtN1rU28HYtk,557
|
|
16
|
+
agentltl_coding/library/ask-before-reading-secret-stores.yaml,sha256=qPgc_V4sOYo9WJ-p6_MBoW190k3IJEcfpvH3D4btZu0,2365
|
|
17
|
+
agentltl_coding/library/ask-before-recursive-delete.yaml,sha256=r9mE6eMg6S-cRJMfKRa0fYa3kIZPCCaAIUwWP-EIu9M,222
|
|
18
|
+
agentltl_coding/library/devops-secrets.yaml,sha256=L86wDUFsM__Hd4auZgHa2Vkx_KH27cp6gvaQdIYY1ME,404
|
|
19
|
+
agentltl_coding/library/no-claude-coauthor.yaml,sha256=XoUgeHt0rt3ueNkO7qDVLkWc5dGJNVxY6RqD2oBMa50,602
|
|
20
|
+
agentltl_coding/library/no-codex-coauthor.yaml,sha256=VJ_-FZMUE2LYNcMQ9Pn2_nw_ric0Mz2QQOZsRX1cCkY,587
|
|
21
|
+
agentltl_coding/library/no-copilot-coauthor.yaml,sha256=Y2zJ_SWrD4kjgr_0pvy2WecbVgusrXb_CO1ZvY54_8A,580
|
|
22
|
+
agentltl_coding/library/no-env-dumps.yaml,sha256=GBdc15SXtBUbgpyAVt6WDR6Cico1vaWImOyyJ-NMTZY,938
|
|
23
|
+
agentltl_coding/library/no-force-push.yaml,sha256=-esr5Ue9gMlZuapyCBpGqV8S0FBKVcTXtyM75JSD9CI,321
|
|
24
|
+
agentltl_coding/library/no-leaky-debug.yaml,sha256=sCUKpPZYvuIWSDVcekf1f3Ht4PtYgglo0KHiHDCLqls,709
|
|
25
|
+
agentltl_coding/library/no-push-to-main.yaml,sha256=VyMt5E9KBjW8WNvi1_UwbDsT6BKgwIiwtzSwDsCMB1U,339
|
|
26
|
+
agentltl_coding/library/no-secrets-in-commands.yaml,sha256=_M_2tAoivUYjBA1nbYZ6jSbK8uiWHMPpgT3Gw65KKFI,1356
|
|
27
|
+
agentltl_coding/library/no-skipping-secret-scans.yaml,sha256=80s2v4BYBWTzNLo1CyCPTAqdSVMUgTcK9bgfa53pCBY,415
|
|
28
|
+
agentltl_coding/library/no-vibe-coauthor.yaml,sha256=ql8uXkQrbQySCFEct3AGBKDFtQ-GLm02eofJXSa5O_Q,620
|
|
29
|
+
agentltl_coding/library/protect-env-files.yaml,sha256=dE91Jc3GWGf6DBaE7qd31-c2o0z7x7wAtoVMpI5G2lI,474
|
|
30
|
+
agentltl_coding/library/protect-secret-files.yaml,sha256=r1lP5rfrLlPyatKm0Kror48bjONqkBHEBN8XcwOSrPk,1660
|
|
31
|
+
agentltl_coding/library/read-before-overwrite.yaml,sha256=VfeVl8hGSvBEv6Ai88bIuA9pW0Txe4GRvBNu75AgsJc,519
|
|
32
|
+
agentltl_coding/library/subagents-on-sonnet.yaml,sha256=arF7txB3cejhpK97GnQ06iy8X3Sz8IHmZTVbpyADtdM,619
|
|
33
|
+
agentltl_coding/library/tests-before-finishing.yaml,sha256=tbRgXDGKDEAutE6w7S8MJe9GtYp4FuvQOC3s7cp-cdI,631
|
|
34
|
+
agentltl_coding/library/tests-before-push.yaml,sha256=-KvlpX_pNOQ-k8mbs8dNO_LusmKSFm42nCrFlNHbXJY,771
|
|
35
|
+
agentltl_coding-0.2.4.dist-info/licenses/LICENSE,sha256=s8Kl1HVH8xqtOmxdNgTs-k9B8E6jYtpoF-u8k5WECac,1072
|
|
36
|
+
agentltl_coding-0.2.4.dist-info/METADATA,sha256=qQzlIa5SqrOPkpqKCNPdYL2482P4_xhnmZejdTS9mAw,6441
|
|
37
|
+
agentltl_coding-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
38
|
+
agentltl_coding-0.2.4.dist-info/entry_points.txt,sha256=F8jVGaO3XCi60loecZlnq_5T4GnGpkzhnOE_GG0zzPo,54
|
|
39
|
+
agentltl_coding-0.2.4.dist-info/top_level.txt,sha256=U6DuI1edF6v8PK7oKaiHubdumNVs9btT8unBtnBqlwU,16
|
|
40
|
+
agentltl_coding-0.2.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Laïla Elkoussy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentltl_coding
|