lnfnt-a2a 0.1.0__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.
a2a/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """a2a package."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
a2a/access.py ADDED
@@ -0,0 +1,143 @@
1
+ """Record-level ACL + profile manifest (DESIGN-A2A-V05 §4, v0.5.0).
2
+
3
+ Two orthogonal concepts:
4
+ - access level (L1/L2/L3) — v0.4.10 contract: L1 visible to author only,
5
+ foreign L1 via --include-l1 with an audit event. UNCHANGED for records
6
+ without an `acl` field (no narrowing of pre-existing queries);
7
+ - `acl` list on a record — explicit grants: profile names, `@all-lex`
8
+ (any profile in the manifest), `@owner` (record's owner/author).
9
+ Author/owner/recipient are always implicitly readable.
10
+
11
+ profiles.json (bus root): identity manifest. Enforcement is only active
12
+ when the manifest exists — legacy buses without it behave exactly as v0.4.10.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from . import util
22
+
23
+ MANIFEST_NAME = "profiles.json"
24
+
25
+ # user-feed is a publication channel, not a sender; listed in the manifest
26
+ # as a channel. Known agent profiles at v0.5.0 introduction:
27
+ DEFAULT_PEER_PROFILES = (
28
+ "coordinator",
29
+ "colleague",
30
+ "user-profile",
31
+ "support",
32
+ "agent-profile",
33
+ "infra",
34
+ )
35
+ DEFAULT_CHANNELS = ("user-feed",)
36
+
37
+
38
+ def manifest_path() -> Path:
39
+ return util.HOME / MANIFEST_NAME
40
+
41
+
42
+ def load_manifest_names() -> set[str] | None:
43
+ """Return known profile names, or None when no manifest governs this bus.
44
+
45
+ A corrupt manifest is treated as 'no manifest' for enforcement (fail
46
+ open on reads, doctor flags the corruption as an issue) — refusing to
47
+ run the whole bus because one JSON file broke would be worse.
48
+ """
49
+ path = manifest_path()
50
+ if not path.exists():
51
+ return None
52
+ try:
53
+ data = json.loads(path.read_text(encoding="utf-8"))
54
+ except Exception:
55
+ return None
56
+ profiles = data.get("profiles")
57
+ if not isinstance(profiles, list):
58
+ return None
59
+ names: set[str] = set()
60
+ for entry in profiles:
61
+ if isinstance(entry, str):
62
+ names.add(entry)
63
+ elif isinstance(entry, dict) and entry.get("name"):
64
+ names.add(str(entry["name"]))
65
+ return names
66
+
67
+
68
+ def manifest_known(name: str) -> bool | None:
69
+ """True/False when a manifest governs, None when it does not exist."""
70
+ names = load_manifest_names()
71
+ if names is None:
72
+ return None
73
+ return name in names
74
+
75
+
76
+ def enforce_known(name: str, role: str) -> None:
77
+ """Refuse unknown profiles when the bus has a manifest (A-01).
78
+
79
+ Self-declaration via env stays, but 'typo'd a name -> wrote into the
80
+ world' is closed: unknown author/recipient/owner = refusal.
81
+ """
82
+ known = manifest_known(name)
83
+ if known is False:
84
+ util.fail(util.EXIT_USAGE_ERROR, f"unknown_profile:{role}:{name}")
85
+
86
+
87
+ def _implicit_grantees(rec: dict[str, Any]) -> set[str]:
88
+ grants = set()
89
+ for key in ("author", "owner", "to"):
90
+ val = rec.get(key)
91
+ if val:
92
+ grants.add(str(val))
93
+ return grants
94
+
95
+
96
+ def may_read(rec: dict[str, Any], profile: str, include_l1: bool = False) -> bool:
97
+ """Single read predicate for all read commands (journal/mail/alerts/tasks).
98
+
99
+ - record with explicit acl: only acl tokens + implicit author/owner/to
100
+ may read; --include-l1 does NOT bypass an explicit acl;
101
+ - record without acl: v0.4.10 semantics preserved exactly —
102
+ L1 readable by author, foreign L1 only with include_l1 (caller must
103
+ log the audit event itself, as journal/read/search already do).
104
+ """
105
+ acl = rec.get("acl")
106
+ if isinstance(acl, list) and acl:
107
+ tokens = {str(t) for t in acl}
108
+ if profile in tokens or "@all-lex" in tokens:
109
+ return True
110
+ if "@owner" in tokens:
111
+ owner = str(rec.get("owner") or rec.get("author") or "")
112
+ if profile == owner:
113
+ return True
114
+ if profile in _implicit_grantees(rec):
115
+ return True
116
+ return False
117
+ if rec.get("access") == "L1":
118
+ if profile in _implicit_grantees(rec):
119
+ return True
120
+ return bool(include_l1)
121
+ return True
122
+
123
+
124
+ def acl_only(rec: dict[str, Any], profile: str) -> bool:
125
+ """acl gate for path-scoped listings (mail inbox/outbox) where legacy
126
+ semantics imposed no access-level filter: only an explicit acl blocks."""
127
+ acl = rec.get("acl")
128
+ if isinstance(acl, list) and acl:
129
+ return may_read(rec, profile)
130
+ return True
131
+
132
+
133
+ def write_default_manifest(path: Path | None = None) -> Path:
134
+ """Create profiles.json at the bus root (idempotent helper for setup)."""
135
+ target = path or manifest_path()
136
+ payload = {
137
+ "profiles": [
138
+ {"name": name, "role": "peer-agent"} for name in DEFAULT_PEER_PROFILES
139
+ ],
140
+ "channels": list(DEFAULT_CHANNELS),
141
+ }
142
+ util.write_atomically(target, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
143
+ return target
a2a/alerts.py ADDED
@@ -0,0 +1,155 @@
1
+ """Health alert commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import time
7
+ from argparse import Namespace
8
+ from datetime import timedelta
9
+
10
+ from . import schema, scrubber, util
11
+ from . import access as acl_mod
12
+
13
+
14
+ def cmd_alert_set(args: Namespace) -> None:
15
+ start = time.time()
16
+ profile = util.resolve_profile(args)
17
+ target = args.target
18
+ severity = args.severity
19
+ active_until = args.active_until
20
+ try:
21
+ if active_until.endswith("m"):
22
+ minutes = int(active_until[:-1])
23
+ until_dt = util.now_utc() + timedelta(minutes=minutes)
24
+ elif active_until.endswith("h"):
25
+ hours = int(active_until[:-1])
26
+ until_dt = util.now_utc() + timedelta(hours=hours)
27
+ elif active_until.endswith("d"):
28
+ days = int(active_until[:-1])
29
+ until_dt = util.now_utc() + timedelta(days=days)
30
+ else:
31
+ until_dt = util.parse_utc(active_until)
32
+ except Exception:
33
+ util.fail(util.EXIT_INVALID_RECORD, "invalid_active_until")
34
+
35
+ access = args.access or schema.DEFAULT_ACCESS["alert"]
36
+ meta = {
37
+ "type": "alert",
38
+ "author": profile,
39
+ "access": access,
40
+ "target": target,
41
+ "severity": severity,
42
+ "active_until": util.utc_iso(until_dt),
43
+ }
44
+ schema.apply_defaults(meta, "alert")
45
+
46
+ ok, err = schema.validate(meta, args.body or "")
47
+ if not ok:
48
+ util.fail(util.EXIT_INVALID_RECORD, err)
49
+
50
+ body = args.body or ""
51
+ text = util.join_front_matter(meta, body)
52
+ dirty, hits = scrubber.check(text)
53
+ if dirty:
54
+ try:
55
+ from . import quarantine as _q
56
+ _q.quarantine_payload(profile, text, hits)
57
+ except Exception:
58
+ pass
59
+ util.fail_hint(util.EXIT_ERROR, "secret_detected", "secret material detected; remove or redact before sending", "a2a alert set --target x --severity info --active-until 2026-12-31 body", {"hits": hits})
60
+
61
+ created = util.parse_utc(meta["created_at"])
62
+ path = util.record_path("alert", profile, access, created, meta["id"])
63
+ util.write_atomically(path, text)
64
+ util.ok({"id": meta["id"], "target": target, "severity": severity, "active_until": meta["active_until"]}, elapsed_ms=int((time.time() - start) * 1000))
65
+
66
+
67
+ def cmd_alert_list(args: Namespace) -> None:
68
+ start = time.time()
69
+ now = util.now_utc()
70
+ profile = util.resolve_profile(args)
71
+ active = []
72
+ for rec, path in _iter_alerts():
73
+ # v0.5.0 (J-05): единый read-гейт; без acl-поля — прежняя семантика L1
74
+ if not acl_mod.may_read(rec, profile):
75
+ continue
76
+ try:
77
+ until = util.parse_utc(rec["active_until"])
78
+ except Exception:
79
+ continue
80
+ if until > now:
81
+ active.append({
82
+ "id": rec["id"],
83
+ "author": rec.get("author"),
84
+ "target": rec.get("target"),
85
+ "severity": rec.get("severity"),
86
+ "active_until": rec["active_until"],
87
+ })
88
+ util.ok({"active": active}, elapsed_ms=int((time.time() - start) * 1000))
89
+
90
+
91
+ def _iter_alerts():
92
+ for path in util.list_files(util.HOME / "alerts"):
93
+ rec = util.read_record(path)
94
+ if rec:
95
+ yield rec, path
96
+
97
+
98
+ def cmd_alert_purge_expired(args: Namespace) -> None:
99
+ start = time.time()
100
+ profile = util.resolve_profile(args)
101
+ now = util.now_utc()
102
+ dry_run = not bool(getattr(args, "yes", False))
103
+ if getattr(args, "dry_run", False):
104
+ dry_run = True
105
+
106
+ expired = []
107
+ moved = 0
108
+ expired_dir = util.HOME / ".state" / "expired"
109
+ for rec, path in _iter_alerts():
110
+ if not acl_mod.may_read(rec, profile):
111
+ continue
112
+ try:
113
+ until = util.parse_utc(rec["active_until"])
114
+ except Exception:
115
+ continue
116
+ if until > now:
117
+ continue
118
+ entry = {
119
+ "id": rec.get("id"),
120
+ "author": rec.get("author"),
121
+ "target": rec.get("target"),
122
+ "severity": rec.get("severity"),
123
+ "active_until": rec.get("active_until"),
124
+ "path": str(path),
125
+ }
126
+ expired.append(entry)
127
+ if not dry_run:
128
+ expired_dir.mkdir(parents=True, exist_ok=True)
129
+ dest = expired_dir / path.name
130
+ if dest.exists():
131
+ stem = path.stem
132
+ suffix = path.suffix
133
+ i = 1
134
+ while (expired_dir / f"{stem}_{i}{suffix}").exists():
135
+ i += 1
136
+ dest = expired_dir / f"{stem}_{i}{suffix}"
137
+ shutil.move(str(path), str(dest))
138
+ entry["moved_to"] = str(dest)
139
+ moved += 1
140
+
141
+ ids = [e.get("id") for e in expired]
142
+ util.ok({
143
+ "expired": expired,
144
+ "expired_ids": ids,
145
+ "ids": ids,
146
+ "purged": 0 if dry_run else moved,
147
+ "purged_count": 0 if dry_run else moved,
148
+ "moved": moved,
149
+ "moved_count": moved,
150
+ "removed": 0 if dry_run else moved,
151
+ "removed_count": 0 if dry_run else moved,
152
+ "count": len(expired),
153
+ "expired_count": len(expired),
154
+ "dry_run": dry_run,
155
+ }, elapsed_ms=int((time.time() - start) * 1000))
a2a/auditlog.py ADDED
@@ -0,0 +1,44 @@
1
+ """Audit logging for a2a access events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import scrubber, util
10
+
11
+
12
+ def _scrub_value(value: Any) -> Any:
13
+ """v0.6.0 §1: канал audit.details — любое секретное значение редактируется
14
+ до записи (аудит-лог не должен становиться каналом утечки)."""
15
+ if isinstance(value, str):
16
+ clean, _ = scrubber.redact(value)
17
+ return clean
18
+ if isinstance(value, list):
19
+ return [_scrub_value(v) for v in value]
20
+ if isinstance(value, dict):
21
+ return {k: _scrub_value(v) for k, v in value.items()}
22
+ return value
23
+
24
+
25
+ def log(event: str, caller: str, target: str, details: dict[str, Any] | None = None) -> None:
26
+ entry = {
27
+ "ts": util.utc_iso(),
28
+ "event": event,
29
+ "caller": caller,
30
+ "target": scrubber.redact(str(target))[0],
31
+ "details": _scrub_value(details or {}),
32
+ }
33
+ path = util.HOME / ".state" / "audit.log"
34
+ util.ensure_dir(path)
35
+ with open(path, "a", encoding="utf-8") as f:
36
+ f.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
37
+
38
+
39
+ def read_logs() -> list[dict[str, Any]]:
40
+ path = util.HOME / ".state" / "audit.log"
41
+ if not path.exists():
42
+ return []
43
+ lines = path.read_text(encoding="utf-8").strip().splitlines()
44
+ return [json.loads(line) for line in lines if line.strip()]
a2a/backup.py ADDED
@@ -0,0 +1,193 @@
1
+ """Bus backup/restore (v0.6.0 §5, G-06).
2
+
3
+ `a2a backup [--dest DIR]` — tar.gz шины (без .state, без самого каталога backups)
4
+ с SHA256SUMS.txt внутри; имя файла — по дню (идемпотентно: повтор в тот же день
5
+ перезаписывает дневной архив).
6
+
7
+ `a2a backup --restore FILE [--yes]` — ПО УМОЛЧАНИЮ dry-run: отчёт со списком
8
+ конфликтов ничего не меняет на диске. --yes применяет, НО никогда не затирает
9
+ файл, чей локальный контент отличается И новее архивного — такие цели уходят в
10
+ conflicts и пропускаются. Members с путями-ловушками (абсолютные, ../) — unsafe,
11
+ не извлекаются. Целостность: содержимое сверяется с SHA256SUMS.txt внутри архива.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import tarfile
19
+ import time
20
+ from io import BytesIO
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from . import util
25
+
26
+ EXCLUDE_TOP = {".state", "backups", "wake"}
27
+ SUMS_NAME = "SHA256SUMS.txt"
28
+
29
+
30
+ def _iter_bus_files():
31
+ home = util.HOME
32
+ if not home.is_dir():
33
+ return
34
+ for p in sorted(home.rglob("*")):
35
+ if not p.is_file():
36
+ continue
37
+ rel = p.relative_to(home)
38
+ if rel.parts and rel.parts[0] in EXCLUDE_TOP:
39
+ continue
40
+ if p.name.endswith(".tmp"):
41
+ continue
42
+ yield p, str(rel)
43
+
44
+
45
+ def _write_bytes(path: Path, data: bytes) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ fd, tmp_name = tempfile_mkstemp(str(path.parent), path.name + ".", ".tmp")
48
+ with os.fdopen(fd, "wb") as fh:
49
+ fh.write(data)
50
+ fh.flush()
51
+ os.fsync(fh.fileno())
52
+ os.replace(tmp_name, path)
53
+
54
+
55
+ def tempfile_mkstemp(dir_: str, prefix: str, suffix: str) -> tuple[int, str]:
56
+ import tempfile
57
+ return tempfile.mkstemp(dir=dir_, prefix=prefix, suffix=suffix)
58
+
59
+
60
+ def create(dest: str | None = None) -> dict:
61
+ dest_dir = Path(dest).expanduser() if dest else util.HOME / "backups"
62
+ dest_dir.mkdir(parents=True, exist_ok=True)
63
+ name = f"a2a-bus-{util.now_utc().strftime('%Y-%m-%d')}.tar.gz"
64
+ target = dest_dir / name
65
+ tmp = dest_dir / f"{name}.{os.getpid()}.tmp"
66
+ rows: list[str] = []
67
+ count = 0
68
+ with tarfile.open(tmp, "w:gz") as tf:
69
+ for p, rel in _iter_bus_files():
70
+ try:
71
+ data = p.read_bytes()
72
+ except Exception:
73
+ continue
74
+ sha = hashlib.sha256(data).hexdigest()
75
+ ti = tarfile.TarInfo(rel)
76
+ ti.size = len(data)
77
+ ti.mtime = p.stat().st_mtime
78
+ tf.addfile(ti, BytesIO(data))
79
+ rows.append(f"{sha} {rel}")
80
+ count += 1
81
+ sums = ("\n".join(sorted(rows)) + "\n").encode("utf-8")
82
+ ti = tarfile.TarInfo(SUMS_NAME)
83
+ ti.size = len(sums)
84
+ ti.mtime = time.time()
85
+ tf.addfile(ti, BytesIO(sums))
86
+ os.replace(tmp, target)
87
+ digest = hashlib.sha256(target.read_bytes()).hexdigest()
88
+ util.write_atomically(Path(str(target) + ".sha256"), f"{digest} {name}\n")
89
+ return {"archive": str(target), "records": count,
90
+ "bytes": target.stat().st_size, "sha256": digest}
91
+
92
+
93
+ def _safe_member(name: str) -> bool:
94
+ if not name or name.startswith("/"):
95
+ return False
96
+ parts = Path(name).parts
97
+ return ".." not in parts
98
+
99
+
100
+ def restore(archive: str, yes: bool = False) -> dict:
101
+ apath = Path(archive).expanduser()
102
+ if not apath.is_file():
103
+ util.fail(util.EXIT_USAGE_ERROR, "backup_not_found", {"path": str(apath)})
104
+ try:
105
+ tf = tarfile.open(apath, "r:gz")
106
+ except Exception as exc:
107
+ util.fail(util.EXIT_USAGE_ERROR, "backup_unreadable", {"error": str(exc)})
108
+ report: dict[str, Any] = {"archive": str(apath), "dry_run": not yes,
109
+ "restored": [], "would_extract": [], "conflicts": [],
110
+ "skipped_identical": [], "unsafe": [], "integrity_failed": []}
111
+ with tf:
112
+ names = [m.name for m in tf.getmembers() if m.isfile()]
113
+ sums: dict[str, str] = {}
114
+ if SUMS_NAME in names:
115
+ fh = tf.extractfile(SUMS_NAME)
116
+ if fh:
117
+ for line in fh.read().decode("utf-8", "replace").splitlines():
118
+ if " " in line:
119
+ sha, nm = line.split(" ", 1)
120
+ sums[nm] = sha
121
+ for m in tf.getmembers():
122
+ if not m.isfile() or m.name == SUMS_NAME:
123
+ continue
124
+ if not _safe_member(m.name):
125
+ report["unsafe"].append(m.name)
126
+ continue
127
+ fh = tf.extractfile(m)
128
+ if fh is None:
129
+ report["integrity_failed"].append(m.name)
130
+ continue
131
+ data = fh.read()
132
+ want = sums.get(m.name)
133
+ if want and hashlib.sha256(data).hexdigest() != want:
134
+ report["integrity_failed"].append(m.name)
135
+ continue
136
+ target = util.HOME / m.name
137
+ if target.is_file():
138
+ same = False
139
+ try:
140
+ same = target.read_bytes() == data
141
+ except Exception:
142
+ same = False
143
+ if same:
144
+ report["skipped_identical"].append(m.name)
145
+ continue
146
+ if target.stat().st_mtime > m.mtime:
147
+ # контент отличается И диск новее — никогда не затираем
148
+ report["conflicts"].append({
149
+ "path": m.name,
150
+ "disk_mtime": util.utc_iso(datetime_from_ts(target.stat().st_mtime)),
151
+ "archive_mtime": util.utc_iso(datetime_from_ts(m.mtime)),
152
+ })
153
+ continue
154
+ if not yes:
155
+ report["would_extract"].append(m.name)
156
+ continue
157
+ _write_bytes(target, data)
158
+ report["restored"].append(m.name)
159
+ return {
160
+ "archive": report["archive"],
161
+ "dry_run": report["dry_run"],
162
+ "restored": len(report["restored"]),
163
+ "restored_paths": report["restored"],
164
+ "would_extract": len(report["would_extract"]),
165
+ "would_extract_paths": report["would_extract"],
166
+ "conflicts": report["conflicts"],
167
+ "skipped_identical": len(report["skipped_identical"]),
168
+ "unsafe": report["unsafe"],
169
+ "integrity_failed": report["integrity_failed"],
170
+ }
171
+
172
+
173
+ def datetime_from_ts(ts: float):
174
+ from datetime import datetime, timezone
175
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
176
+
177
+
178
+ def cmd_backup(args) -> None:
179
+ start = time.time()
180
+ from . import auditlog
181
+ profile = util.resolve_profile(args)
182
+ restore_target = getattr(args, "restore", None)
183
+ if restore_target:
184
+ yes = bool(getattr(args, "yes", False))
185
+ result = restore(restore_target, yes=yes)
186
+ auditlog.log("backup_restore", profile, str(restore_target),
187
+ {"dry_run": result["dry_run"], "restored": result["restored"],
188
+ "conflicts": len(result["conflicts"]), "unsafe": len(result["unsafe"])})
189
+ util.ok(result, elapsed_ms=int((time.time() - start) * 1000))
190
+ result = create(getattr(args, "dest", None))
191
+ auditlog.log("backup_create", profile, result["archive"],
192
+ {"records": result["records"]})
193
+ util.ok(result, elapsed_ms=int((time.time() - start) * 1000))
a2a/cards.py ADDED
@@ -0,0 +1,58 @@
1
+ """a2a.cards — P1-7: машинная проверка Agent Cards и gate вызовов.
2
+
3
+ Канон реализации валидации/правил — bin/validate_cards.py
4
+ один исходный текст; здесь только мост к каталогу cards() внутри A2A_HOME и
5
+ обёртка запрета с аудит-событием (fail-closed).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import util
14
+
15
+ _CANON = Path("bin/validate_cards.py")
16
+
17
+
18
+ def _impl():
19
+ spec = importlib.util.spec_from_file_location("a2a_validate_cards_impl", _CANON)
20
+ if spec is None or spec.loader is None:
21
+ raise RuntimeError("validate_cards.py canon not loadable")
22
+ m = importlib.util.module_from_spec(spec)
23
+ spec.loader.exec_module(m)
24
+ return m
25
+
26
+
27
+ def cards_dir() -> Path:
28
+ return util.resolve_home() / "cards"
29
+
30
+
31
+ def validate_all() -> tuple[bool, dict]:
32
+ impl = _impl()
33
+ return impl.validate_dir(cards_dir())
34
+
35
+
36
+ def who_can(task_token: str) -> list[dict]:
37
+ impl = _impl()
38
+ out = []
39
+ for f in sorted(cards_dir().glob("*.json")):
40
+ import json
41
+ card = json.loads(f.read_text(encoding="utf-8"))
42
+ caps = [c.lower() for c in card.get("capabilities", [])]
43
+ if any(task_token.lower() in c for c in caps):
44
+ out.append({"profile": card["name"], "capabilities": card["capabilities"],
45
+ "status": card["status"]})
46
+ return out
47
+
48
+
49
+ def gate_call(caller: str, method: str, target: str, auditlog_mod) -> tuple[bool, str]:
50
+ """Fail-closed проверка вызова против карточки получателя.
51
+
52
+ Отказ пишется в audit log (event=card_denied) — как требует приёмка P1-7.
53
+ """
54
+ impl = _impl()
55
+ allowed, reason = impl.can_call(caller, method, target, cards_dir())
56
+ if not allowed:
57
+ auditlog_mod.log("card_denied", caller, target, {"method": method, "reason": reason})
58
+ return allowed, reason