feat(guard): כלל-קשירה ל-INV-AG3 — השער דיווח "תקין" על allow-list שאיש אינו אוכף
All checks were successful
Lint — undefined names / undefined-names (pull_request) Successful in 12s
INV-AG3 Agent Tool Grants / agent-tool-grants (pull_request) Successful in 5s
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s

כללים 1–4 משווים קבצים לקבצים, וזה לא אומר דבר על **אכיפה**. רשימת
tools: הופכת ל-allow-list רק כשה-runtime בוחר את הסוכן (--agent <name>);
בלעדיו אותו קובץ בדיוק נמסר כ---append-system-prompt-file — פרוזה שהמודל
רשאי לקיים או להתעלם ממנה — וכל 108 הכלים נשארים נגישים.

התגלה 2026-08-05: סוכן אחד נושא 41 הענקות **בלי** --agent. זו הרשימה
הגדולה במערכת, והשער דיווח עליה OK — כי הוא מעולם לא הסתכל על הקשירה.
שער שמדווח "תקין" על רשימה בלתי-נאכפת גרוע מהיעדר שער: הוא מייצר
ביטחון-שווא.

כלל 5 (host-only, --check-bindings): מוודא ש-extraArgs מכיל
["--agent", <name>] התואם ל-instructionsEntryFile. הקשירה יושבת ב-DB של
הפלטפורמה, שה-CI לא רואה, ולכן הבדיקה היא opt-in ומריצה psql בתת-תהליך
במקום להוסיף תלות-דרייבר שהייתה שוברת את תכונת ה-stdlib-בלבד שמסלול
ה-CI נשען עליה. DB בלתי-נגיש מחזיר [] — "לא נבדק", לא "אין הפרות".

ובמסלול ה-CI, השער אומר עכשיו במפורש מה **לא** נבדק, במקום להדפיס OK
חשוף שמזמין את הקורא להסיק שהרשימות נאכפות.

מדווח רק על סוכנים שבאמת מצהירים הענקות — סוכן בלי tools: אינו ממצא.

אומת: מסלול-CI ירוק; --check-bindings תופס את legal-ceo עם הנימוק
"extraArgs is empty" ומציע את שתי החלופות (להוסיף --agent, או למחוק
tools: ולתעד כבלתי-מוגבל) — במפורש לא שתיהן, כי זו הפרת G2.

invariants: INV-AG3 — מרחיב מקובץ לזמן-ריצה. G2 — מקיים; אין מפת-הרשאות
שנייה, רק אימות שהמפה הקיימת נאכפת.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 08:53:53 +00:00
parent b394177207
commit 5f5b13c6a4
2 changed files with 114 additions and 1 deletions

View File

