packet-tracer-skill 0.2.3 → 0.3.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +491 -73
  2. package/README.md +575 -442
  3. package/SKILL.md +513 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/github-launch-ops-0.2.3.md +37 -0
  6. package/docs/github-metadata.md +6 -4
  7. package/docs/hero-demo-plan.md +1 -1
  8. package/docs/home-iot-donor-proof.md +4 -4
  9. package/docs/l2-security-qos-proof.md +1 -1
  10. package/docs/packet-tracer-feature-gap-atlas.md +4 -4
  11. package/docs/post-launch-follow-up.md +9 -5
  12. package/docs/proof-readiness-dashboard.md +69 -0
  13. package/docs/publish-preview-roadmap.md +6 -5
  14. package/docs/release-checklist.md +17 -8
  15. package/docs/release-notes-0.2.4.md +20 -0
  16. package/docs/runtime-truth.md +33 -8
  17. package/docs/security-edge-deepening-proof.md +1 -1
  18. package/examples/README.md +98 -69
  19. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  20. package/examples/gallery.md +94 -6
  21. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  22. package/examples/index.json +932 -4
  23. package/examples/local-sample-evidence.json +24 -0
  24. package/examples/proof-cards.json +117 -0
  25. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  26. package/package.json +61 -53
  27. package/pytest.ini +9 -0
  28. package/references/packettracer-sample-catalog.json +45287 -4525
  29. package/references/packettracer-sample-catalog.md +599 -259
  30. package/references/proof-readiness-candidates.json +352 -0
  31. package/scripts/build_examples_index.py +228 -35
  32. package/scripts/build_sample_catalog.py +62 -40
  33. package/scripts/corpus_runner.py +430 -0
  34. package/scripts/coverage_matrix.py +1842 -1812
  35. package/scripts/donor_cache.py +354 -0
  36. package/scripts/donor_diagnostics.py +3 -1
  37. package/scripts/generate_pkt.py +12738 -4228
  38. package/scripts/intent_parser.py +2269 -1657
  39. package/scripts/lab_coherence.py +455 -0
  40. package/scripts/local_donors.py +340 -0
  41. package/scripts/packet_tracer_env.py +846 -391
  42. package/scripts/pkt_annotate.py +218 -0
  43. package/scripts/pkt_codec.py +420 -181
  44. package/scripts/pkt_editor.py +2487 -1703
  45. package/scripts/pkt_transformer.py +1170 -727
  46. package/scripts/pkt_verify.py +461 -0
  47. package/scripts/runtime_doctor.py +80 -29
  48. package/scripts/sample_catalog.py +1425 -1250
  49. package/scripts/twofish_diagnostics.py +48 -31
  50. package/scripts/usage_ledger.py +218 -0
  51. package/scripts/vendor/README.md +44 -37
  52. package/scripts/vendor/twofish_pure.py +321 -0
  53. package/scripts/workspace_repair.py +548 -508
  54. package/templates/pt900/donors/README.md +15 -0
