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 +5 -0
- a2a/access.py +143 -0
- a2a/alerts.py +155 -0
- a2a/auditlog.py +44 -0
- a2a/backup.py +193 -0
- a2a/cards.py +58 -0
- a2a/cli.py +1024 -0
- a2a/cookbook.py +143 -0
- a2a/digest.py +161 -0
- a2a/dnd_policy.py +52 -0
- a2a/doctor.py +644 -0
- a2a/export.py +194 -0
- a2a/feed_rules.py +273 -0
- a2a/handoff.py +105 -0
- a2a/idempotency.py +153 -0
- a2a/index.py +185 -0
- a2a/journal.py +393 -0
- a2a/locks.py +288 -0
- a2a/mail.py +739 -0
- a2a/metrics.py +103 -0
- a2a/migrate.py +146 -0
- a2a/owner.py +305 -0
- a2a/prune.py +238 -0
- a2a/quarantine.py +191 -0
- a2a/rpc.py +106 -0
- a2a/schema.py +211 -0
- a2a/scrub_config.py +99 -0
- a2a/scrubber.py +124 -0
- a2a/secrets.py +164 -0
- a2a/segments.py +297 -0
- a2a/sessions.py +161 -0
- a2a/snapshot.py +123 -0
- a2a/status.py +404 -0
- a2a/tasks.py +1026 -0
- a2a/thoughts.py +48 -0
- a2a/userfeed.py +110 -0
- a2a/util.py +429 -0
- lnfnt_a2a-0.1.0.dist-info/METADATA +103 -0
- lnfnt_a2a-0.1.0.dist-info/RECORD +43 -0
- lnfnt_a2a-0.1.0.dist-info/WHEEL +4 -0
- lnfnt_a2a-0.1.0.dist-info/entry_points.txt +2 -0
- lnfnt_a2a-0.1.0.dist-info/licenses/LICENSE +202 -0
- lnfnt_a2a-0.1.0.dist-info/licenses/NOTICE +1 -0
a2a/__init__.py
ADDED
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
|