packet-tracer-skill 0.2.3 → 0.3.0
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.
- package/CHANGELOG.md +424 -73
- package/README.md +557 -442
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/l2-security-qos-proof.md +1 -1
- package/docs/packet-tracer-feature-gap-atlas.md +4 -4
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +17 -8
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +1 -1
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -53
- package/pytest.ini +9 -0
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1812
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/generate_pkt.py +8762 -4228
- package/scripts/intent_parser.py +2242 -1657
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1703
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1250
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A local cache of donor labs, so generation stops needing Packet Tracer installed.
|
|
3
|
+
|
|
4
|
+
The codec is self-contained, but generation still reads device and link
|
|
5
|
+
prototypes out of real Packet Tracer saves. That made a local install a hard
|
|
6
|
+
requirement for CI, servers, and any machine other than the one where labs are
|
|
7
|
+
authored.
|
|
8
|
+
|
|
9
|
+
The cache is deliberately shaped like a Packet Tracer `saves/` tree, using the
|
|
10
|
+
same relative paths. `resolve_sample_path()` and `require_packet_tracer_saves_root()`
|
|
11
|
+
are already the single seams every consumer goes through, so a cache that
|
|
12
|
+
answers those seams needs no changes anywhere downstream. A separate "where do
|
|
13
|
+
samples live" model would be a second source of truth, which is the defect shape
|
|
14
|
+
this repo has hit repeatedly.
|
|
15
|
+
|
|
16
|
+
Nothing is redistributed: the cache is populated from the user's own licensed
|
|
17
|
+
install and never leaves the machine.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
MANIFEST_VERSION = 1
|
|
30
|
+
MANIFEST_NAME = "manifest.json"
|
|
31
|
+
COVERAGE_DIR = "_donors"
|
|
32
|
+
DEFAULT_COVERAGE_LIMIT = 3
|
|
33
|
+
|
|
34
|
+
# Samples the generation and edit paths name directly. Without these a machine
|
|
35
|
+
# with no Packet Tracer cannot build links, servers, or wireless clients.
|
|
36
|
+
REQUIRED_PROTOTYPE_SAMPLES: tuple[str, ...] = (
|
|
37
|
+
r"01 Networking\FTP\FTP.pkt",
|
|
38
|
+
r"01 Networking\DNS\Multilevel_DNS.pkt",
|
|
39
|
+
r"01 Networking\DHCP\dhcp_reservation.pkt",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Families worth having a donor for beyond what the prototype samples cover.
|
|
43
|
+
COVERAGE_FAMILIES: tuple[str, ...] = ("Router", "Switch", "PC", "Server")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BootstrapReport:
|
|
48
|
+
source_root: str = ""
|
|
49
|
+
cached_paths: list[str] = field(default_factory=list)
|
|
50
|
+
coverage_families: list[str] = field(default_factory=list)
|
|
51
|
+
skipped: list[str] = field(default_factory=list)
|
|
52
|
+
bytes_written: int = 0
|
|
53
|
+
error: str = ""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return not self.error and bool(self.cached_paths)
|
|
58
|
+
|
|
59
|
+
def summary(self) -> str:
|
|
60
|
+
if self.error:
|
|
61
|
+
return f"donor cache not written: {self.error}"
|
|
62
|
+
families = ", ".join(self.coverage_families) or "none"
|
|
63
|
+
return (
|
|
64
|
+
f"Cached {len(self.cached_paths)} donor sample(s) "
|
|
65
|
+
f"({self.bytes_written / 1024:.0f} KB, families: {families}) in {cache_root()}. "
|
|
66
|
+
"Generation no longer needs Packet Tracer on this machine."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cache_enabled() -> bool:
|
|
71
|
+
return (os.getenv("PKT_DONOR_CACHE") or "").strip().lower() not in {"off", "0", "false", "none"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cache_root() -> Path:
|
|
75
|
+
override = (os.getenv("PKT_DONOR_CACHE") or "").strip()
|
|
76
|
+
if override and override.lower() not in {"off", "0", "false", "none", "on", "1", "true"}:
|
|
77
|
+
return Path(override).expanduser()
|
|
78
|
+
return Path.home() / ".pkt" / "saves"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def manifest_path() -> Path:
|
|
82
|
+
return cache_root() / MANIFEST_NAME
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_manifest() -> dict | None:
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(manifest_path().read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
return None
|
|
90
|
+
return data if isinstance(data, dict) else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cache_is_usable(target_version: str | None = None) -> bool:
|
|
94
|
+
"""True when the manifest is valid and every file it lists still exists."""
|
|
95
|
+
if not cache_enabled():
|
|
96
|
+
return False
|
|
97
|
+
manifest = read_manifest()
|
|
98
|
+
if not manifest or manifest.get("manifest_version") != MANIFEST_VERSION:
|
|
99
|
+
return False
|
|
100
|
+
cached_target = str(manifest.get("target_version") or "")
|
|
101
|
+
if target_version and cached_target:
|
|
102
|
+
# Compare by compatibility tier, not string equality. The detected target
|
|
103
|
+
# legitimately differs between a machine with Packet Tracer installed
|
|
104
|
+
# (`9.0.0`, from the install directory) and one without it (`9.0.0.0810`,
|
|
105
|
+
# from the donor's own VERSION). Exact matching here would discard a
|
|
106
|
+
# perfectly good cache on exactly the machine it exists to serve — the
|
|
107
|
+
# same mistake the donor gate itself used to make.
|
|
108
|
+
from packet_tracer_env import donor_compatibility
|
|
109
|
+
|
|
110
|
+
# Deliberately not `donor_tier_is_accepted`: that follows the donor
|
|
111
|
+
# policy, which is `exact` because a generation *base* must be a lab the
|
|
112
|
+
# running install wrote. This check answers a different question — is the
|
|
113
|
+
# cache from the same release line — so it accepts `same_minor` outright.
|
|
114
|
+
if donor_compatibility(cached_target, target_version) not in {"exact", "same_minor"}:
|
|
115
|
+
return False
|
|
116
|
+
entries = manifest.get("cached_paths") or []
|
|
117
|
+
if not entries:
|
|
118
|
+
return False
|
|
119
|
+
root = cache_root()
|
|
120
|
+
return all((root / str(entry)).exists() for entry in entries)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _copy_into_cache(source: Path, relative_path: str, report: BootstrapReport) -> None:
|
|
124
|
+
destination = cache_root() / relative_path
|
|
125
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.copy2(source, destination)
|
|
127
|
+
report.cached_paths.append(relative_path)
|
|
128
|
+
report.bytes_written += destination.stat().st_size
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
HOST_FAMILIES = {"PC", "Server", "Printer", "Laptop", "Tablet", "Smartphone"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _access_score(devices: list, links: list) -> int:
|
|
135
|
+
"""How many hosts hang off switches in this lab.
|
|
136
|
+
|
|
137
|
+
Donor-prune keeps a switch and renames the hosts attached to it, so the
|
|
138
|
+
useful measure of a donor is its access structure, not its raw device count.
|
|
139
|
+
A 33-device industrial lab whose switches carry no PCs is a worse donor than
|
|
140
|
+
a small campus lab with six hosts on a switch — exactly the mistake an
|
|
141
|
+
earlier version of this selection made.
|
|
142
|
+
"""
|
|
143
|
+
from sample_catalog import normalize_device_type
|
|
144
|
+
|
|
145
|
+
kind = {str(device.get("name", "")): normalize_device_type(str(device.get("type", ""))) for device in devices}
|
|
146
|
+
host_links = 0
|
|
147
|
+
router_uplinks = 0
|
|
148
|
+
for link in links:
|
|
149
|
+
left, right = str(link.get("from") or ""), str(link.get("to") or "")
|
|
150
|
+
if not left or not right:
|
|
151
|
+
continue
|
|
152
|
+
pair = {kind.get(left), kind.get(right)}
|
|
153
|
+
if "Switch" in pair and pair & HOST_FAMILIES:
|
|
154
|
+
host_links += 1
|
|
155
|
+
elif "Switch" in pair and "Router" in pair:
|
|
156
|
+
router_uplinks += 1
|
|
157
|
+
# Hosts dominate; a router uplink is worth having but only once or twice.
|
|
158
|
+
# Weighting every uplink equally let a four-router lab with a single PC
|
|
159
|
+
# outrank a campus lab with six hosts on a switch.
|
|
160
|
+
return host_links + min(router_uplinks, 2)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _local_saved_labs(target_version: str | None, limit: int) -> list[Path]:
|
|
164
|
+
"""Labs this Packet Tracer wrote, richest access structure first.
|
|
165
|
+
|
|
166
|
+
These are the only files usable as a generation base: their `<VERSION>` is
|
|
167
|
+
the running build, so Packet Tracer opens what is built from them.
|
|
168
|
+
"""
|
|
169
|
+
from packet_tracer_env import DEFAULT_DONOR_FALLBACKS, _pkt_version
|
|
170
|
+
from pkt_codec import decode_pkt_auto, parse_pkt_xml
|
|
171
|
+
from sample_catalog import normalize_device_type
|
|
172
|
+
|
|
173
|
+
scored: list[tuple[int, Path]] = []
|
|
174
|
+
for directory in DEFAULT_DONOR_FALLBACKS:
|
|
175
|
+
if not directory.exists():
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
candidates = sorted(directory.glob("*.pkt"))[:20]
|
|
179
|
+
except OSError:
|
|
180
|
+
continue
|
|
181
|
+
for candidate in candidates:
|
|
182
|
+
if target_version and _pkt_version(candidate) != target_version:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
root = parse_pkt_xml(decode_pkt_auto(candidate.read_bytes())[0])
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
devices = [
|
|
189
|
+
{
|
|
190
|
+
"name": device.findtext("./ENGINE/NAME") or "",
|
|
191
|
+
"type": normalize_device_type(device.findtext("./ENGINE/TYPE") or ""),
|
|
192
|
+
}
|
|
193
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
194
|
+
]
|
|
195
|
+
ref_to_name = {
|
|
196
|
+
device.findtext("./ENGINE/SAVE_REF_ID") or "": device.findtext("./ENGINE/NAME") or ""
|
|
197
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
198
|
+
}
|
|
199
|
+
links = []
|
|
200
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
201
|
+
cable = link.find("./CABLE")
|
|
202
|
+
if cable is None:
|
|
203
|
+
continue
|
|
204
|
+
links.append(
|
|
205
|
+
{
|
|
206
|
+
"from": ref_to_name.get(cable.findtext("FROM") or "", ""),
|
|
207
|
+
"to": ref_to_name.get(cable.findtext("TO") or "", ""),
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
scored.append((_access_score(devices, links), candidate))
|
|
211
|
+
|
|
212
|
+
scored.sort(key=lambda item: (-item[0], item[1].name))
|
|
213
|
+
return [path for _, path in scored[:limit]]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def select_coverage_donors(
|
|
217
|
+
catalog: list,
|
|
218
|
+
target_version: str | None,
|
|
219
|
+
limit: int = DEFAULT_COVERAGE_LIMIT,
|
|
220
|
+
) -> list[str]:
|
|
221
|
+
"""Pick the best-structured version-compatible lab covering each device family.
|
|
222
|
+
|
|
223
|
+
The catalogue already records devices, version and capability tags, so this
|
|
224
|
+
reads existing evidence rather than scanning the install again.
|
|
225
|
+
"""
|
|
226
|
+
from packet_tracer_env import donor_compatibility, donor_tier_is_accepted
|
|
227
|
+
from sample_catalog import normalize_device_type
|
|
228
|
+
|
|
229
|
+
eligible = []
|
|
230
|
+
for sample in catalog:
|
|
231
|
+
version = getattr(sample, "version", "") or ""
|
|
232
|
+
if target_version and not donor_tier_is_accepted(donor_compatibility(version, target_version)):
|
|
233
|
+
continue
|
|
234
|
+
devices = getattr(sample, "devices", [])
|
|
235
|
+
families = {normalize_device_type(str(device.get("type", ""))) for device in devices}
|
|
236
|
+
eligible.append(
|
|
237
|
+
(
|
|
238
|
+
getattr(sample, "relative_path", ""),
|
|
239
|
+
families,
|
|
240
|
+
_access_score(devices, getattr(sample, "links", [])),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
chosen: list[str] = []
|
|
245
|
+
covered: set[str] = set()
|
|
246
|
+
for family in COVERAGE_FAMILIES:
|
|
247
|
+
if family in covered or len(chosen) >= limit:
|
|
248
|
+
continue
|
|
249
|
+
candidates = [item for item in eligible if family in item[1] and item[0] not in chosen]
|
|
250
|
+
if not candidates:
|
|
251
|
+
continue
|
|
252
|
+
# Best access structure first. A donor exists to be pruned *from*, and
|
|
253
|
+
# what gets reused is a switch with hosts on it, so rank by that rather
|
|
254
|
+
# than by device count or file size.
|
|
255
|
+
relative_path, families, _ = max(
|
|
256
|
+
candidates,
|
|
257
|
+
key=lambda item: (item[2], len(item[1] & set(COVERAGE_FAMILIES)), item[0]),
|
|
258
|
+
)
|
|
259
|
+
chosen.append(relative_path)
|
|
260
|
+
covered |= families
|
|
261
|
+
return chosen
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def bootstrap(
|
|
265
|
+
saves_root: Path,
|
|
266
|
+
catalog: list | None = None,
|
|
267
|
+
target_version: str | None = None,
|
|
268
|
+
limit: int = DEFAULT_COVERAGE_LIMIT,
|
|
269
|
+
) -> BootstrapReport:
|
|
270
|
+
"""Populate the cache from a live Packet Tracer install.
|
|
271
|
+
|
|
272
|
+
Never raises: a cache that cannot be written must not fail a generation that
|
|
273
|
+
would otherwise succeed.
|
|
274
|
+
"""
|
|
275
|
+
report = BootstrapReport(source_root=str(saves_root))
|
|
276
|
+
if not cache_enabled():
|
|
277
|
+
report.error = "disabled by PKT_DONOR_CACHE"
|
|
278
|
+
return report
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
cache_root().mkdir(parents=True, exist_ok=True)
|
|
282
|
+
except OSError as exc:
|
|
283
|
+
report.error = f"cache directory is not writable: {exc}"
|
|
284
|
+
return report
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
for relative_path in REQUIRED_PROTOTYPE_SAMPLES:
|
|
288
|
+
source = saves_root / relative_path
|
|
289
|
+
if source.exists():
|
|
290
|
+
_copy_into_cache(source, relative_path, report)
|
|
291
|
+
else:
|
|
292
|
+
report.skipped.append(relative_path)
|
|
293
|
+
|
|
294
|
+
# Coverage donors must be labs the running install actually wrote.
|
|
295
|
+
# Bundled Cisco samples carry other builds and Packet Tracer refuses to
|
|
296
|
+
# open anything generated from them — relabelling the output does not
|
|
297
|
+
# help, because the donor's structures were never migrated.
|
|
298
|
+
for source in _local_saved_labs(target_version, limit):
|
|
299
|
+
destination_relative = str(Path(COVERAGE_DIR) / source.name)
|
|
300
|
+
_copy_into_cache(source, destination_relative, report)
|
|
301
|
+
report.coverage_families.append(source.stem)
|
|
302
|
+
if not report.coverage_families:
|
|
303
|
+
report.skipped.append(
|
|
304
|
+
f"no locally saved lab at build {target_version} was found to cache as a donor"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
manifest = {
|
|
308
|
+
"manifest_version": MANIFEST_VERSION,
|
|
309
|
+
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
310
|
+
"source_root": str(saves_root),
|
|
311
|
+
"target_version": target_version or "",
|
|
312
|
+
"cached_paths": report.cached_paths,
|
|
313
|
+
"skipped": report.skipped,
|
|
314
|
+
}
|
|
315
|
+
manifest_path().write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
316
|
+
except OSError as exc:
|
|
317
|
+
report.error = str(exc)
|
|
318
|
+
return report
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def missing_requirements_message() -> str:
|
|
322
|
+
"""What to tell a user who has neither Packet Tracer nor a cache."""
|
|
323
|
+
required = "\n ".join(REQUIRED_PROTOTYPE_SAMPLES)
|
|
324
|
+
return (
|
|
325
|
+
"No Packet Tracer install and no donor cache were found. Generation needs real "
|
|
326
|
+
"Packet Tracer saves for device and link prototypes:\n "
|
|
327
|
+
f"{required}\n"
|
|
328
|
+
f"Run any generate command once on a machine that has Packet Tracer to populate "
|
|
329
|
+
f"{cache_root()}, then copy that folder here. Set PACKET_TRACER_SAVES_ROOT to "
|
|
330
|
+
"point at an existing saves tree instead."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cache_status(target_version: str | None = None) -> dict[str, object]:
|
|
335
|
+
"""Cache state for `--doctor`."""
|
|
336
|
+
manifest = read_manifest()
|
|
337
|
+
return {
|
|
338
|
+
"enabled": cache_enabled(),
|
|
339
|
+
"root": str(cache_root()),
|
|
340
|
+
"usable": cache_is_usable(target_version),
|
|
341
|
+
"cached_file_count": len(manifest.get("cached_paths", [])) if manifest else 0,
|
|
342
|
+
"created_at": manifest.get("created_at", "") if manifest else "",
|
|
343
|
+
"source_root": manifest.get("source_root", "") if manifest else "",
|
|
344
|
+
"target_version": manifest.get("target_version", "") if manifest else "",
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main() -> int:
|
|
349
|
+
print(json.dumps(cache_status(), ensure_ascii=False, indent=2))
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
raise SystemExit(main())
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
-
from packet_tracer_env import inspect_packet_tracer_compatibility_donor
|
|
7
|
+
from packet_tracer_env import get_donor_policy, inspect_packet_tracer_compatibility_donor
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def collect_donor_diagnostics() -> dict[str, object]:
|
|
@@ -15,6 +15,8 @@ def collect_donor_diagnostics() -> dict[str, object]:
|
|
|
15
15
|
"donor_path": str(details.resolved_path) if details.resolved_path else "",
|
|
16
16
|
"donor_version": details.donor_version or "",
|
|
17
17
|
"donor_source": details.donor_source or "",
|
|
18
|
+
"donor_policy": get_donor_policy(),
|
|
19
|
+
"compatibility_tier": details.compatibility_tier,
|
|
18
20
|
"status": details.status,
|
|
19
21
|
"message": (
|
|
20
22
|
f"{details.resolved_path} (version {details.donor_version})"
|