@@ -0,0 +1,455 @@
1
+ """Cross-check the facts a lab states about itself in more than one place.
2
+
3
+ Every defect this project has paid for has the same shape: one fact derived
4
+ twice, in two passes, with nothing comparing the derivations. A port's VLAN and
5
+ its host's address. A pool's network and the interface that serves it. A
6
+ bundle's members and the cables that join them. Each half read correctly on its
7
+ own, the lab opened, every static check passed, and nothing could reach
8
+ anything.
9
+
10
+ The checks below are not a style guide. Each one is a contradiction that was
11
+ measured in a generated lab and cost a working network:
12
+
13
+ * a printer in VLAN 200 holding a 192.168.110.x address, because the port's
14
+ VLAN and the host's address were assigned by different passes
15
+ * four routers carrying 10.10.10.1 as a real interface address while it is
16
+ also the HSRP virtual address on a fifth
17
+ * `channel-group 1 mode on` on a port whose peer was not bundling, which took
18
+ the switch behind it off the network
19
+ * a DHCP pool for a network no interface serves
20
+ * two cables on one switch port
21
+ * a config block for an interface the device does not have
22
+
23
+ Findings are reported, never repaired: a checker that fixes what it finds stops
24
+ being able to tell you whether the thing it checks is working.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import xml.etree.ElementTree as ET
31
+ from dataclasses import dataclass
32
+
33
+ SWITCH_TYPES = {"Switch", "MultiLayerSwitch"}
34
+ HOST_TYPES = {"Pc", "Laptop", "Server", "Printer", "IpPhone", "Tablet", "Smartphone"}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Finding:
39
+ """One contradiction, named by the two things that disagree."""
40
+
41
+ kind: str
42
+ where: str
43
+ detail: str
44
+
45
+ def __str__(self) -> str:
46
+ return f"{self.kind}: {self.where} -- {self.detail}"
47
+
48
+
49
+ def _address_to_int(text: str) -> int | None:
50
+ parts = (text or "").strip().split(".")
51
+ if len(parts) != 4:
52
+ return None
53
+ value = 0
54
+ for part in parts:
55
+ if not part.isdigit() or not 0 <= int(part) <= 255:
56
+ return None
57
+ value = (value << 8) | int(part)
58
+ return value
59
+
60
+
61
+ def _network_of(address: str, mask: str) -> tuple[int, int] | None:
62
+ left, right = _address_to_int(address), _address_to_int(mask)
63
+ if left is None or right is None:
64
+ return None
65
+ return left & right, right
66
+
67
+
68
+ def _name(device: ET.Element) -> str:
69
+ return (device.findtext("./ENGINE/NAME") or "").strip()
70
+
71
+
72
+ def _kind(device: ET.Element) -> str:
73
+ return (device.findtext("./ENGINE/TYPE") or "").strip()
74
+
75
+
76
+ def _config_blocks(device: ET.Element) -> dict[str, list[str]]:
77
+ """Interface name -> the lines inside its block, in file order."""
78
+ blocks: dict[str, list[str]] = {}
79
+ current = ""
80
+ for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
81
+ text = (node.text or "").strip()
82
+ if text.startswith("interface "):
83
+ current = text.split(None, 1)[1]
84
+ blocks.setdefault(current, [])
85
+ elif current and text and not text.startswith("!"):
86
+ blocks.setdefault(current, []).append(text)
87
+ elif text.startswith("!"):
88
+ current = ""
89
+ return blocks
90
+
91
+
92
+ def _interface_addresses(device: ET.Element) -> list[tuple[str, str, str]]:
93
+ """(port, address, mask) for every configured interface address."""
94
+ found: list[tuple[str, str, str]] = []
95
+ for port, body in _config_blocks(device).items():
96
+ for line in body:
97
+ match = re.match(r"^ip address (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$", line)
98
+ if match:
99
+ found.append((port, match.group(1), match.group(2)))
100
+ return found
101
+
102
+
103
+ def _standby_addresses(device: ET.Element) -> list[tuple[str, str]]:
104
+ """(port, virtual address) for every HSRP group."""
105
+ found: list[tuple[str, str]] = []
106
+ for port, body in _config_blocks(device).items():
107
+ for line in body:
108
+ match = re.match(r"^standby \d+ ip (\d+\.\d+\.\d+\.\d+)$", line)
109
+ if match:
110
+ found.append((port, match.group(1)))
111
+ return found
112
+
113
+
114
+ def _host_addresses(device: ET.Element) -> list[tuple[str, str, str]]:
115
+ """(port name, address, mask) a host really uses.
116
+
117
+ A port with `PORT_DHCP_ENABLE` set is a DHCP client: Packet Tracer ignores
118
+ whatever address and gateway the file still carries and leases new ones at
119
+ runtime. Reading those stale fields made the checker report 82 hosts in the
120
+ enterprise lab as pointing at a gateway nobody answered for, when 78 of
121
+ them were simply waiting for a lease. The checker had the defect it looks
122
+ for -- a fact read from one place while the device is governed by another.
123
+ """
124
+ found: list[tuple[str, str, str]] = []
125
+ for port in device.findall(".//PORT"):
126
+ if (port.findtext("PORT_DHCP_ENABLE") or "").strip().lower() == "true":
127
+ continue
128
+ address = (port.findtext("IP") or "").strip()
129
+ mask = (port.findtext("SUBNET") or "").strip()
130
+ if address and address != "0.0.0.0":
131
+ found.append(((port.findtext("NAME") or "").strip(), address, mask))
132
+ return found
133
+
134
+
135
+ def _cabled_ports(root: ET.Element) -> dict[tuple[str, str], list[tuple[str, str]]]:
136
+ """(device, port) -> the far ends cabled to it. More than one is a defect."""
137
+ by_ref = {
138
+ (device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
139
+ for device in root.findall(".//DEVICES/DEVICE")
140
+ }
141
+ ends: dict[tuple[str, str], list[tuple[str, str]]] = {}
142
+ for link in root.findall(".//LINKS/LINK"):
143
+ cable = link.find("./CABLE")
144
+ if cable is None:
145
+ continue
146
+ refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
147
+ ports = [node.text or "" for node in cable.findall("PORT")]
148
+ if len(ports) < 2:
149
+ continue
150
+ left, right = by_ref.get(refs[0]), by_ref.get(refs[1])
151
+ if left is None or right is None:
152
+ continue
153
+ ends.setdefault((_name(left), ports[0]), []).append((_name(right), ports[1]))
154
+ ends.setdefault((_name(right), ports[1]), []).append((_name(left), ports[0]))
155
+ return ends
156
+
157
+
158
+ def _check_addresses_are_unique(devices: list[ET.Element]) -> list[Finding]:
159
+ """No address may be held by two interfaces, or double as a virtual one.
160
+
161
+ Measured on the enterprise lab: R6, R7 and R8 each carried 10.10.10.1 on a
162
+ real subinterface while R1 offered the same address as its HSRP virtual
163
+ gateway. Nothing complained, because the pass that wrote the subinterfaces
164
+ and the pass that wrote HSRP each read correctly on its own.
165
+ """
166
+ real: dict[str, list[str]] = {}
167
+ virtual: dict[str, list[str]] = {}
168
+ for device in devices:
169
+ for port, address, _mask in _interface_addresses(device):
170
+ real.setdefault(address, []).append(f"{_name(device)}:{port}")
171
+ if _kind(device) in HOST_TYPES:
172
+ # A router's PORT node mirrors the address its config already
173
+ # states. Counting both made every router interface look like two
174
+ # holders of one address -- the checker's own version of the defect
175
+ # it looks for.
176
+ for _port, address, _mask in _host_addresses(device):
177
+ real.setdefault(address, []).append(_name(device))
178
+ for port, address in _standby_addresses(device):
179
+ virtual.setdefault(address, []).append(f"{_name(device)}:{port}")
180
+
181
+ findings: list[Finding] = []
182
+ for address, holders in sorted(real.items()):
183
+ if len(holders) > 1:
184
+ findings.append(
185
+ Finding("duplicate_address", address, f"held by {', '.join(sorted(holders))}")
186
+ )
187
+ if address in virtual:
188
+ findings.append(
189
+ Finding(
190
+ "real_address_is_also_virtual",
191
+ address,
192
+ f"real on {', '.join(sorted(holders))}; HSRP virtual on "
193
+ f"{', '.join(sorted(virtual[address]))}",
194
+ )
195
+ )
196
+ return findings
197
+
198
+
199
+ def _check_ports_carry_one_cable(root: ET.Element) -> list[Finding]:
200
+ """One socket, one cable. Two clones once shared a port and the lab refused."""
201
+ findings: list[Finding] = []
202
+ for (device, port), ends in sorted(_cabled_ports(root).items()):
203
+ if len(ends) > 1:
204
+ joined = ", ".join(f"{name}:{far}" for name, far in sorted(ends))
205
+ findings.append(Finding("port_double_booked", f"{device}:{port}", f"cabled to {joined}"))
206
+ return findings
207
+
208
+
209
+ def _check_config_names_real_ports(devices: list[ET.Element]) -> list[Finding]:
210
+ """Packet Tracer refuses a lab naming an interface the device does not have."""
211
+ from pkt_transformer import port_exists
212
+
213
+ findings: list[Finding] = []
214
+ for device in devices:
215
+ for port in _config_blocks(device):
216
+ if port.startswith(("Vlan", "Loopback", "Port-channel", "Tunnel")) or "." in port:
217
+ continue
218
+ if not port_exists(device, port):
219
+ findings.append(
220
+ Finding("port_not_on_device", f"{_name(device)}:{port}", f"a {_kind(device)} has no such interface")
221
+ )
222
+ return findings
223
+
224
+
225
+ def _check_trunk_ends_agree(root: ET.Element, devices: list[ET.Element]) -> list[Finding]:
226
+ """A native VLAN mismatch makes spanning tree block a cabled, configured port."""
227
+ by_name = {_name(device): device for device in devices}
228
+ blocks = {name: _config_blocks(device) for name, device in by_name.items()}
229
+
230
+ def native(name: str, port: str) -> str:
231
+ for line in blocks.get(name, {}).get(port, []):
232
+ match = re.match(r"^switchport trunk native vlan (\d+)$", line)
233
+ if match:
234
+ return match.group(1)
235
+ return "1"
236
+
237
+ def is_trunk(name: str, port: str) -> bool:
238
+ return "switchport mode trunk" in blocks.get(name, {}).get(port, [])
239
+
240
+ findings: list[Finding] = []
241
+ seen: set[tuple[str, str]] = set()
242
+ for (device, port), ends in sorted(_cabled_ports(root).items()):
243
+ for far_device, far_port in ends:
244
+ key = tuple(sorted(((device, port), (far_device, far_port))))
245
+ if key in seen:
246
+ continue
247
+ seen.add(key)
248
+ if _kind(by_name.get(device, ET.Element("x"))) not in SWITCH_TYPES:
249
+ continue
250
+ if _kind(by_name.get(far_device, ET.Element("x"))) not in SWITCH_TYPES:
251
+ continue
252
+ if not (is_trunk(device, port) and is_trunk(far_device, far_port)):
253
+ continue
254
+ here, there = native(device, port), native(far_device, far_port)
255
+ if here != there:
256
+ findings.append(
257
+ Finding(
258
+ "native_vlan_mismatch",
259
+ f"{device}:{port} <-> {far_device}:{far_port}",
260
+ f"native VLAN {here} against {there}",
261
+ )
262
+ )
263
+ return findings
264
+
265
+
266
+ def _check_etherchannel_members_are_paired(root: ET.Element, devices: list[ET.Element]) -> list[Finding]:
267
+ """A channel-group whose peer does not bundle takes the switch off the network."""
268
+ blocks = {_name(device): _config_blocks(device) for device in devices}
269
+
270
+ def bundles(name: str, port: str) -> bool:
271
+ return any(line.startswith("channel-group ") for line in blocks.get(name, {}).get(port, []))
272
+
273
+ findings: list[Finding] = []
274
+ cabled = _cabled_ports(root)
275
+ for (device, port), ends in sorted(cabled.items()):
276
+ if not bundles(device, port):
277
+ continue
278
+ if not ends:
279
+ findings.append(Finding("etherchannel_member_uncabled", f"{device}:{port}", "bundled but no cable"))
280
+ continue
281
+ for far_device, far_port in ends:
282
+ if not bundles(far_device, far_port):
283
+ findings.append(
284
+ Finding(
285
+ "etherchannel_peer_does_not_bundle",
286
+ f"{device}:{port}",
287
+ f"peer {far_device}:{far_port} carries no channel-group",
288
+ )
289
+ )
290
+ for device in devices:
291
+ for port, body in _config_blocks(device).items():
292
+ if any(line.startswith("channel-group ") for line in body) and (_name(device), port) not in cabled:
293
+ findings.append(
294
+ Finding("etherchannel_member_uncabled", f"{_name(device)}:{port}", "bundled but no cable")
295
+ )
296
+ return findings
297
+
298
+
299
+ def _check_hosts_can_reach_their_gateway(devices: list[ET.Element]) -> list[Finding]:
300
+ """A host's address, its mask and its gateway have to describe one network.
301
+
302
+ Measured on the enterprise lab: a workstation sat on a port in VLAN 200 and
303
+ carried 192.168.110.12, because the pass that placed ports in VLANs and the
304
+ pass that handed out addresses each had its own plan and neither read the
305
+ other's. The lab opened and the host could reach nothing off its subnet.
306
+ """
307
+ served: set[tuple[int, int]] = set()
308
+ gateways: set[str] = set()
309
+ for device in devices:
310
+ for _port, address, mask in _interface_addresses(device):
311
+ network = _network_of(address, mask)
312
+ if network is not None:
313
+ served.add(network)
314
+ gateways.add(address)
315
+ for _port, address in _standby_addresses(device):
316
+ gateways.add(address)
317
+ # A home router's LAN address is a setting, not a config line, so
318
+ # reading only running configs made it invisible: the checker went on
319
+ # reporting `gateway_answers_for_nobody` against a router that had just
320
+ # been moved onto exactly that address. Packet Tracer puts it on
321
+ # `Vlan1`, which the live device reports as a real interface.
322
+ lan = (device.findtext("./ENGINE/LAN_IP_ADDRESS") or "").strip()
323
+ lan_mask = (device.findtext("./ENGINE/LAN_SUBNET_MASK") or "").strip()
324
+ if lan and lan != "0.0.0.0":
325
+ gateways.add(lan)
326
+ network = _network_of(lan, lan_mask) if lan_mask else None
327
+ if network is not None:
328
+ served.add(network)
329
+
330
+ findings: list[Finding] = []
331
+ for device in devices:
332
+ if _kind(device) not in HOST_TYPES:
333
+ continue
334
+ gateway = (device.findtext(".//GATEWAY") or "").strip()
335
+ for _port, address, mask in _host_addresses(device):
336
+ if not gateway or gateway == "0.0.0.0":
337
+ findings.append(Finding("host_without_gateway", _name(device), f"{address} has no default gateway"))
338
+ continue
339
+ here, there = _network_of(address, mask), _network_of(gateway, mask)
340
+ if here is None or there is None or here != there:
341
+ findings.append(
342
+ Finding(
343
+ "gateway_off_host_subnet",
344
+ _name(device),
345
+ f"{address}/{mask} cannot reach gateway {gateway}",
346
+ )
347
+ )
348
+ continue
349
+ if gateway not in gateways:
350
+ findings.append(
351
+ Finding("gateway_answers_for_nobody", _name(device), f"no interface holds {gateway}")
352
+ )
353
+ return findings
354
+
355
+
356
+ def _check_pools_have_an_interface(devices: list[ET.Element]) -> list[Finding]:
357
+ """A pool for a network no interface serves hands out unreachable addresses."""
358
+ served: set[tuple[int, int]] = set()
359
+ for device in devices:
360
+ for _port, address, mask in _interface_addresses(device):
361
+ network = _network_of(address, mask)
362
+ if network is not None:
363
+ served.add(network)
364
+
365
+ findings: list[Finding] = []
366
+ for device in devices:
367
+ pool = ""
368
+ for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
369
+ text = (node.text or "").strip()
370
+ if text.startswith("ip dhcp pool "):
371
+ pool = text.split(None, 3)[3] if len(text.split()) > 3 else text
372
+ continue
373
+ match = re.match(r"^network (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$", text)
374
+ if match and pool:
375
+ network = _network_of(match.group(1), match.group(2))
376
+ if network is not None and network not in served:
377
+ findings.append(
378
+ Finding(
379
+ "pool_without_interface",
380
+ f"{_name(device)} pool {pool}",
381
+ f"{match.group(1)} {match.group(2)} matches no interface",
382
+ )
383
+ )
384
+ pool = ""
385
+ return findings
386
+
387
+
388
+ def _check_no_interface_is_declared_twice(devices: list[ET.Element]) -> list[Finding]:
389
+ """One interface, one block.
390
+
391
+ IOS applies repeated blocks in order and keeps the last; every reader that
392
+ scans for the first sees a different device. Measured on the enterprise
393
+ lab: the standby router had each subinterface written five times, once per
394
+ build, because a generated lab becomes the donor for the next one and the
395
+ HSRP pass appended instead of merging. `10.10.40.1`, then `10.10.40.3`,
396
+ then `10.10.40.1` again -- all of them true of the same file.
397
+ """
398
+ findings: list[Finding] = []
399
+ for device in devices:
400
+ counts: dict[str, int] = {}
401
+ for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
402
+ text = (node.text or "").strip()
403
+ if text.startswith("interface "):
404
+ port = text.split(None, 1)[1]
405
+ counts[port] = counts.get(port, 0) + 1
406
+ for port, count in sorted(counts.items()):
407
+ if count > 1:
408
+ findings.append(
409
+ Finding("interface_declared_twice", f"{_name(device)}:{port}", f"{count} blocks")
410
+ )
411
+ return findings
412
+
413
+
414
+ def _check_no_pool_is_declared_twice(devices: list[ET.Element]) -> list[Finding]:
415
+ """IOS merges two pools of one name and keeps the last."""
416
+ findings: list[Finding] = []
417
+ for device in devices:
418
+ counts: dict[str, int] = {}
419
+ for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
420
+ text = (node.text or "").strip()
421
+ if text.startswith("ip dhcp pool "):
422
+ name = text[len("ip dhcp pool ") :].strip()
423
+ counts[name] = counts.get(name, 0) + 1
424
+ for name, count in sorted(counts.items()):
425
+ if count > 1:
426
+ findings.append(
427
+ Finding("dhcp_pool_declared_twice", f"{_name(device)} pool {name}", f"{count} blocks")
428
+ )
429
+ return findings
430
+
431
+
432
+ def check_lab_coherence(root: ET.Element) -> list[Finding]:
433
+ """Every contradiction the lab states about itself, most structural first."""
434
+ devices = root.findall(".//DEVICES/DEVICE")
435
+ return [
436
+ *_check_no_interface_is_declared_twice(devices),
437
+ *_check_no_pool_is_declared_twice(devices),
438
+ *_check_config_names_real_ports(devices),
439
+ *_check_ports_carry_one_cable(root),
440
+ *_check_addresses_are_unique(devices),
441
+ *_check_trunk_ends_agree(root, devices),
442
+ *_check_etherchannel_members_are_paired(root, devices),
443
+ *_check_hosts_can_reach_their_gateway(devices),
444
+ *_check_pools_have_an_interface(devices),
445
+ ]
446
+
447
+
448
+ def summarise(findings: list[Finding]) -> str:
449
+ if not findings:
450
+ return "coherent: no contradictions found"
451
+ counts: dict[str, int] = {}
452
+ for finding in findings:
453
+ counts[finding.kind] = counts.get(finding.kind, 0) + 1
454
+ parts = ", ".join(f"{kind} {count}" for kind, count in sorted(counts.items()))
455
+ return f"{len(findings)} contradiction(s): {parts}"