@@ -40,6 +40,18 @@ Plus one reviewed-exception rule:
job, or a DB column that merely shares a tool's name. Each is classified
once in ``CONTRASTIVE_OK`` below; anything new fails until reviewed.
And one host-only rule, opt-in via ``--check-bindings``:
5. **Bindings.** Rules 14 compare files to files, which says nothing about
whether an allow-list is *enforced*. It is only enforced when the runtime
selects that agent (``--agent <name>``); without the flag the same file is
delivered as ``--append-system-prompt-file`` — prose, not a gate — and every
tool stays reachable. Found on 2026-08-05: one agent declared 41 grants with
no ``--agent`` flag, so the largest allow-list in the system was inert while
this guard reported OK. The binding lives in the platform DB, so CI cannot
see it; without the flag the guard now says so out loud instead of implying
enforcement it never verified.
NOT AGENTS (no frontmatter by design — the adapter sends the file as a raw
prompt, so YAML would leak into it): ``hermes-curator.md`` (deepseek_local),
``legal-analyst-gemini-critique.md`` (gemini_local). ``HEARTBEAT.md`` is a
@@ -50,6 +62,9 @@ Usage:
"""
from __future__ import annotations
import json
import os
import subprocess
import re
import sys
from pathlib import Path
@@ -110,6 +125,79 @@ def agent_files() -> list[Path]:
return sorted(p for p in AGENTS_DIR.glob("*.md") if p.name not in NOT_AGENTS)
def check_bindings() -> list[tuple[str, str]]:
"""Return [(agent file, why)] for agents whose allow-list nothing enforces.
A ``tools:`` list is only an allow-list when the runtime is told which agent
to be. The local adapter enforces it under ``--agent <name>``; without that
flag the very same file is delivered as ``--append-system-prompt-file``, i.e.
prose the model may follow or ignore, and every tool stays reachable.
Found the hard way on 2026-08-05: one agent carried 41 grants and no
``--agent`` flag, so the largest allow-list in the system was inert — and
this guard had been reporting OK on it, because Rules 14 only ever compare
files to files.
Host-only. The binding lives in the platform's database, which CI cannot
reach, so this shells out to ``psql`` rather than adding a driver dependency
that would break the stdlib-only property the CI path relies on. Returns []
when the database is unreachable — an unreachable DB is "not checked", not
"no violations", and the caller prints that distinction.
"""
sql = (
"select adapter_config->>'instructionsEntryFile', "
"coalesce(adapter_config->>'extraArgs','') "
"from agents where adapter_type='claude_local' "
"and adapter_config->>'instructionsEntryFile' is not null;"
)
try:
out = subprocess.run(
["psql", "-h", "localhost", "-p", "54329", "-U", "paperclip",
"-d", "paperclip", "-X", "-A", "-t", "-F", "\t", "-c", sql],
capture_output=True, text=True, timeout=20,
env={**os.environ, "PGPASSWORD": os.environ.get("PGPASSWORD", "paperclip")},
)
except (OSError, subprocess.SubprocessError):
return []
if out.returncode != 0:
return []
bad: dict[str, str] = {}
for line in out.stdout.splitlines():
if "\t" not in line:
continue
entry_file, extra = line.split("\t", 1)
entry_file = entry_file.strip()
if not entry_file or entry_file in NOT_AGENTS:
continue
want = entry_file[:-3] if entry_file.endswith(".md") else entry_file
try:
args = json.loads(extra) if extra.strip() else []
except json.JSONDecodeError:
args = []
# Only a literal ["--agent", "<name>"] pair binds the allow-list.
ok = any(
a == "--agent" and i + 1 < len(args) and args[i + 1] == want
for i, a in enumerate(args)
)
if not ok:
bad[entry_file] = (
"extraArgs is empty" if not args
else f"extraArgs={extra.strip()} does not select '{want}'"
)
# Only report agents that actually declare grants — an agent with no tools:
# list has nothing to enforce and is not a finding.
result = []
for path in agent_files():
if path.name in bad:
fm, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
if TOOL_RE.findall(fm):
result.append((path.name, bad[path.name]))
return sorted(result)
def main() -> int:
registered = server_tools()
if not registered:
@@ -183,6 +271,20 @@ def main() -> int:
f"file — drop the exception."
)
# Rule 5 — a grant list only binds if the runtime actually selects that agent.
unenforced = check_bindings() if "--check-bindings" in sys.argv else None
if unenforced:
for name, detail in unenforced:
violations.append(
f"[5 binding] .claude/agents/{name} declares a tools: allow-list, but "
f"the runtime does not select that agent — {detail}.\n"
f" The list is inert: it is delivered as prompt text only, so "
f"every tool remains callable.\n"
f" fix: set adapter_config.extraArgs to "
f'["--agent", "{name[:-3]}"], or drop tools: and document the agent as '
f"unrestricted. Not both."
)
if violations:
print(f"INV-AG3 agent-tool-grants guard: {len(violations)} violation(s)\n")
for v in violations:
@@ -199,6 +301,17 @@ def main() -> int:
f"({len(agent_files())} agents, {len(all_granted)} distinct grants, "
f"{len(registered)} tools registered)"
)
if unenforced is None:
# Say plainly what was NOT checked. A guard that prints a bare "OK" invites
# the reader to conclude the allow-lists are enforced; this one has only
# compared files to files. Enforcement is a runtime property (see Rule 5),
# and on 2026-08-05 exactly one agent was found declaring 41 grants that
# nothing enforces — while this guard reported OK.
print(
" note: file-level only. Whether each allow-list is actually ENFORCED "
"depends on the\n runtime passing --agent <name>, which needs the platform "
"DB — re-run with --check-bindings\n on the host to verify."
)
return 0